Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ data class VoipPayload(
private val createdAtMs: Long?
get() = parseCreatedAtMs(createdAt)

private val expiresAtMs: Long?
get() = createdAtMs?.plus(INCOMING_CALL_LIFETIME_MS)

fun isVoipIncomingCall(): Boolean {
return pushType == VoipPushType.INCOMING_CALL &&
callId.isNotBlank() &&
Expand Down Expand Up @@ -95,14 +92,18 @@ data class VoipPayload(
}
}

fun getRemainingLifetimeMs(): Long? {
val expiresAtMs = expiresAtMs ?: return null
val nowMs = System.currentTimeMillis()
fun getRemainingLifetimeMs(nowMs: Long = System.currentTimeMillis()): Long? {
val createdAtMs = createdAtMs ?: return null
val skew = kotlin.math.abs(nowMs - createdAtMs)
if (skew > MAX_TRUSTED_CLOCK_SKEW_MS) {
return INCOMING_CALL_LIFETIME_MS
}
val expiresAtMs = createdAtMs + INCOMING_CALL_LIFETIME_MS
return (expiresAtMs - nowMs).coerceAtLeast(0L)
}

fun isExpired(): Boolean {
val remainingLifetimeMs = getRemainingLifetimeMs()
fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean {
val remainingLifetimeMs = getRemainingLifetimeMs(nowMs)
return remainingLifetimeMs?.let { it <= 0L } ?: true
}

Expand All @@ -111,6 +112,10 @@ data class VoipPayload(
private const val VOIP_NOTIFICATION_TYPE = "voip"
// the amount of time in milliseconds that an incoming call will be kept alive
private const val INCOMING_CALL_LIFETIME_MS = 60_000L
// Maximum tolerated drift between device clock and server `createdAt` before
// treating the device clock as untrusted. Within this window we honour the
// existing expiry math; outside we grant the full incoming-call lifetime.
private const val MAX_TRUSTED_CLOCK_SKEW_MS = 10 * 60_000L
private val isoDateFormats = listOf(
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US),
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.US),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package chat.rocket.reactnative.voip

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class VoipPayloadExpiryTest {

private val incomingCallLifetimeMs = 60_000L

private fun isoString(epochMs: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US)
formatter.timeZone = TimeZone.getTimeZone("UTC")
return formatter.format(java.util.Date(epochMs))
}

private fun makePayload(createdAt: String?): VoipPayload {
return VoipPayload(
callId = "call-1",
caller = "Caller",
username = "user1",
host = "https://example.com",
type = VoipPushType.INCOMING_CALL.value,
hostName = "Example",
avatarUrl = null,
createdAt = createdAt,
voipAcceptFailed = false,
)
}

@Test
fun `getRemainingLifetimeMs returns full lifetime when device clock is far ahead of createdAt`() {
val createdAtMs = 1_700_000_000_000L
val payload = makePayload(isoString(createdAtMs))
// Device clock is 20 minutes ahead of createdAt -> skew exceeds threshold (10 min)
val nowMs = createdAtMs + 20 * 60_000L
assertEquals(incomingCallLifetimeMs, payload.getRemainingLifetimeMs(nowMs))
}

@Test
fun `getRemainingLifetimeMs returns full lifetime when device clock is far behind createdAt`() {
val createdAtMs = 1_700_000_000_000L
val payload = makePayload(isoString(createdAtMs))
// Device clock is 20 minutes behind createdAt -> skew exceeds threshold (10 min)
val nowMs = createdAtMs - 20 * 60_000L
assertEquals(incomingCallLifetimeMs, payload.getRemainingLifetimeMs(nowMs))
}

@Test
fun `getRemainingLifetimeMs returns expected remaining when skew is small and call is fresh`() {
val createdAtMs = 1_700_000_000_000L
val payload = makePayload(isoString(createdAtMs))
// 30s after createdAt -> ~30_000 ms remaining
val nowMs = createdAtMs + 30_000L
val remaining = payload.getRemainingLifetimeMs(nowMs)
assertEquals(30_000L, remaining)
}

@Test
fun `getRemainingLifetimeMs returns 0 when skew is small and call is stale`() {
val createdAtMs = 1_700_000_000_000L
val payload = makePayload(isoString(createdAtMs))
// 70s after createdAt -> expired
val nowMs = createdAtMs + 70_000L
assertEquals(0L, payload.getRemainingLifetimeMs(nowMs))
}

@Test
fun `getRemainingLifetimeMs returns null when createdAt is missing`() {
val payload = makePayload(null)
assertNull(payload.getRemainingLifetimeMs(System.currentTimeMillis()))
}

@Test
fun `getRemainingLifetimeMs returns null when createdAt is unparseable`() {
val payload = makePayload("not-a-date")
assertNull(payload.getRemainingLifetimeMs(System.currentTimeMillis()))
}

@Test
fun `isExpired follows getRemainingLifetimeMs across all skew scenarios`() {
val createdAtMs = 1_700_000_000_000L
val payload = makePayload(isoString(createdAtMs))

// Far-ahead skew: device clock untrusted -> not expired
assertFalse(payload.isExpired(createdAtMs + 20 * 60_000L))
// Far-behind skew: device clock untrusted -> not expired
assertFalse(payload.isExpired(createdAtMs - 20 * 60_000L))
// Fresh call within trusted skew -> not expired
assertFalse(payload.isExpired(createdAtMs + 30_000L))
// Stale call within trusted skew -> expired
assertTrue(payload.isExpired(createdAtMs + 70_000L))

// Missing createdAt -> expired (remaining lifetime is null)
val nullPayload = makePayload(null)
assertTrue(nullPayload.isExpired(System.currentTimeMillis()))
}
}
13 changes: 11 additions & 2 deletions ios/Libraries/VoipPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ public class VoipPayload: NSObject {
// the amount of time in seconds that an incoming call will be kept alive
@objc public static let INCOMING_CALL_LIFETIME_SEC: TimeInterval = 60

// Maximum tolerated drift between device clock and server `createdAt` before
// treating the device clock as untrusted. Mirrors Android's
// MAX_TRUSTED_CLOCK_SKEW_MS.
private static let maxTrustedClockSkew: TimeInterval = 10 * 60

@objc public let callId: String
let callUUID: UUID
@objc public let caller: String
Expand Down Expand Up @@ -176,10 +181,14 @@ public class VoipPayload: NSObject {
}

public func remainingLifetime(now: Date = Date()) -> TimeInterval? {
guard let expiresAt else {
guard let createdAtDate else {
return nil
}

let skew = abs(now.timeIntervalSince(createdAtDate))
if skew > Self.maxTrustedClockSkew {
return Self.INCOMING_CALL_LIFETIME_SEC
}
let expiresAt = createdAtDate.addingTimeInterval(Self.INCOMING_CALL_LIFETIME_SEC)
return max(0, expiresAt.timeIntervalSince(now))
}

Expand Down
Loading