Compare commits

..
Author SHA1 Message Date
Cyrille BéraudandCopilot e0fcee240a proxy server: collapse low-priority push notifications
When a device is unreachable (deep doze, offline), FCM queues
non-collapsible messages individually, with a hard limit of 100
pending messages per device. A Jami device that is a member of a few
active swarms receives a sustained stream of connection-churn and
expiration notifications (observed: 27-44/min, ~12k/day), so a phone
suspended for a few tens of minutes is enough to overflow the queue.
Once full, FCM silently drops messages, including the high-priority
notification announcing an incoming call: the callee never wakes up
and the caller gets a timeout after the ICE window (observed in
production, with the call notification never delivered while pushes
sent after the device woke up arrived in under a second).

Mark Android notifications as collapsible (collapse_key) when they
are neither high-priority (calls, messages, invitations - as set by
the client through Value::priority) nor resubscribe requests. While
collapsed messages are pending, FCM only keeps the last one per key,
so routine sync noise can no longer evict actionable notifications.

A single constant key is used instead of one key per DHT listen key:
FCM only honors 4 distinct collapse keys per device, and the Android
client reacts to any low-priority notification the same way (wake up
and resynchronize against the proxy), so finer granularity would not
change client behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 08:22:02 -04:00
4 changed files with 21 additions and 95 deletions

No files matched your search

