Expose the write sides of the current-call and ringing state

`CurrentCallService` and `ActiveCallManager` are read by the whole app but
written only by whoever is actually running a call. Writing has so far been
reachable only from inside `features/call/impl`, because that is where both
implementations live.

Adds `CurrentCallTracker` and `RingingCallTracker` to `features/call/api`, so
that a call implementation in another module can report the call it is running
and answer a ring, without depending on `features/call/impl`.

No behaviour change: both interfaces are implemented by the existing classes,
and their methods are aliases of methods that already existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Valere
2026-09-14 19:04:13 +02:00
committed by Valere Fedronic
parent 93b08e3508
commit 0059c72a32
6 changed files with 120 additions and 6 deletions
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.api
/**
* The write side of [CurrentCallService], for whoever is actually running a call.
*
* Separate from [CurrentCallService] because the two have opposite audiences: everything in the app
* that wants to know whether we are in a call reads the service, while only a call implementation
* writes. The WebView path reaches the implementation directly through `ActiveCallManager`, both
* being in `features/call/impl`; the native path is in another module and would otherwise have to
* depend on that impl to report a call it is running itself.
*/
interface CurrentCallTracker {
fun onCallStarted(call: CurrentCall)
fun onCallEnded()
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.api
/**
* Tells the ringing machinery what became of the call it is ringing for.
*
* There is one ring per incoming call, and it is owned by `ActiveCallManager` in
* `features/call/impl` - but *whichever* call implementation answers has to stop it, cancel the
* missed-call timeout and release the wake lock. The WebView path reaches the manager directly,
* being in the same module; the native path is in another one, and this is the narrow slice of it
* that anyone answering a call needs.
*/
interface RingingCallTracker {
/**
* The user is now in the call: stop ringing, take down the incoming-call UI, and cancel the
* timeout that would otherwise post a missed call.
*/
suspend fun onCallJoined(callData: CallData)
/** The call is over. Clears the active call, and stops a ring that is still in progress. */
suspend fun onCallEnded(callData: CallData)
}
@@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.RingingCallTracker
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -90,7 +92,8 @@ interface ActiveCallManager {
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
@ContributesBinding(AppScope::class, binding = binding<ActiveCallManager>())
@ContributesBinding(AppScope::class, binding = binding<RingingCallTracker>())
class DefaultActiveCallManager(
@ApplicationContext context: Context,
@AppCoroutineScope
@@ -103,7 +106,16 @@ class DefaultActiveCallManager(
private val appForegroundStateService: AppForegroundStateService,
private val imageLoaderHolder: ImageLoaderHolder,
private val systemClock: SystemClock,
) : ActiveCallManager {
) : ActiveCallManager, RingingCallTracker {
/**
* The two [RingingCallTracker] entry points are the existing methods under the names a caller
* outside this module can reach. Deliberately not new behaviour: answering has always had to do
* exactly this, and the native path simply could not say so from another module.
*/
override suspend fun onCallJoined(callData: CallData) = joinedCall(callData)
override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData)
private val tag = "ActiveCallManager"
private var timedOutCallJob: Job? = null
@@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.api.CurrentCallTracker
import kotlinx.coroutines.flow.MutableStateFlow
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCurrentCallService : CurrentCallService {
@ContributesBinding(AppScope::class, binding = binding<CurrentCallService>())
@ContributesBinding(AppScope::class, binding = binding<CurrentCallTracker>())
class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) {
override fun onCallStarted(call: CurrentCall) {
currentCall.value = call
}
fun onCallEnded() {
override fun onCallEnded() {
currentCall.value = CurrentCall.None
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallTracker
class FakeCurrentCallTracker : CurrentCallTracker {
/** Every transition in order, so a test can assert the call was reported before it was cleared. */
val calls = mutableListOf<CurrentCall>()
override fun onCallStarted(call: CurrentCall) {
calls += call
}
override fun onCallEnded() {
calls += CurrentCall.None
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.RingingCallTracker
class FakeRingingCallTracker : RingingCallTracker {
val joined = mutableListOf<CallData>()
val ended = mutableListOf<CallData>()
override suspend fun onCallJoined(callData: CallData) {
joined += callData
}
override suspend fun onCallEnded(callData: CallData) {
ended += callData
}
}