Skip to content

Add APNs VoIP send path for Transport=voip notifications#158

Merged
lieut-data merged 11 commits into
masterfrom
ios-ring
Jun 5, 2026
Merged

Add APNs VoIP send path for Transport=voip notifications#158
lieut-data merged 11 commits into
masterfrom
ios-ring

Conversation

@enahum

@enahum enahum commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Push proxy side of iOS PushKit + CallKit ringing. Adds an APNs VoIP
send path for the new apple_voip_rn{,beta} platforms that the server
emits when a Calls push has a VoIP token registered for the recipient.

Ticket Link

Relates-to: https://mattermost.atlassian.net/browse/MM-68727

enahum added 2 commits May 23, 2026 14:40
When core sends a push tagged apple_voip_rn{,beta}, dispatch it through
the same Apple key as the standard target but emit a VoIP-shaped APNs
request: apns-push-type: voip, priority 10, topic <bundle>.voip, flat
custom keys (channel_id, server_id, post_id, thread_id,
sender_id, id_loaded, ack_id, signature; plus sender_name and
channel_name when populated).
The mobile client uses this to distinguish flavors of a cancel push:
"answered_elsewhere" maps to CXCallEndedReason.answeredElsewhere
(silent dismiss when the user answers on another device); anything
else stays at .unanswered (the caller-hung-up cancel-ring case).
@enahum enahum added the 1: Dev Review Requires review by a core commiter label May 25, 2026
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds iOS VoIP push support: routes on PushNotification.Transport=="voip", builds VoIP APNs requests (PushTypeVOIP, .voip topic, content-available and routing fields), centralizes APNs dispatch, expands metrics with a transport label, redacts tokens in logs, adds tests, README, and a go.mod indirect dependency.

Changes

iOS VoIP Push Notifications

Layer / File(s) Summary
Constants, token redaction, and PushNotification.Transport
server/push_notification.go
Adds redactToken helper, exported constants (PushSubTypeCalls, PushTransportDefault, PushTransportVoIP) and a Transport field on PushNotification.
Metrics: add transport label and helpers
server/metrics.go
Prometheus counters updated to include transport; metrics helper methods refactored to accept and pass transport.
Metrics tests update
server/metrics_test.go
Updates metric tests to call increment helpers with the extra transport argument and explicit failure/removal reasons.
Propagate transport and redact tokens (Android & server)
server/android_notification_server.go, server/server.go
Android and server delivered/ack call-sites updated to pass the new transport metric dimension; device tokens are redacted in logs and retry logging.
Apple: VoIP routing, build, dispatch, and retry
server/apple_notification_server.go
Detects msg.Transport == PushTransportVoIP in SendNotification, adds buildVoIPNotification/sendVoIPNotification, refactors APNs dispatch/response handling into dispatchAndHandleResponse, and updates retry-loop/logging to use redactToken.
Apple VoIP unit tests
server/apple_notification_test.go
Adds tests for transport routing and VoIP APNs envelope and payload assertions, plus marshalPayload helper.
RedactToken unit tests
server/push_notification_test.go
Adds table-driven tests for redactToken behaviour (empty, short, truncated).
README docs and go.mod
README.md, go.mod
Documents VoIP dispatch via transport=voip, APNs VoIP request shape, reuse of existing Apple push settings, iOS app prerequisites; adds indirect dependency github.com/kylelemons/godebug v1.1.0.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding VoIP support for APNs notifications when Transport is set to voip.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description clearly relates to the changeset, detailing the implementation of iOS PushKit VoIP send path for APNs notifications with specific transport routing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ios-ring

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
README.md (1)

7-7: ⚡ Quick win

Document Category forwarding in the VoIP payload contract.

Please add a short note that Category is forwarded for VoIP pushes (and omitted when empty), since clients rely on it to distinguish cancel reasons.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 7, Add a short note to the VoIP delivery paragraph
clarifying that the APNs `Category` field from the incoming notification is
forwarded into the VoIP payload for platforms `apple_voip_rn` and
`apple_voip_rnbeta` (which map to `ApplePushSettings`), and that the proxy omits
the `Category` when it is empty; this ensures clients can rely on `Category` to
distinguish cancel reasons in VoIP pushes.
server/apple_notification_test.go (1)

16-35: ⚡ Quick win

Alias test is validating duplicated logic, not the real dispatch alias path.