-20
View File
@@ -146,26 +146,6 @@ public:
std::shared_ptr<DhtRunner> getNode() const { return dht_; }
#ifdef OPENDHT_PUSH_NOTIFICATIONS
/**
* Decide whether a batch of values notified to a push listener must be
* delivered as a high (urgent) priority push notification.
*
* Only real-time call signaling — values whose pushType is "audioCall" or
* "videoCall", published at high priority (priority == 0) — warrants an
* urgent push. Every other notification (presence/connection-request
* refreshes carrying an empty pushType, value expirations, etc.) is sent at
* normal priority so it does not saturate the mobile platform's adaptive
* high-priority quota; once that quota is tripped, the platform silently
* drops genuine call pushes.
*
* @param values the values being notified to the listener.
* @param expired whether this notification is for expired (removed) values.
* @return true if the notification must be sent at high priority.
*/
static bool isHighPriorityPush(const std::vector<std::shared_ptr<Value>>& values, bool expired) noexcept;
#endif // OPENDHT_PUSH_NOTIFICATIONS
private:
class ConnectionListener;
struct RestRouterTraitsTls;
+21 -28
View File
@@ -1134,25 +1134,6 @@ DhtProxyServer::handleCancelPushListen(const asio::error_code& ec,
pushListeners_.erase(pushListener);
}
bool
DhtProxyServer::isHighPriorityPush(const std::vector<std::shared_ptr<Value>>& values, bool expired) noexcept
{
// Expired-value notifications (de-registration) are never time-critical.
if (expired)
return false;
// Promote to high (urgent) push priority only for real-time call signaling.
// Background traffic — most notably connection-request refreshes, which
// carry an empty pushType — stays at normal priority so it does not trip the
// mobile platform's adaptive high-priority quota, which would otherwise
// silently drop genuine call pushes once the high-priority noise ratio gets
// too high.
for (const auto& v : values) {
if (v->priority == 0 and (v->pushType == "videoCall" or v->pushType == "audioCall"))
return true;
}
return false;
}
bool
DhtProxyServer::handlePushListen(const InfoHash& infoHash,
const std::string& pushToken,
@@ -1195,23 +1176,20 @@ DhtProxyServer::handlePushListen(const InfoHash& infoHash,
json["exp"] = json["ids"];
}
const bool highPriority = isHighPriorityPush(values, expired);
auto minPriority = 1000u;
for (const auto& v : values)
minPriority = std::min(minPriority, v->priority);
if (logger_) {
auto minPriority = 1000u;
for (const auto& v : values)
minPriority = std::min(minPriority, v->priority);
logger_->debug("[proxy:server] [listen {}] [client {}] [session {}] [expired {}] [priority {}] [high {}] [values {}]",
if (logger_)
logger_->debug("[proxy:server] [listen {}] [client {}] [session {}] [expired {}] [priority {}] [values {}]",
infoHash,
clientId,
sessionCtx->sessionId,
expired,
minPriority,
highPriority,
values.size());
}
sendPushNotification(pushToken, std::move(json), type, highPriority, topic);
sendPushNotification(pushToken, std::move(json), type, !expired and minPriority == 0, topic);
return true;
}
@@ -1289,6 +1267,21 @@ DhtProxyServer::sendPushNotification(
Json::Value androidConfig(Json::objectValue);
androidConfig["priority"] = priority;
androidConfig["ttl"] = "86400s"; // time to live = 24 hours
// Make routine sync notifications (connection churn, value
// expirations) collapsible: FCM keeps only the last pending
// message per collapse key when the device is unreachable
// (dozing/offline), while non-collapsible messages pile up in
// a 100-message queue. When that queue overflows, FCM silently
// drops messages - including the high-priority ones that
// announce incoming calls. A single constant key is used
// because FCM only honors 4 distinct collapse keys per device
// and the Android client reacts to any of these notifications
// the same way: wake up and resynchronize against the proxy.
// High-priority notifications (calls, messages, invites - see
// Value::priority set by the client) and resubscribe requests
// remain non-collapsible so none of them can be overwritten.
if (!highPriority && !isResubscribe)
androidConfig["collapse_key"] = "sync";
notification["android"] = std::move(androidConfig);
} else {
notification["priority"] = priority;
-35
View File
@@ -327,39 +327,4 @@ DhtProxyTester::testShutdownStop()
CPPUNIT_ASSERT_EQUAL(2 * C, callback_count.load());
}
#ifdef OPENDHT_PUSH_NOTIFICATIONS
void
DhtProxyTester::testPushNotificationPriority()
{
auto mkValue = [](unsigned priority, const std::string& pushType) {
auto v = std::make_shared<dht::Value>();
v->priority = priority;
v->pushType = pushType;
return v;
};
// Real-time call signaling at high priority => urgent push.
CPPUNIT_ASSERT(dht::DhtProxyServer::isHighPriorityPush({mkValue(0, "videoCall")}, false));
CPPUNIT_ASSERT(dht::DhtProxyServer::isHighPriorityPush({mkValue(0, "audioCall")}, false));
// Connection-request refresh (empty pushType) is noise => normal push.
CPPUNIT_ASSERT(!dht::DhtProxyServer::isHighPriorityPush({mkValue(0, "")}, false));
// Any other pushType is not a call => normal push.
CPPUNIT_ASSERT(!dht::DhtProxyServer::isHighPriorityPush({mkValue(0, "message")}, false));
// Expired (de-registration) notifications are never urgent, even for calls.
CPPUNIT_ASSERT(!dht::DhtProxyServer::isHighPriorityPush({mkValue(0, "videoCall")}, true));
// A call value explicitly published at normal priority stays normal.
CPPUNIT_ASSERT(!dht::DhtProxyServer::isHighPriorityPush({mkValue(1, "videoCall")}, false));
// Mixed batch: a single high-priority call value is enough to make it urgent.
CPPUNIT_ASSERT(dht::DhtProxyServer::isHighPriorityPush({mkValue(0, ""), mkValue(0, "videoCall")}, false));
// Empty batch => normal.
CPPUNIT_ASSERT(!dht::DhtProxyServer::isHighPriorityPush({}, false));
}
#endif // OPENDHT_PUSH_NOTIFICATIONS
} // namespace test
-12
View File
@@ -21,9 +21,6 @@ class DhtProxyTester : public CppUnit::TestFixture
CPPUNIT_TEST(testPutGet40KChars);
CPPUNIT_TEST(testFuzzy);
CPPUNIT_TEST(testShutdownStop);
#ifdef OPENDHT_PUSH_NOTIFICATIONS
CPPUNIT_TEST(testPushNotificationPriority);
#endif
CPPUNIT_TEST_SUITE_END();
public:
@@ -58,15 +55,6 @@ public:
void testShutdownStop();
#ifdef OPENDHT_PUSH_NOTIFICATIONS
/**
* The proxy must only request a high-priority push for real-time call
* signaling (audioCall/videoCall at priority 0); everything else stays at
* normal priority.
*/
void testPushNotificationPriority();
#endif
private:
dht::DhtRunner::Config clientConfig {};
dht::DhtRunner nodePeer;