Observing phone call state in JetPack Compose

Many parts of your application might need to react to the phone being in a call. While registering an observer for phone state changes is simple, coordinating this across multiple components can become cumbersome. Here’s how to implement a centralized call monitoring mechanism for a cleaner and more maintainable approach.

This article demonstrates a basic approach to monitoring phone calls within a Jetpack Compose application. We’ll simulate a scenario where a chat app disconnects from the server and disables message sending during active calls. While this example focuses on call monitoring, the concepts can be applied to various scenarios where app behavior needs to adapt to phone state changes.

The application will feature a single screen composed of three distinct, independent Jetpack Compose functions, each leveraging its own dedicated ViewModel.

After creating an empty JetPack Compose project and integrating Hilt (you can read more on Hilt integration in my article Hilt dependency injection), we are going to create these classes:

PhoneCallReceiver:

  • Monitors Phone Calls: This class listens for changes in the phone’s call state.

PhoneCallStatusRepository:

  • Centralized Call State Provider: This repository acts as a single source of truth for phone call state. It exposes five streams (Flow) of data, allowing different parts of the application to be notified of call state changes.

ChatRepository (Simulated):

  • Mock Chat Data & Functionality: This class simulates a chat repository, holding a list of messages and offering a function to send new messages (for demonstration purposes).

In addition to that we must add permission into a manifest

<uses-permission android:name="android.permission.READ_PHONE_STATE"/>

The application requires the READ_PHONE_STATE permission to monitor call activity. We’ll leverage the Accompanist Permissions library for permission handling. While this article focuses on call monitoring itself, you can refer to the Accompanist documentation for details on permission request implementation.

Phone call receiver class

Here is a source code

class PhoneCallReceiver(
    var onOutgoingCallAccepted: () -> Unit,
    var onOutgoingCallEnded: () -> Unit,
    var onIncomingCallAccepted: () -> Unit,
    var onIncomingCallEnded: () -> Unit,
    var onIncomingCallRinging: () -> Unit
) :
    BroadcastReceiver() {
    var isRegistered = false

    override fun onReceive(context: Context?, intent: Intent?) {
        val state: Int = when (intent?.extras?.getString(TelephonyManager.EXTRA_STATE)) {
            TelephonyManager.EXTRA_STATE_IDLE -> TelephonyManager.CALL_STATE_IDLE
            TelephonyManager.EXTRA_STATE_OFFHOOK -> TelephonyManager.CALL_STATE_OFFHOOK
            TelephonyManager.EXTRA_STATE_RINGING -> TelephonyManager.CALL_STATE_RINGING
            else -> 0
        }
        onCallStateChanged(state)
    }

    private fun onCallStateChanged(state: Int?) {
        if (lastState == state) {
            return
        }

        when (state) {
            TelephonyManager.CALL_STATE_RINGING -> {
                isIncoming = true
                onIncomingCallRinging()
            }

            TelephonyManager.CALL_STATE_OFFHOOK -> {
                if (!isIncoming) {
                    onOutgoingCallAccepted()
                } else {
                    onIncomingCallAccepted()
                }
            }

            TelephonyManager.CALL_STATE_IDLE -> {
                if (isIncoming) {
                    onIncomingCallEnded()
                } else {
                    onOutgoingCallEnded()
                }

                isIncoming = false
            }
        }

        lastState = state as Int
    }

    companion object {
        private var lastState = TelephonyManager.CALL_STATE_IDLE
        private var isIncoming: Boolean = false

    }
}

This class extends BroadcastReceiver and listens for changes in the phone’s state using the onReceive method. To differentiate between incoming and outgoing calls, it analyzes the phone’s previous and current states:

  • Incoming Call: If the phone is ringing (state is RINGING), an onIncomingCallAccepted callback is triggered.
  • Outgoing Call: If the phone transitions from idle (state is IDLE) to off-hook (state is CALL_STATE_OFFHOOK), an onOutgoingCallStarted callback is triggered (assuming the call is accepted).

Phone call status repository

Source code of this repository. First interface:

interface PhoneCallStatusRepository {
    val incomingCallAccepted: StateFlow<Unit>
    val incomingCallEnded: StateFlow<Unit>
    val outgoingCallAccepted: StateFlow<Unit>
    val outgoingCallEnded: StateFlow<Unit>
    val incomingCallRinging: StateFlow<Unit>

    fun onIncomingCallAcceptedNotifyObservers()
    fun onIncomingCallEndedNotifyObservers()
    fun onOutgoingCallAcceptedNotifyObservers()
    fun onOutgoingCallEndedNotifyObservers()
    fun onIncomingCallRinging()
}