TestVoIPPlatformDispatchAlias re-implements the mapping inline, so a regression in the production remap path can still pass this test. Please route this assertion through the production aliasing function/path (or extract a shared helper used by both code and test).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/apple_notification_test.go` around lines 16 - 35,
TestVoIPPlatformDispatchAlias is re-implementing the VoIP->non-VoIP mapping
instead of exercising the production remap path; change the test to call the
real aliasing function (or a shared helper) used by runtime dispatch so it
validates actual behavior. Locate the production remap/alias function (the
function that uses applePlatformVoIPPrefix to produce the final platform string)
and replace the inline logic in TestVoIPPlatformDispatchAlias with a call to
that function (or extract and import the shared helper used by both production
code and tests), then assert the returned value equals tc.expected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@README.md`:
- Line 7: Add a short note to the VoIP delivery paragraph clarifying that the
APNs `Category` field from the incoming notification is forwarded into the VoIP
payload for platforms `apple_voip_rn` and `apple_voip_rnbeta` (which map to
`ApplePushSettings`), and that the proxy omits the `Category` when it is empty;
this ensures clients can rely on `Category` to distinguish cancel reasons in
VoIP pushes.

In `@server/apple_notification_test.go`:
- Around line 16-35: TestVoIPPlatformDispatchAlias is re-implementing the
VoIP->non-VoIP mapping instead of exercising the production remap path; change
the test to call the real aliasing function (or a shared helper) used by runtime
dispatch so it validates actual behavior. Locate the production remap/alias
function (the function that uses applePlatformVoIPPrefix to produce the final
platform string) and replace the inline logic in TestVoIPPlatformDispatchAlias
with a call to that function (or extract and import the shared helper used by
both production code and tests), then assert the returned value equals
tc.expected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c2dc483e-d03d-4575-901b-ca3e9c873884

📥 Commits

Reviewing files that changed from the base of the PR and between 7852e23 and 8893585.

📒 Files selected for processing (5)
  • README.md
  • server/apple_notification_server.go
  • server/apple_notification_test.go
  • server/push_notification.go
  • server/server.go

@lieut-data lieut-data self-requested a review May 26, 2026 16:37

@lieut-data lieut-data left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm new to this code as well as push notifications in general, but a few thoughts to help get the ball moving on the suite of PRs.

Comment thread server/apple_notification_test.go Outdated
Comment thread server/apple_notification_test.go
Comment thread server/apple_notification_test.go Outdated
Comment thread README.md Outdated

## VoIP push notifications (iOS Calls)

The proxy delivers PushKit / VoIP pushes for iOS calls under the platform identifiers `apple_voip_rn` and `apple_voip_rnbeta`. These are aliased internally to the existing `apple_rn` / `apple_rnbeta` `ApplePushSettings` entries — no extra configuration block is required; the same APNs key is reused. The proxy emits a VoIP-shaped APNs request (`apns-push-type: voip`, topic `<ApplePushTopic>.voip`, minimal payload) when an incoming notification's platform carries the `apple_voip_` prefix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm stumbling over the infix parsing and string juggling here and in the other PRs: could you elaborate on the current design?

Should we instead model the desired APNS push type explicitly in the payload? The configuration stays the same, the target platform remains unchanged, and the push proxy continues to vary the push type on demand. And I suppose this would allow a future use of the background push type if we ever went in that direction. (If APNSPushType is too granular, even just a VoIP flag signalling intent at the payload level seems simpler, at least on the surface.)

Secondly, are apple_rn and apple_rnbeta names universal targets, or just current conventions from our hosted push proxy? I think the code is agnostic, but might be good to clarify the documentation here if we're not actually bound to these platform identifiers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The idea was to avoid a duplicate configuration just because of the voip vs normal push notification, the initial design decided based on the "Platform" and that was either apple or android with variants for "rn" and "rnbeta".

I'm with you when it comes to the model, in fact that was my initial direction, I ended up going this way cause modifying the model meant to introduce a breaking change in the plugin as we dropped support for MySQL and the plugin still has that code and I did not want to make that decision nor delay this.

I even had to add a very hacky approach for a type of clear notification, that I think it should be avoided.

A lot of the decisions were made based on the above.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

as we dropped support for MySQL and the plugin still has that code and I did not want to make that decision nor delay this.

