diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a38859..328366fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ are not listed here. ## [Unreleased] +### Fixed + +- **Android:** An outgoing call's connection stays in `STATE_DIALING`, and an incoming call's in `STATE_RINGING`, as Telecom shows them. `setInitialized()`, called after `setDialing()` or `setRinging()`, put the connection back to `STATE_NEW`: `VoiceConnectionService.getConnection(uuid).getState()` returned `STATE_NEW` while the call was dialing or ringing. Samsung devices already skipped it for outgoing calls, where it kept the native UI from showing; every device now does as they did. + ## [4.6.0] - 2026-10-18 A call that iOS or Android refuses to start is now reported to JavaScript, with the new `startCallFailed` event. diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index 95fbab77..7f2d7950 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -272,7 +272,6 @@ public Connection onCreateIncomingConnection(PhoneAccountHandle connectionManage Connection incomingCallConnection = createConnection(request); incomingCallConnection.setRinging(); - incomingCallConnection.setInitialized(); startForegroundService(); @@ -351,13 +350,6 @@ private Connection makeOutgoingCall(ConnectionRequest request, String uuid, Bool startForegroundService(); - // ‍️Weirdly on some Samsung phones (A50, S9...) using `setInitialized` will not display the native UI ... - // when making a call from the native Phone application. The call will still be displayed correctly without it. - if (!Build.MANUFACTURER.equalsIgnoreCase("Samsung")) { - Log.d(TAG, "[VoiceConnectionService] onCreateOutgoingConnection: initializing connection on non-Samsung device"); - outgoingCallConnection.setInitialized(); - } - HashMap extrasMap = this.bundleToMap(extras); sendCallRequestToActivity(ACTION_ONGOING_CALL, extrasMap, true); diff --git a/tests/android/CallKeepConnectionStateTest.kt b/tests/android/CallKeepConnectionStateTest.kt new file mode 100644 index 00000000..175c8a56 --- /dev/null +++ b/tests/android/CallKeepConnectionStateTest.kt @@ -0,0 +1,131 @@ +package io.wazo.callkeep + +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.Process +import android.os.UserManager +import android.telecom.Connection +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.BridgeReactContext +import io.wazo.callkeep.RNCallKeepModule +import io.wazo.callkeep.VoiceConnectionService +import java.util.UUID +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** Uses the real Telecom service. The host declares VoiceConnectionService, RECORD_AUDIO, + * CALL_PHONE, READ_PHONE_STATE and READ_PHONE_NUMBERS, granted when installed (`adb install -g`), + * as for CallKeepStartCallFailedTest. */ +@RunWith(AndroidJUnit4::class) +class CallKeepConnectionStateTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private lateinit var module: RNCallKeepModule + private val calls = mutableListOf() + + @After fun tearDown() { + if (!::module.isInitialized) return + instrumentation.runOnMainSync { + calls.forEach { VoiceConnectionService.getConnection(it)?.onDisconnect() } + module.clearInitialEvents() + } + shell("telecom unregister-phone-account ${account()}") + } + + @Test fun anOutgoingSelfManagedCallIsDialing() = outgoingCallIsDialing(selfManaged = true) + + @Test fun anOutgoingManagedCallIsDialing() = outgoingCallIsDialing(selfManaged = false) + + @Test fun anIncomingSelfManagedCallIsRinging() = incomingCallIsRinging(selfManaged = true) + + @Test fun anIncomingManagedCallIsRinging() = incomingCallIsRinging(selfManaged = false) + + private fun outgoingCallIsDialing(selfManaged: Boolean) { + setUp(selfManaged) + val uuid = UUID.randomUUID().toString().also { calls += it } + instrumentation.runOnMainSync { module.startCall(uuid, "5551001", "Outgoing", false) } + + waitFor("Telecom shows the call dialing") { telecomState() == "DIALING" } + assertEquals(Connection.STATE_DIALING, stateOf(uuid)) + + instrumentation.runOnMainSync { module.setCurrentCallActive(uuid) } + assertEquals(Connection.STATE_ACTIVE, stateOf(uuid)) + waitFor("Telecom shows the call active") { telecomState() == "ACTIVE" } + } + + private fun incomingCallIsRinging(selfManaged: Boolean) { + setUp(selfManaged) + val uuid = UUID.randomUUID().toString().also { calls += it } + instrumentation.runOnMainSync { module.displayIncomingCall(uuid, "5551002", "Incoming", false) } + + waitFor("Telecom shows the call ringing") { telecomState() == "RINGING" } + assertEquals(Connection.STATE_RINGING, stateOf(uuid)) + + instrumentation.runOnMainSync { + module.answerIncomingCall(uuid) + module.setCurrentCallActive(uuid) + } + assertEquals(Connection.STATE_ACTIVE, stateOf(uuid)) + waitFor("Telecom shows the call active") { telecomState() == "ACTIVE" } + } + + private fun setUp(selfManaged: Boolean) { + // Android 16 doesn't turn a phone account, left by another test, into the other kind. + shell("telecom unregister-phone-account ${account()}") + instrumentation.runOnMainSync { + module = RNCallKeepModule.getInstance(BridgeReactContext(instrumentation.targetContext), true) + module.setup(Arguments.createMap().apply { putBoolean("selfManaged", selfManaged) }) + module.clearInitialEvents() + } + // A phone account that isn't self-managed is disabled until the user enables it. + if (!selfManaged) shell("telecom set-phone-account-enabled ${account()}") + } + + /** The connection's state once Telecom has created it. */ + private fun stateOf(uuid: String): Int { + waitFor("Telecom creates the connection") { VoiceConnectionService.getConnection(uuid) != null } + var state = -1 + instrumentation.runOnMainSync { state = VoiceConnectionService.getConnection(uuid)!!.state } + return state + } + + /** The state Telecom shows for the only call, read from `dumpsys telecom`. */ + private fun telecomState(): String? { + val dump = shell("dumpsys telecom") + val section = dump.substringAfter("mCalls:") + // "[TC@1, DIALING, ..." up to Android 10, "[Call id=TC@1, state=DIALING, ..." later. + val states = Regex("""\[(?:Call id=)?TC@\d+, (?:state=)?([A-Z_]+),""").findAll(section) + .map { it.groupValues[1] }.toList() + Log.i("CallKeepConnectionStateTest", "Telecom call states: $states") + return states.firstOrNull() + } + + /** The phone account's component, id and user serial number, as `adb shell telecom` takes them. */ + private fun account(): String { + val context = instrumentation.targetContext + val id = context.applicationInfo.loadLabel(context.packageManager) + val user = (context.getSystemService(Context.USER_SERVICE) as UserManager) + .getSerialNumberForUser(Process.myUserHandle()) + return "${context.packageName}/${VoiceConnectionService::class.java.name} $id $user" + } + + private fun shell(command: String): String { + val output = instrumentation.uiAutomation.executeShellCommand(command) + return ParcelFileDescriptor.AutoCloseInputStream(output).use { String(it.readBytes()) } + } + + private fun waitFor(what: String, condition: () -> Boolean) { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + var met = false + instrumentation.runOnMainSync { met = condition() } + if (met) return + Thread.sleep(50) + } + fail("timed out: $what") + } +}