and actual implementation:


class PhoneCallStatusRepositoryImpl: PhoneCallStatusRepository{
    private var _incomingCallAccepted = EventStateFlow()
    override val incomingCallAccepted = _incomingCallAccepted.asStateFlow()

    private var _outgoingCallAccepted = EventStateFlow()
    override val outgoingCallAccepted = _outgoingCallAccepted.asStateFlow()

    private var _incomingCallEnded = EventStateFlow()
    override val incomingCallEnded = _incomingCallEnded.asStateFlow()

    private var _outgoingCallEnded = EventStateFlow()
    override val outgoingCallEnded = _outgoingCallEnded.asStateFlow()

    private var _incomingCallRinging = EventStateFlow()
    override val incomingCallRinging = _incomingCallRinging.asStateFlow()

    override fun onIncomingCallEndedNotifyObservers() {
        _incomingCallEnded.notifyObservers()
    }

    override fun onIncomingCallAcceptedNotifyObservers() {
        _incomingCallAccepted.notifyObservers()
    }

    override fun onOutgoingCallAcceptedNotifyObservers() {
        _outgoingCallAccepted.notifyObservers()
    }

    override fun onIncomingCallRinging() {
        _incomingCallRinging.notifyObservers()
    }

    override fun onOutgoingCallEndedNotifyObservers() {
        _outgoingCallEnded.notifyObservers()
    }

}

This utility class simplifies using Flow as an event source. It encapsulates an EventStateFlow property, providing a way to emit single events that are only delivered to current collectors. Here’s the source code for your reference:


/***
 * Class used as Event flow.
 * Uses [notifyObservers] functionality to notify observers
 * Uses Unit as data type and do not store cached value so attaching to this flow will not raise it immediately
 */
@OptIn(ExperimentalCoroutinesApi::class)
class EventStateFlow : MutableStateFlow<Unit> {
    override var value: Unit = Unit
        set(value) {
            field = value
            innerFlow.tryEmit(Unit)
        }

    private val innerFlow = MutableSharedFlow<Unit>(replay = 1)

    override fun compareAndSet(expect: Unit, update: Unit): Boolean {
        value = update
        return true
    }

    override suspend fun emit(value: Unit) {
        this.value = Unit
    }

    override fun tryEmit(value: Unit): Boolean {
        this.value = Unit
        return true
    }

    /***
     * Notify subscribers
     */
    fun notifyObservers() {
        innerFlow.tryEmit(Unit)
        resetReplayCache()
    }

    override val subscriptionCount: StateFlow<Int> = innerFlow.subscriptionCount

    override fun resetReplayCache() = innerFlow.resetReplayCache()
    override suspend fun collect(collector: FlowCollector<Unit>): Nothing = innerFlow.collect(collector)
    override val replayCache: List<Unit> = innerFlow.replayCache
}

Event-like Flow with EventStateFlow:

This custom class, built around EventStateFlow, offers event-like behavior for Jetpack Compose. Unlike MutableStateFlow, which caches values for new subscribers, EventStateFlow utilizes the notifyObservers method. This method not only notifies observers about the event but also clears the replay cache. This ensures new subscribers only receive the latest event, similar to how .NET Events function.

Rationale for a Custom Class:

Standard Flow is designed to retain values, allowing new subscribers to access past data. This behavior isn’t ideal for one-time events where you only want to notify observers about a specific occurrence without passing data. This custom class addresses that need by mimicking event-based communication.

Alternatives:

While this custom class provides a convenient solution, you can also achieve similar behavior with Flow directly but with additional logic to manage the event emission and clearing of past values.

Chat repository

Source code of interface:

interface ChatRepository {
    val messages: Flow<List<MessageModel>>

    fun sendMessage(message: String, isSystemMessage: Boolean)
}

and implementation:

class ChatRepositoryImpl(private val phoneCallStatusRepository: PhoneCallStatusRepository) : ChatRepository {

    private val _messages = MutableStateFlow<List<MessageModel>>(emptyList())
    override val messages = _messages.asStateFlow()

    init {
        CoroutineScope(Dispatchers.Main).launch {
            phoneCallStatusRepository.outgoingCallAccepted.collect {
                sendDisconnectedMessage()
            }
        }
        CoroutineScope(Dispatchers.Main).launch {
            phoneCallStatusRepository.outgoingCallEnded.collect {
                sendConnectingMessage()
            }
        }
        CoroutineScope(Dispatchers.Main).launch {
            phoneCallStatusRepository.incomingCallAccepted.collect {
                sendDisconnectedMessage()
            }
        }
        CoroutineScope(Dispatchers.Main).launch {
            phoneCallStatusRepository.incomingCallEnded.collect {
                sendConnectingMessage()
            }
        }

    }

    private fun sendDisconnectedMessage() {
        sendMessage(
            message = "Phone call is active. Disconnecting from chat",
            isSystemMessage = true
        )
    }

    private fun sendConnectingMessage() {
        sendMessage(
            message = "Phone call is not active. Connecting to chat",
            isSystemMessage = true
        )
    }

    override fun sendMessage(message: String, isSystemMessage: Boolean) {
        _messages.value = messages.value + MessageModel(
            message = message,
            type = if (isSystemMessage) MessageType.SYSTEM else MessageType.OUTGOING,
            time = SimpleDateFormat.getTimeInstance().format(Calendar.getInstance().time),
        )

        if (!isSystemMessage) {
            //simulate answer
            Timer().schedule(100L) {

                _messages.value = messages.value + MessageModel(
                    message = "Answer to : $message",
                    type = MessageType.INCOMING,
                    time = SimpleDateFormat.getTimeInstance().format(Calendar.getInstance().time),
                )
            }
        }
    }
}

ChatRepository with Hilt and Phone Call Monitoring:

This ChatRepository class, provided through Hilt dependency injection, manages chat functionalities. It offers methods to:

  • Access message history: Retrieve the current list of messages.
  • Send new messages: Add new user messages to the chat history.
  • Simulate chat replies (for demonstration): When a user sends a message, the repository automatically adds a corresponding reply message (for demonstration purposes only).

Integrating Phone Call Monitoring:

The ChatRepository also depends on an injected PhoneCallStatusRepository to monitor the phone’s call state. Here’s how it interacts with call state:

  • Incoming or outgoing call accepted: If the user accepts an incoming call or initiates an outgoing call, the repository adds a system message to the chat indicating the call status.

Now that we’ve established the core components, let’s explore how to integrate phone call monitoring into our application. We’ll achieve this by adding functionality to the MainActivity.

Registering phone call observer

For the beginning we will inject phone call repository like this:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var phoneCallStatusRepository: PhoneCallStatusRepository

next we are goiunt to create variable of PhoneCallReceiver class:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var phoneCallStatusRepository: PhoneCallStatusRepository

    private val phoneCallReceiver = PhoneCallReceiver(
        onIncomingCallAccepted = {
            phoneCallStatusRepository.onIncomingCallAcceptedNotifyObservers()
        },
        onIncomingCallEnded = {
            phoneCallStatusRepository.onIncomingCallEndedNotifyObservers()
        },
        onOutgoingCallEnded = {
            phoneCallStatusRepository.onOutgoingCallEndedNotifyObservers()
        },
        onOutgoingCallAccepted = {
            phoneCallStatusRepository.onOutgoingCallAcceptedNotifyObservers()
        },
        onIncomingCallRinging = {
            phoneCallStatusRepository.onIncomingCallRinging()
        }
    )

Let’s break down how MainActivity registers the phone call observer to monitor call state changes:

  1. PhoneCallReceiver: We have a phoneCallReceiver variable that listens for phone state changes. Upon detecting a change, it notifies the PhoneCallStatusRepository using one of its notifyObservers functions.
  2. initHooks Function: We’ll create an initHooks function within MainActivity to handle initialization tasks, including registering the phone call observer.
    private fun initHooks() {
        registerReceiver(phoneCallReceiver, IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED))
        phoneCallReceiver.isRegistered = true
    }

then we are adding LaunchedEffect which will execute initHooks

            PhoneCallObserverExampleTheme {
                val navController: NavHostController = rememberNavController()

                LaunchedEffect(key1 = true) {
                    initHooks()
                }

and do not forget to unregister observer in onDestroy function of MainActivity

    override fun onDestroy() {
        if (phoneCallReceiver.isRegistered) {
            unregisterReceiver(phoneCallReceiver)
        }
        super.onDestroy()
    }

From Registration to Observation:

With the phone call observer registered, let’s shift our focus to the user interface (UI). We’ll explore how ViewModels handle phone call state observation within the UI layer.

UI and ViewModels

Main screen for our chat applicatioln will be a ChatScreen:

@Composable
fun ChatScreen() {
    Scaffold {
        Column(
            modifier = Modifier
                .padding(it)
                .padding(16.dp)
                .fillMaxSize()
        ) {
            ChatPhoneStateLog(modifier = Modifier.height(200.dp))
            ChatMessages(modifier = Modifier.weight(1f, fill = true))
            ChatInput()
        }
    }
}

Main Screen with Separate Composables:

The main screen of our application is a single Jetpack Compose function composed of three independent, non-interacting child composables:

  1. ChatPhoneStateLog: This composable displays a list of all recorded phone call activities.
  2. ChatMessages: This composable displays the chat message history as a list.
  3. ChatInput: This composable provides an input text box and a send button for user interaction.

Focus on ViewModel Functionality:

While the UI elements in these composables are fairly straightforward, the core logic resides in their respective ViewModels. Let’s delve into the first ViewModel, ChatInputViewModel. Here’s how it’s declared:

@HiltViewModel
class ChatInputViewModel @Inject constructor(
    private val phoneCallStatusRepository: PhoneCallStatusRepository,
    private val chatRepository: ChatRepository
) : ViewModel() {

Hilt DI will inject PhoneCallStatusRepository and ChatRepository. ViewModel will subscribe to PhoneCallStatusRepository. This is how you do it:

init {
        viewModelScope.launch {
            phoneCallStatusRepository.incomingCallAccepted.collect {
                isPhoneCallActive = true
            }
        }
        viewModelScope.launch {
            phoneCallStatusRepository.outgoingCallAccepted.collect {
                isPhoneCallActive = true
            }
        }

        viewModelScope.launch {
            phoneCallStatusRepository.outgoingCallEnded.collect {
                isPhoneCallActive = false
            }
        }

        viewModelScope.launch {
            phoneCallStatusRepository.incomingCallEnded.collect {
                isPhoneCallActive = false
            }
        }
    }

The ChatInputViewModel subscribes to the PhoneCallStatusRepository‘s Flow to stay updated on the phone’s call state. Based on these updates, it manages a Boolean property called isPhoneCallActive.

ViewModel to display chat messages is very simple one. this is full source code:

@HiltViewModel
class ChatMessagesViewModel @Inject constructor(private val chatRepository: ChatRepository):ViewModel() {
    var messages = mutableStateOf<List<MessageModel>>(emptyList())
        private set
    init {
        viewModelScope.launch {
            chatRepository.messages.collect {
                messages.value = it
            }
        }
    }
}

It subscribes to Chat repository’s messages Flow and collects it when any changes are made.

ViewModel for chat phone state log does similar job as ChatInputViewModel by subscribing to PhoneCallStatusRepository events:

init {
        viewModelScope.launch {
            phoneCallStatusRepository.incomingCallAccepted.collect {
                addToLog("onIncomingCallAccepted")
            }
        }
        viewModelScope.launch {
            phoneCallStatusRepository.outgoingCallAccepted.collect {
                addToLog("onOutgoingCallStarted")
            }
        }

        viewModelScope.launch {
            phoneCallStatusRepository.outgoingCallEnded.collect {
                addToLog("onOutgoingCallEnded")
            }
        }

        viewModelScope.launch {
            phoneCallStatusRepository.incomingCallEnded.collect {
                addToLog("onIncomingCallEnded")
            }
        }

        viewModelScope.launch {
            phoneCallStatusRepository.incomingCallRinging.collect {
                addToLog("onIncomingCallRinging")
            }
        }
    }

But this time ViewModel wil only add events to the log by using addToLog function:

    private fun addToLog(value: String) {
        val time = Calendar.getInstance().time

        val current = formatter.format(time)
        _list.add("$current - $value")
    }

This is how UI looks like

To simulate phone call we can use android emulator’s feature

If you make a call and accept it this is what UI wil look like

Centralized vs. Decentralized Phone Call Monitoring:

This example demonstrates how a centralized repository (PhoneCallStatusRepository) facilitates independent phone call state observation across various application parts. This approach offers advantages over directly observing phone calls within individual composable ViewModels:

  • Improved Maintainability: Centralized state management reduces code duplication and simplifies logic associated with phone call state handling.
  • Scalability: The repository pattern allows for easier addition of new components that need to react to phone calls without modifying existing ViewModels.
  • Flexibility: The repository can be extended to handle additional state types beyond phone calls, promoting a reusable approach for event observation.

Broader Applicability:

This centralized observation technique is not limited to phone calls. It’s a valuable pattern for handling any type of event that requires observation from multiple parts of your application. The pattern can be applied not only to ViewModels but also to repositories and use case classes, promoting modularity and code reuse.

As always full source code is available on Phone call state observer (github.com)

Spread the love

Related Post