To clarify, is the existing MySQL support in the Calls plugin a blocker? We can absolutely drop that as a consideration, and I'm happy to help with that.

I'm with you when it comes to the model, in fact that was my initial direction, I ended up going this way cause modifying the model meant to introduce a breaking change in the plugin

I'd love to dig into this and enable your original design. Let me find time to 1:1 and we can unblock this ASAP.

Thanks, @enahum!

Comment on lines +318 to +320
if me.metrics != nil {
me.metrics.incrementNotificationTotal(PushNotifyApple, msg.Type)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we have metrics for this new VoIP path? (Do we need to key off SubType somewhere?)

Comment thread server/apple_notification_server.go Outdated
Comment thread server/apple_notification_server.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/apple_notification_test.go (1)

83-161: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Re-add VoIP category payload assertions to protect the Calls contract.

Given Calls routing depends on category, this suite should assert it is forwarded when non-empty and omitted when empty. That would catch the current payload regression early.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/apple_notification_test.go` around lines 83 - 161, The test suite is
missing assertions for the VoIP "category" field; update the tests around
buildVoIPNotification and PushNotification to assert that when msg.Category is
set it appears in the marshalled payload (body["category"]) and when
msg.Category is empty it is omitted (no key present), mirroring the existing
patterns for sender_name/channel_name and ack_id; modify the first test to
assert the populated category is forwarded and add/adjust the IdLoaded-mode test
(and the missing-ack test pattern) to assert the category key is absent when
empty.
server/apple_notification_server.go (1)

325-346: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Forward Category in VoIP payload to preserve call-end semantics.

buildVoIPNotification currently drops msg.Category. That breaks the Calls contract for Type=clear, SubType=calls where the client distinguishes answered_elsewhere vs other end reasons. Please include category when non-empty.

Proposed fix
 func (me *AppleNotificationServer) buildVoIPNotification(msg *PushNotification) *apns.Notification {
 	data := payload.NewPayload().
 		ContentAvailable().
 		Custom("type", msg.Type).
 		Custom("sub_type", msg.SubType).
 		Custom("channel_id", msg.ChannelID).
 		Custom("server_id", msg.ServerID).
 		Custom("post_id", msg.PostID).
 		Custom("thread_id", msg.RootID).
 		Custom("sender_id", msg.SenderID).
 		Custom("id_loaded", msg.IsIDLoaded)
+
+	if msg.Category != "" {
+		data.Custom("category", msg.Category)
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/apple_notification_server.go` around lines 325 - 346, In
buildVoIPNotification, include msg.Category in the VoIP payload when non-empty
so call-end semantics are preserved; modify the payload construction in
AppleNotificationServer.buildVoIPNotification to add data.Custom("category",
msg.Category) conditional on msg.Category != "" (similar to existing
sender_name/channel_name checks) so the client can distinguish
answered_elsewhere vs other end reasons for Type=clear, SubType=calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/apple_notification_server.go`:
- Around line 325-346: In buildVoIPNotification, include msg.Category in the
VoIP payload when non-empty so call-end semantics are preserved; modify the
payload construction in AppleNotificationServer.buildVoIPNotification to add
data.Custom("category", msg.Category) conditional on msg.Category != "" (similar
to existing sender_name/channel_name checks) so the client can distinguish
answered_elsewhere vs other end reasons for Type=clear, SubType=calls.

In `@server/apple_notification_test.go`:
- Around line 83-161: The test suite is missing assertions for the VoIP
"category" field; update the tests around buildVoIPNotification and
PushNotification to assert that when msg.Category is set it appears in the
marshalled payload (body["category"]) and when msg.Category is empty it is
omitted (no key present), mirroring the existing patterns for
sender_name/channel_name and ack_id; modify the first test to assert the
populated category is forwarded and add/adjust the IdLoaded-mode test (and the
missing-ack test pattern) to assert the category key is absent when empty.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 832d70c2-c2fe-42ef-80cf-1c5d329ed8b3

📥 Commits

Reviewing files that changed from the base of the PR and between 7e4aec7 and 86ba52d.

📒 Files selected for processing (6)
  • server/android_notification_server.go
  • server/apple_notification_server.go
  • server/apple_notification_test.go
  • server/push_notification.go
  • server/push_notification_test.go
  • server/server.go
✅ Files skipped from review due to trivial changes (1)
  • server/push_notification_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/server.go
  • server/android_notification_server.go

@enahum enahum changed the title Add APNs VoIP send path for SubType=calls notifications Add APNs VoIP send path for Transport=voip notifications Jun 1, 2026

@lieut-data lieut-data left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for tweaking the data model, @enahum! A handful of follow up comments, but not strictly blocking at this point.


// PushTransportVoIP is the value of PushNotification.Transport that
// dispatches via the PushKit/VoIP send path.
PushTransportVoIP = "voip"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we add a PushTransportDefault = "" (or PushTransportNone?) to clarify the meaning of the various "" added to the code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure I think that will make it more readable

Comment thread server/push_notification.go Outdated

// RedactToken returns the first 8 chars of a device token followed by an
// ellipsis, for safe inclusion in logs.
func RedactToken(token string) string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: do we need to export this from the server package, or can we keep it redactToken and internal?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll check.. I believe it ahould be possible

want string
}{
{"empty", "", ""},
{"short token passes through", "1234", "1234"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't know anything about the tokens in these cases, but wondering if there's a more holistic approach to token handling we should be considering? (i.e. not logging it at all, or if we do, logging a one-way hash)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well is the tokens are long enough, but we definitely need a way to identify them and match with the session in the db in case we need to debug something, so a one way hash will make it very difficult imo

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/android_notification_server.go (1)

221-232: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inconsistent transport label: use PushTransportDefault instead of "".

These metric increments pass an empty transport string, while incrementNotificationTotal (Line 163) and incrementRemoval (Line 199) on the same Android path pass PushTransportDefault. This emits split label values (transport="" vs transport="default") for the same notification flow, which fragments counters and breaks aggregation/dashboards that filter by transport.

🔧 Proposed fix to align the transport label
 		if me.metrics != nil {
-			me.metrics.incrementFailure(PushNotifyAndroid, pushType, "", reason)
+			me.metrics.incrementFailure(PushNotifyAndroid, pushType, PushTransportDefault, reason)
 		}

 		return NewErrorPushResponse(err.Error())
 	}

 	if me.metrics != nil {
 		if msg.AckID != "" {
-			me.metrics.incrementSuccessWithAck(PushNotifyAndroid, pushType, "")
+			me.metrics.incrementSuccessWithAck(PushNotifyAndroid, pushType, PushTransportDefault)
 		} else {
-			me.metrics.incrementSuccess(PushNotifyAndroid, pushType, "")
+			me.metrics.incrementSuccess(PushNotifyAndroid, pushType, PushTransportDefault)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/android_notification_server.go` around lines 221 - 232, The metric
calls on the Android notification path are using an empty transport label (""),
causing split Prometheus labels; update the calls to use the canonical constant
PushTransportDefault instead. Specifically, replace the third argument in
me.metrics.incrementFailure(PushNotifyAndroid, pushType, "", reason) and in both
me.metrics.incrementSuccessWithAck(PushNotifyAndroid, pushType, "") and
me.metrics.incrementSuccess(PushNotifyAndroid, pushType, "") with
PushTransportDefault so they match other calls like incrementNotificationTotal
and incrementRemoval.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/android_notification_server.go`:
- Around line 221-232: The metric calls on the Android notification path are
using an empty transport label (""), causing split Prometheus labels; update the
calls to use the canonical constant PushTransportDefault instead. Specifically,
replace the third argument in me.metrics.incrementFailure(PushNotifyAndroid,
pushType, "", reason) and in both
me.metrics.incrementSuccessWithAck(PushNotifyAndroid, pushType, "") and
me.metrics.incrementSuccess(PushNotifyAndroid, pushType, "") with
PushTransportDefault so they match other calls like incrementNotificationTotal
and incrementRemoval.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5bd429d-cbb7-423e-b868-0723c14077c7

📥 Commits

Reviewing files that changed from the base of the PR and between f0f6359 and 70cb3d9.

📒 Files selected for processing (6)
  • server/android_notification_server.go
  • server/apple_notification_server.go
  • server/apple_notification_test.go
  • server/push_notification.go
  • server/push_notification_test.go
  • server/server.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/apple_notification_test.go
  • server/apple_notification_server.go

@lieut-data lieut-data merged commit 76bc7f5 into master Jun 5, 2026
31 checks passed
@lieut-data lieut-data deleted the ios-ring branch June 5, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1: Dev Review Requires review by a core commiter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants