Skip to content

Commit d01ea8a

Browse files
authored
fix: allow self-managed calls to numbers Android classifies as emergency (WT-1730) (#349)
* fix: allow self-managed calls to numbers Android classifies as emergency TelecomManager.placeCall() blocks a self-managed PhoneAccount from ever placing a call to a number the OS classifies as emergency, based purely on the tel: address matching the device/SIM-region emergency-number list - regardless of whether the number is actually reachable as one. This silently drops calls to legitimate internal PBX extensions that happen to collide with that list (e.g. an extension literally named 112 or 911). Building the outgoing Uri as a sip: address instead of tel: sidesteps the check entirely, since it only matches tel: addresses. The real number keeps flowing unchanged via CallMetadata into onCreateOutgoingConnection, which never reads the Uri - so this only affects what Telecom itself sees for its own emergency classification, not what actually gets dialed. * build: use portadialer.internal instead of webtrit.invalid for the decoy sip: host Same RFC-reserved, never-resolving intent (RFC 9476 .internal instead of RFC 2606 .invalid), just a more identifiable name for this address in logs. * build: remove dead emergency-number exception handling on Android The sip: scheme change means Android's Telecom never classifies our outgoing calls as emergency anymore, so the code path that used to catch and report an emergency-number failure can never run: - removed EmergencyNumberException and its throw/catch sites - removed the now-single-case-only OutgoingFailureType.EMERGENCY_NUMBER - removed the now-unused TelephonyUtils.isEmergencyNumber() helper The shared PCallRequestErrorEnum.emergencyNumber / CallkeepCallRequestError.emergencyNumber wire values are left in place deliberately: the Kotlin side encodes this enum by explicit raw Int values while the Dart side decodes by list index, so removing an entry from the middle would require re-aligning every value after it by hand on both sides to avoid a silent wire-format mismatch. Nothing on the Android side sets this value anymore either way. * build: centralize outgoing decoy sip: Uri construction in TelephonyUtils * build: percent-encode the number in the outgoing sip: Uri
1 parent 50cf63a commit d01ea8a

7 files changed

Lines changed: 35 additions & 72 deletions

File tree

webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/common/TelephonyUtils.kt

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import android.Manifest
44
import android.content.ComponentName
55
import android.content.Context
66
import android.net.Uri
7-
import android.os.Build
87
import android.os.Bundle
98
import android.telecom.PhoneAccount
109
import android.telecom.PhoneAccountHandle
1110
import android.telecom.TelecomManager
12-
import android.telephony.PhoneNumberUtils
1311
import android.telephony.TelephonyManager
1412
import androidx.annotation.RequiresPermission
1513
import com.webtrit.callkeep.models.CallMetadata
@@ -18,17 +16,6 @@ import com.webtrit.callkeep.services.services.connection.PhoneConnectionService
1816
class TelephonyUtils(
1917
private val context: Context,
2018
) {
21-
fun isEmergencyNumber(number: String): Boolean {
22-
val telephonyManager =
23-
context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
24-
25-
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
26-
telephonyManager.isEmergencyNumber(number)
27-
} else {
28-
PhoneNumberUtils.isEmergencyNumber(number)
29-
}
30-
}
31-
3219
fun getTelecomManager(): TelecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
3320

3421
@RequiresPermission(Manifest.permission.CALL_PHONE)
@@ -103,8 +90,34 @@ class TelephonyUtils(
10390
// Defined as a local constant to avoid a lint InlinedApi warning on minSdk 26.
10491
private const val FEATURE_TELECOM = "android.software.telecom"
10592

93+
// RFC 9476 reserved .internal TLD, guaranteed to never resolve publicly. This host is
94+
// never used for actual dialing, only for Telecom's own bookkeeping - see buildOutgoingUri.
95+
private const val OUTGOING_URI_HOST = "portadialer.internal"
96+
10697
private val logger = Log(TAG)
10798

99+
/**
100+
* Builds the [Uri] to pass to [TelecomManager.placeCall] for an outgoing call to [number].
101+
*
102+
* Deliberately uses the sip: scheme with an explicit @host instead of tel:. Android's
103+
* emergency-number matching (Telecom's internal isEmergencyNumber() re-check before a
104+
* self-managed PhoneAccount is allowed to place a call) only ever applies to tel: scheme
105+
* addresses whose scheme-specific-part looks like a bare phone number - a self-managed
106+
* PhoneAccount can never place a Telecom-classified emergency call, so a legitimate PBX
107+
* extension that happens to collide with the device/SIM-region emergency-number list
108+
* (e.g. "112" or "911") would otherwise be silently blocked. This Uri is only used for
109+
* Telecom bookkeeping; the real number keeps flowing unchanged via CallMetadata into
110+
* onCreateOutgoingConnection, which never reads request.address.
111+
*
112+
* The number is percent-encoded so URI-reserved characters in it (e.g. "#" in PBX
113+
* feature codes) cannot break the Uri structure, while the "@host" delimiter stays
114+
* literal - a bare "sip:<number>" (no literal @host) still matches the emergency
115+
* check, so the delimiter must not be encoded (Uri.fromParts would encode it too).
116+
* Plain digit numbers are unaffected by the encoding, keeping the exact Uri shape
117+
* validated on-device.
118+
*/
119+
fun buildOutgoingUri(number: String): Uri = Uri.parse("${PhoneAccount.SCHEME_SIP}:${Uri.encode(number)}@$OUTGOING_URI_HOST")
120+
108121
/**
109122
* Returns true if the device supports the Android Telecom framework.
110123
*

webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt

Lines changed: 0 additions & 5 deletions
This file was deleted.

webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/FailureMetaData.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.os.Bundle
44

55
enum class OutgoingFailureType {
66
UNENTITLED,
7-
EMERGENCY_NUMBER,
87
}
98

109
open class FailureMetadata(

webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/connection/PhoneConnectionService.kt

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import android.telecom.Connection
99
import android.telecom.ConnectionRequest
1010
import android.telecom.ConnectionService
1111
import android.telecom.DisconnectCause
12-
import android.telecom.PhoneAccount
1312
import android.telecom.PhoneAccountHandle
1413
import androidx.annotation.RequiresPermission
1514
import com.webtrit.callkeep.PIncomingCallError
@@ -20,10 +19,8 @@ import com.webtrit.callkeep.common.Log
2019
import com.webtrit.callkeep.common.TelephonyUtils
2120
import com.webtrit.callkeep.models.CallConnectionState
2221
import com.webtrit.callkeep.models.CallMetadata
23-
import com.webtrit.callkeep.models.EmergencyNumberException
2422
import com.webtrit.callkeep.models.FailureMetadata
2523
import com.webtrit.callkeep.models.InvalidCallMetadataException
26-
import com.webtrit.callkeep.models.OutgoingFailureType
2724
import com.webtrit.callkeep.services.broadcaster.CallCommandEvent
2825
import com.webtrit.callkeep.services.broadcaster.CallLifecycleEvent
2926
import com.webtrit.callkeep.services.broadcaster.ConnectionEvent
@@ -765,30 +762,18 @@ class PhoneConnectionService : ConnectionService() {
765762
?: throw InvalidCallMetadataException(
766763
"startOutgoingCall: missing destination number for callId=${metadata.callId}",
767764
)
768-
val uri: Uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null)
769765
val telephonyUtils = TelephonyUtils(context)
770766

771-
if (telephonyUtils.isEmergencyNumber(number)) {
772-
Log.i(TAG, "onOutgoingCall, trying to call on emergency number: $number")
767+
val uri: Uri = TelephonyUtils.buildOutgoingUri(number)
773768

774-
val failureMetadata =
775-
FailureMetadata(
776-
metadata,
777-
"Failed to establish outgoing connection: Emergency number",
778-
outgoingFailureType = OutgoingFailureType.EMERGENCY_NUMBER,
779-
)
780-
781-
throw EmergencyNumberException(failureMetadata)
782-
} else {
783-
// If there is already an active call not on hold, we terminate it and start a new one,
784-
// otherwise, we would encounter an exception when placing the outgoing call.
785-
connectionManager.getActiveConnection()?.let {
786-
Log.i(TAG, "onOutgoingCall, hung up previous call: $it")
787-
it.hungUp()
788-
}
789-
790-
telephonyUtils.placeOutgoingCall(uri, metadata)
769+
// If there is already an active call not on hold, we terminate it and start a new one,
770+
// otherwise, we would encounter an exception when placing the outgoing call.
771+
connectionManager.getActiveConnection()?.let {
772+
Log.i(TAG, "onOutgoingCall, hung up previous call: $it")
773+
it.hungUp()
791774
}
775+
776+
telephonyUtils.placeOutgoingCall(uri, metadata)
792777
}
793778

794779
/**

webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/foreground/ForegroundService.kt

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import com.webtrit.callkeep.common.TelephonyUtils
3232
import com.webtrit.callkeep.managers.NotificationChannelManager
3333
import com.webtrit.callkeep.models.CallConnectionState
3434
import com.webtrit.callkeep.models.CallMetadata
35-
import com.webtrit.callkeep.models.EmergencyNumberException
3635
import com.webtrit.callkeep.models.FailedCallInfo
3736
import com.webtrit.callkeep.models.FailureMetadata
3837
import com.webtrit.callkeep.models.InvalidCallMetadataException
@@ -375,15 +374,11 @@ class ForegroundService :
375374
OutgoingFailureSource.CS_CALLBACK,
376375
failureMetaData.getThrowable(),
377376
)
378-
val result =
377+
val result: Result<PCallRequestError?> =
379378
when (failureMetaData.outgoingFailureType) {
380379
OutgoingFailureType.UNENTITLED -> {
381380
Result.failure(failureMetaData.getThrowable())
382381
}
383-
384-
OutgoingFailureType.EMERGENCY_NUMBER -> {
385-
Result.success(PCallRequestError(PCallRequestErrorEnum.EMERGENCY_NUMBER))
386-
}
387382
}
388383
finish(result)
389384
}
@@ -420,10 +415,6 @@ class ForegroundService :
420415
@SuppressLint("MissingPermission")
421416
core.startOutgoingCall(metadata)
422417
logger.i("$logContext: startOutgoingCall dispatched")
423-
} catch (e: EmergencyNumberException) {
424-
logger.e("$logContext failed: emergency number", e)
425-
saveFailedOutgoingCall(metadata, OutgoingFailureSource.DISPATCH_ERROR, e)
426-
finish(Result.success(PCallRequestError(PCallRequestErrorEnum.EMERGENCY_NUMBER)))
427418
} catch (e: Exception) {
428419
logger.e("$logContext failed: ${e.javaClass.simpleName}: ${e.message}", e)
429420
saveFailedOutgoingCall(metadata, OutgoingFailureSource.DISPATCH_ERROR, e)

webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/models/FailureMetadataTest.kt

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,6 @@ class FailureMetadataTest {
1818
assertEquals(OutgoingFailureType.UNENTITLED, restored.outgoingFailureType)
1919
}
2020

21-
@Test
22-
fun `round-trip preserves EMERGENCY_NUMBER failure type`() {
23-
val original = FailureMetadata(
24-
callMetadata = null,
25-
message = "emergency",
26-
outgoingFailureType = OutgoingFailureType.EMERGENCY_NUMBER,
27-
)
28-
val restored = FailureMetadata.fromBundle(original.toBundle())
29-
assertEquals(OutgoingFailureType.EMERGENCY_NUMBER, restored.outgoingFailureType)
30-
}
31-
3221
@Test
3322
fun `unknown string falls back to UNENTITLED`() {
3423
val bundle = Bundle().apply { putString("FAILURE_OUTGOING_TYPE", "FUTURE_TYPE") }

webtrit_callkeep_android/docs/models.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,6 @@ Carry structured error information when a call fails to connect.
116116

117117
---
118118

119-
## EmergencyNumberException
120-
121-
**File**: `kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt`
122-
123-
Thrown (and caught, then reported to Dart) when the app attempts to handle an emergency number
124-
(`112`, `911`, etc.) that must be routed to the system dialer instead.
125-
126-
---
127-
128119
## Converters
129120

130121
**File**: `kotlin/com/webtrit/callkeep/models/Converters.kt`

0 commit comments

Comments
 (0)