From 20317b0a243d13b65f7e8aa7e717c56aec6de309 Mon Sep 17 00:00:00 2001 From: Dmitro Serdun Date: Thu, 16 Jul 2026 17:46:56 +0300 Subject: [PATCH 1/2] 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 --- .../webtrit/callkeep/common/TelephonyUtils.kt | 39 ++++++++++++------- .../models/EmergencyNumberException.kt | 5 --- .../callkeep/models/FailureMetaData.kt | 1 - .../connection/PhoneConnectionService.kt | 31 ++++----------- .../services/foreground/ForegroundService.kt | 11 +----- .../callkeep/models/FailureMetadataTest.kt | 11 ------ webtrit_callkeep_android/docs/models.md | 9 ----- 7 files changed, 35 insertions(+), 72 deletions(-) delete mode 100644 webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/common/TelephonyUtils.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/common/TelephonyUtils.kt index 8faad486..4ab77edd 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/common/TelephonyUtils.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/common/TelephonyUtils.kt @@ -4,12 +4,10 @@ import android.Manifest import android.content.ComponentName import android.content.Context import android.net.Uri -import android.os.Build import android.os.Bundle import android.telecom.PhoneAccount import android.telecom.PhoneAccountHandle import android.telecom.TelecomManager -import android.telephony.PhoneNumberUtils import android.telephony.TelephonyManager import androidx.annotation.RequiresPermission import com.webtrit.callkeep.models.CallMetadata @@ -18,17 +16,6 @@ import com.webtrit.callkeep.services.services.connection.PhoneConnectionService class TelephonyUtils( private val context: Context, ) { - fun isEmergencyNumber(number: String): Boolean { - val telephonyManager = - context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - telephonyManager.isEmergencyNumber(number) - } else { - PhoneNumberUtils.isEmergencyNumber(number) - } - } - fun getTelecomManager(): TelecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager @RequiresPermission(Manifest.permission.CALL_PHONE) @@ -103,8 +90,34 @@ class TelephonyUtils( // Defined as a local constant to avoid a lint InlinedApi warning on minSdk 26. private const val FEATURE_TELECOM = "android.software.telecom" + // RFC 9476 reserved .internal TLD, guaranteed to never resolve publicly. This host is + // never used for actual dialing, only for Telecom's own bookkeeping - see buildOutgoingUri. + private const val OUTGOING_URI_HOST = "portadialer.internal" + private val logger = Log(TAG) + /** + * Builds the [Uri] to pass to [TelecomManager.placeCall] for an outgoing call to [number]. + * + * Deliberately uses the sip: scheme with an explicit @host instead of tel:. Android's + * emergency-number matching (Telecom's internal isEmergencyNumber() re-check before a + * self-managed PhoneAccount is allowed to place a call) only ever applies to tel: scheme + * addresses whose scheme-specific-part looks like a bare phone number - a self-managed + * PhoneAccount can never place a Telecom-classified emergency call, so a legitimate PBX + * extension that happens to collide with the device/SIM-region emergency-number list + * (e.g. "112" or "911") would otherwise be silently blocked. This Uri is only used for + * Telecom bookkeeping; the real number keeps flowing unchanged via CallMetadata into + * onCreateOutgoingConnection, which never reads request.address. + * + * The number is percent-encoded so URI-reserved characters in it (e.g. "#" in PBX + * feature codes) cannot break the Uri structure, while the "@host" delimiter stays + * literal - a bare "sip:" (no literal @host) still matches the emergency + * check, so the delimiter must not be encoded (Uri.fromParts would encode it too). + * Plain digit numbers are unaffected by the encoding, keeping the exact Uri shape + * validated on-device. + */ + fun buildOutgoingUri(number: String): Uri = Uri.parse("${PhoneAccount.SCHEME_SIP}:${Uri.encode(number)}@$OUTGOING_URI_HOST") + /** * Returns true if the device supports the Android Telecom framework. * diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt deleted file mode 100644 index 3040c93a..00000000 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.webtrit.callkeep.models - -class EmergencyNumberException( - val metadata: FailureMetadata, -) : Exception("Failed to establish outgoing connection: Emergency number") diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/FailureMetaData.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/FailureMetaData.kt index 476ff462..e5e198ae 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/FailureMetaData.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/models/FailureMetaData.kt @@ -4,7 +4,6 @@ import android.os.Bundle enum class OutgoingFailureType { UNENTITLED, - EMERGENCY_NUMBER, } open class FailureMetadata( diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/connection/PhoneConnectionService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/connection/PhoneConnectionService.kt index 98afe842..e47239c2 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/connection/PhoneConnectionService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/connection/PhoneConnectionService.kt @@ -9,7 +9,6 @@ import android.telecom.Connection import android.telecom.ConnectionRequest import android.telecom.ConnectionService import android.telecom.DisconnectCause -import android.telecom.PhoneAccount import android.telecom.PhoneAccountHandle import androidx.annotation.RequiresPermission import com.webtrit.callkeep.PIncomingCallError @@ -20,10 +19,8 @@ import com.webtrit.callkeep.common.Log import com.webtrit.callkeep.common.TelephonyUtils import com.webtrit.callkeep.models.CallConnectionState import com.webtrit.callkeep.models.CallMetadata -import com.webtrit.callkeep.models.EmergencyNumberException import com.webtrit.callkeep.models.FailureMetadata import com.webtrit.callkeep.models.InvalidCallMetadataException -import com.webtrit.callkeep.models.OutgoingFailureType import com.webtrit.callkeep.services.broadcaster.CallCommandEvent import com.webtrit.callkeep.services.broadcaster.CallLifecycleEvent import com.webtrit.callkeep.services.broadcaster.ConnectionEvent @@ -765,30 +762,18 @@ class PhoneConnectionService : ConnectionService() { ?: throw InvalidCallMetadataException( "startOutgoingCall: missing destination number for callId=${metadata.callId}", ) - val uri: Uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null) val telephonyUtils = TelephonyUtils(context) - if (telephonyUtils.isEmergencyNumber(number)) { - Log.i(TAG, "onOutgoingCall, trying to call on emergency number: $number") + val uri: Uri = TelephonyUtils.buildOutgoingUri(number) - val failureMetadata = - FailureMetadata( - metadata, - "Failed to establish outgoing connection: Emergency number", - outgoingFailureType = OutgoingFailureType.EMERGENCY_NUMBER, - ) - - throw EmergencyNumberException(failureMetadata) - } else { - // If there is already an active call not on hold, we terminate it and start a new one, - // otherwise, we would encounter an exception when placing the outgoing call. - connectionManager.getActiveConnection()?.let { - Log.i(TAG, "onOutgoingCall, hung up previous call: $it") - it.hungUp() - } - - telephonyUtils.placeOutgoingCall(uri, metadata) + // If there is already an active call not on hold, we terminate it and start a new one, + // otherwise, we would encounter an exception when placing the outgoing call. + connectionManager.getActiveConnection()?.let { + Log.i(TAG, "onOutgoingCall, hung up previous call: $it") + it.hungUp() } + + telephonyUtils.placeOutgoingCall(uri, metadata) } /** diff --git a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/foreground/ForegroundService.kt b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/foreground/ForegroundService.kt index 9b690873..85551acf 100644 --- a/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/foreground/ForegroundService.kt +++ b/webtrit_callkeep_android/android/src/main/kotlin/com/webtrit/callkeep/services/services/foreground/ForegroundService.kt @@ -32,7 +32,6 @@ import com.webtrit.callkeep.common.TelephonyUtils import com.webtrit.callkeep.managers.NotificationChannelManager import com.webtrit.callkeep.models.CallConnectionState import com.webtrit.callkeep.models.CallMetadata -import com.webtrit.callkeep.models.EmergencyNumberException import com.webtrit.callkeep.models.FailedCallInfo import com.webtrit.callkeep.models.FailureMetadata import com.webtrit.callkeep.models.InvalidCallMetadataException @@ -375,15 +374,11 @@ class ForegroundService : OutgoingFailureSource.CS_CALLBACK, failureMetaData.getThrowable(), ) - val result = + val result: Result = when (failureMetaData.outgoingFailureType) { OutgoingFailureType.UNENTITLED -> { Result.failure(failureMetaData.getThrowable()) } - - OutgoingFailureType.EMERGENCY_NUMBER -> { - Result.success(PCallRequestError(PCallRequestErrorEnum.EMERGENCY_NUMBER)) - } } finish(result) } @@ -420,10 +415,6 @@ class ForegroundService : @SuppressLint("MissingPermission") core.startOutgoingCall(metadata) logger.i("$logContext: startOutgoingCall dispatched") - } catch (e: EmergencyNumberException) { - logger.e("$logContext failed: emergency number", e) - saveFailedOutgoingCall(metadata, OutgoingFailureSource.DISPATCH_ERROR, e) - finish(Result.success(PCallRequestError(PCallRequestErrorEnum.EMERGENCY_NUMBER))) } catch (e: Exception) { logger.e("$logContext failed: ${e.javaClass.simpleName}: ${e.message}", e) saveFailedOutgoingCall(metadata, OutgoingFailureSource.DISPATCH_ERROR, e) diff --git a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/models/FailureMetadataTest.kt b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/models/FailureMetadataTest.kt index 204ecfcd..c940224c 100644 --- a/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/models/FailureMetadataTest.kt +++ b/webtrit_callkeep_android/android/src/test/kotlin/com/webtrit/callkeep/models/FailureMetadataTest.kt @@ -18,17 +18,6 @@ class FailureMetadataTest { assertEquals(OutgoingFailureType.UNENTITLED, restored.outgoingFailureType) } - @Test - fun `round-trip preserves EMERGENCY_NUMBER failure type`() { - val original = FailureMetadata( - callMetadata = null, - message = "emergency", - outgoingFailureType = OutgoingFailureType.EMERGENCY_NUMBER, - ) - val restored = FailureMetadata.fromBundle(original.toBundle()) - assertEquals(OutgoingFailureType.EMERGENCY_NUMBER, restored.outgoingFailureType) - } - @Test fun `unknown string falls back to UNENTITLED`() { val bundle = Bundle().apply { putString("FAILURE_OUTGOING_TYPE", "FUTURE_TYPE") } diff --git a/webtrit_callkeep_android/docs/models.md b/webtrit_callkeep_android/docs/models.md index 09caa927..bd77be0e 100644 --- a/webtrit_callkeep_android/docs/models.md +++ b/webtrit_callkeep_android/docs/models.md @@ -116,15 +116,6 @@ Carry structured error information when a call fails to connect. --- -## EmergencyNumberException - -**File**: `kotlin/com/webtrit/callkeep/models/EmergencyNumberException.kt` - -Thrown (and caught, then reported to Dart) when the app attempts to handle an emergency number -(`112`, `911`, etc.) that must be routed to the system dialer instead. - ---- - ## Converters **File**: `kotlin/com/webtrit/callkeep/models/Converters.kt` From c23145d5da2f4a109b9964a68c6b285acf54bd3a Mon Sep 17 00:00:00 2001 From: Dmytro Date: Thu, 16 Jul 2026 21:14:01 +0300 Subject: [PATCH 2/2] build: bump version build iteration to 1.3.1+1 --- webtrit_callkeep/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webtrit_callkeep/pubspec.yaml b/webtrit_callkeep/pubspec.yaml index 8fcfc000..032d91dc 100644 --- a/webtrit_callkeep/pubspec.yaml +++ b/webtrit_callkeep/pubspec.yaml @@ -1,6 +1,6 @@ name: webtrit_callkeep description: Flutter WebTrit CallKeep plugin -version: 1.3.1+0 +version: 1.3.1+1 publish_to: none environment: