diff --git a/atox/src/main/AndroidManifest.xml b/atox/src/main/AndroidManifest.xml index 9698eaa8c..533c45e27 100644 --- a/atox/src/main/AndroidManifest.xml +++ b/atox/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ + + contactRepository.setStatusMessage(publicKey, message) @@ -197,7 +202,14 @@ class EventListenerCallbacks @Inject constructor( callStateHandler = { pk, callState -> Log.e(TAG, "callState ${pk.fingerprint()} $callState") - if (callState.contains(ToxavFriendCallState.FINISHED) || callState.contains(ToxavFriendCallState.ERROR)) { + if (callState.contains(ToxavFriendCallState.SENDING_A) || + callState.contains(ToxavFriendCallState.ACCEPTING_A) + ) { + callManager.setAnswered(PublicKey(pk)) + } + if (callState.contains(ToxavFriendCallState.FINISHED) || + callState.contains(ToxavFriendCallState.ERROR) + ) { audioPlayer?.stop() audioPlayer?.release() audioPlayer = null @@ -238,8 +250,10 @@ class EventListenerCallbacks @Inject constructor( if (audioPlayer == null) { audioPlayer = AudioPlayer(samplingRate, channels) audioPlayer?.start() + frameCount.set(0) } audioPlayer?.buffer(pcm) + frameCount.incrementAndGet() } } } diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 1b2fa540e..609332e0c 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -6,7 +6,11 @@ package ltd.evilcorp.atox.ui.call import android.Manifest +import android.media.MediaPlayer +import android.os.Build import android.os.Bundle +import android.os.SystemClock +import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -14,8 +18,14 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import androidx.fragment.app.viewModels -import androidx.lifecycle.asLiveData +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentCallBinding import ltd.evilcorp.atox.hasPermission @@ -24,9 +34,11 @@ import ltd.evilcorp.atox.ui.BaseFragment import ltd.evilcorp.atox.ui.chat.CONTACT_PUBLIC_KEY import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.core.vo.PublicKey -import ltd.evilcorp.domain.feature.CallState +import ltd.evilcorp.domain.feature.Call +import ltd.evilcorp.domain.feature.inCall private const val PERMISSION = Manifest.permission.RECORD_AUDIO +private const val TAG = "CallFragment" class CallFragment : BaseFragment(FragmentCallBinding::inflate) { private val vm: CallViewModel by viewModels { vmFactory } @@ -35,13 +47,20 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) { - vm.startSendingAudio() + vm.setMicrophoneOn() + updateMicrophoneControlIcon() } else { + Log.d(TAG, "Got no permission") Toast.makeText(requireContext(), getString(R.string.call_mic_permission_needed), Toast.LENGTH_LONG).show() } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { + private var mediaPlayer: MediaPlayer? = null + private var timerNHandle: Job? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run { + Log.d(TAG, "onViewCreated here") + ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars()) controlContainer.updatePadding(bottom = insets.bottom + controlContainer.paddingTop) @@ -51,31 +70,31 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl vm.setActiveContact(PublicKey(requireStringArg(CONTACT_PUBLIC_KEY))) vm.contact.observe(viewLifecycleOwner) { avatarImageView.setFrom(it) + tvData.setText(it.name) } endCall.setOnClickListener { + Log.d(TAG, "finishing by End Call") vm.endCall() - findNavController().popBackStack() + adoptState(Call.State.IDLE) } - vm.sendingAudio.asLiveData().observe(viewLifecycleOwner) { sending -> - if (sending) { - microphoneControl.setImageResource(R.drawable.ic_mic) - } else { - microphoneControl.setImageResource(R.drawable.ic_mic_off) - } - } + vm.micOn = requireContext().hasPermission(PERMISSION) + updateMicrophoneControlIcon() microphoneControl.setOnClickListener { - if (vm.sendingAudio.value) { - vm.stopSendingAudio() + if (!requireContext().hasPermission(PERMISSION)) { + vm.micOn = false + /*Toast.makeText( + context, + R.string.call_mic_permission_needed, + Toast.LENGTH_LONG + ).show()*/ + requestPermissionLauncher.launch(PERMISSION) } else { - if (requireContext().hasPermission(PERMISSION)) { - vm.startSendingAudio() - } else { - requestPermissionLauncher.launch(PERMISSION) - } + vm.toggleMicrophoneControl() } + updateMicrophoneControlIcon() } updateSpeakerphoneIcon() @@ -88,33 +107,110 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl findNavController().popBackStack() } - if (vm.inCall.value is CallState.InCall) { - vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> - if (inCall == CallState.NotInCall) { - findNavController().popBackStack() - } - } - return + vm.callLiveData.observe(viewLifecycleOwner) { call -> + Log.d(TAG, "observer here") + adoptState() } - startCall() - - if (requireContext().hasPermission(PERMISSION)) { - vm.startSendingAudio() + if (vm.call.value.state != Call.State.IDLE && + vm.call.value.state != Call.State.PENDING + ) { + adoptState() + return@run } - } + binding.tvState.setText("startinng a call...") // normally, not to be seen + vm.startCall() + } // end onViewCreated + + /*override fun onResume() = binding.run { + val nme = vm.call.value.state + Log.d(TAG, "onResume here, state=$nme") + super.onResume() + }*/ private fun updateSpeakerphoneIcon() { - val icon = if (vm.speakerphoneOn) R.drawable.ic_speakerphone else R.drawable.ic_speakerphone_off + val icon = if (vm.speakerphoneOn) { + R.drawable.ic_speakerphone + } else { + R.drawable.ic_speakerphone_off + } binding.speakerphone.setImageResource(icon) } - private fun startCall() { - vm.startCall() - vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> - if (inCall == CallState.NotInCall) { + private fun updateMicrophoneControlIcon() { + val icon = if (vm.micOn) { + R.drawable.ic_mic + } else { + R.drawable.ic_mic_off + } + binding.microphoneControl.setImageResource(icon) + } + private fun adoptState() { + adoptState(vm.call.value.state) + } + private fun adoptState(state: Call.State) { + // may be called repeatedly, so must be idempotent + Log.d(TAG, "adoptState, state = $state") + when (state) { + Call.State.CALLING_OUT -> { + binding.tvState.setText(getString(R.string.ringing)) + playConnecting() + } + Call.State.ANSWERED -> { + stopPlay() + binding.tvState.setText("talking") + startTimer() + if (!vm.sendingAudio.value && vm.micOn) { + if (requireContext().hasPermission(PERMISSION)) { + vm.startSendingAudio() + } + } + } + // as LiveData never emits its init value, IDLE means the call is finished + Call.State.IDLE -> { + binding.tvState.setText("00000") + stopPlay() findNavController().popBackStack() } + else -> Log.e(TAG, "STATE = $state") + } + } + + private fun playConnecting() { + val audioAttrContext = + if (Build.VERSION.SDK_INT >= 30) { + context?.createAttributionContext("audioPlayback") + } else { + context + } + if (mediaPlayer == null) { + mediaPlayer = MediaPlayer.create(audioAttrContext, R.raw.connecting_ringtone) + mediaPlayer?.setLooping(true) + } + mediaPlayer?.start() + } + + private fun stopPlay() { + mediaPlayer?.stop() + mediaPlayer = null + } + + private fun startTimer() { + if (!vm.call.value.inCall()) return + if (timerNHandle?.isActive == true) return + val from: Long = vm.call.value.data?.startTime ?: 0 + timerNHandle = lifecycleScope.launch(Dispatchers.IO) { + while (vm.call.value.inCall()) { + lifecycleScope.launch { + val elapsed: Duration = (SystemClock.elapsedRealtime() - from).milliseconds + val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> + // String.format("%01d:%02d:%02d", hours, minutes, seconds) + vm.presentTime(hours, minutes, seconds, nanoseconds) + } + binding.tvState.setText(s) + } + delay(vm.screenTimerMs) + } } } } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index 5adcbfacd..44af8f776 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -8,14 +8,17 @@ package ltd.evilcorp.atox.ui.call import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import ltd.evilcorp.atox.ProximityScreenOff +import ltd.evilcorp.atox.tox.EventListenerCallbacks import ltd.evilcorp.atox.ui.NotificationHelper import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.PublicKey +import ltd.evilcorp.domain.feature.Call import ltd.evilcorp.domain.feature.CallManager import ltd.evilcorp.domain.feature.ContactManager @@ -26,6 +29,13 @@ class CallViewModel @Inject constructor( private val contactManager: ContactManager, private val proximityScreenOff: ProximityScreenOff, ) : ViewModel() { + val vmContext = viewModelScope.coroutineContext + var storedFrameCount: Int = 0 + + @Inject + lateinit var eventListenerCallbacks: EventListenerCallbacks + + val screenTimerMs = 1000L private var publicKey = PublicKey("") val contact: LiveData by lazy { @@ -58,8 +68,58 @@ class CallViewModel @Inject constructor( } } - val inCall = callManager.inCall - val sendingAudio = callManager.sendingAudio + var micOn = false + fun toggleMicrophoneControl() { + if (micOn) { + micOn = false + if (sendingAudio.value) { + stopSendingAudio() + } + } else { + setMicrophoneOn() + } + } + + fun setMicrophoneOn() { + micOn = true + if (!sendingAudio.value && call.value.state == Call.State.ANSWERED) { + startSendingAudio() + } + } + + fun presentTime(hours: Long, minutes: Int, seconds: Int, nanoseconds: Int): String { + var sf: String = when (call.value.data?.inOrOut) { + Call.InOrOut.INCOMING -> "in " + Call.InOrOut.OUTGOING -> "out " + else -> "" + } + sf += if (hours == 0L) { + String.format("%02d:%02d", minutes, seconds) + } else { + String.format("%01d:%02d:%02d", hours, minutes, seconds) + } + + sf += presentFrameRate() + return sf + } + fun presentFrameRate(): String { + val new = eventListenerCallbacks.getFrameCount() + var delta = new - storedFrameCount + if (delta < 0) delta = 0 + storedFrameCount = new + // normal fps = screenTimerMs / CallManager.AUDIO_SEND_INTERVAL_MS = 50 + val asSymbol = if (delta > 25) { + " +" // audio link is working + } else { + " X" // few or no frames are coming in + } + return asSymbol + // return " ${delta}fps" + } + + val call = callManager.call + val callLiveData = callManager.call.asLiveData(vmContext) + val sendingAudio = callManager.sendingAudio var speakerphoneOn by callManager::speakerphoneOn } diff --git a/atox/src/main/kotlin/ui/chat/ChatFragment.kt b/atox/src/main/kotlin/ui/chat/ChatFragment.kt index f9a504063..4d2beb8e3 100644 --- a/atox/src/main/kotlin/ui/chat/ChatFragment.kt +++ b/atox/src/main/kotlin/ui/chat/ChatFragment.kt @@ -55,7 +55,7 @@ import ltd.evilcorp.core.vo.Message import ltd.evilcorp.core.vo.MessageType import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.core.vo.isComplete -import ltd.evilcorp.domain.feature.CallState +import ltd.evilcorp.domain.feature.inCall private const val TAG = "ChatFragment" const val CONTACT_PUBLIC_KEY = "publicKey" @@ -252,11 +252,11 @@ class ChatFragment : BaseFragment(FragmentChatBinding::infl } viewModel.ongoingCall.observe(viewLifecycleOwner) { - if (it is CallState.InCall && it.publicKey.string() == contactPubKey) { + if (it.inCall() && it.data?.publicKey?.string() == contactPubKey) { ongoingCall.container.visibility = View.VISIBLE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { ongoingCall.duration.visibility = View.VISIBLE - ongoingCall.duration.base = it.startTime + ongoingCall.duration.base = it.data?.startTime!! ongoingCall.duration.isCountDown = false ongoingCall.duration.start() } else { diff --git a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt index 15a676845..6689887b8 100644 --- a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt +++ b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt @@ -37,11 +37,11 @@ import ltd.evilcorp.core.vo.Message import ltd.evilcorp.core.vo.MessageType import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.feature.CallManager -import ltd.evilcorp.domain.feature.CallState import ltd.evilcorp.domain.feature.ChatManager import ltd.evilcorp.domain.feature.ContactManager import ltd.evilcorp.domain.feature.ExportManager import ltd.evilcorp.domain.feature.FileTransferManager +import ltd.evilcorp.domain.feature.inCall private const val TAG = "ChatViewModel" @@ -73,13 +73,24 @@ class ChatViewModel @Inject constructor( val fileTransfers: LiveData> by lazy { fileTransferManager.transfersFor(publicKey).asLiveData() } fun callingNeedsConfirmation(): Boolean = settings.confirmCalling - val ongoingCall = callManager.inCall.asLiveData() + val ongoingCall = callManager.call.asLiveData() val callState get() = contactManager.get(publicKey) .filterNotNull() .transform { emit(it.connectionStatus != ConnectionStatus.None) } - .combine(callManager.inCall) { contactOnline, callState -> + .combine(callManager.call) { contactOnline, callValue -> if (!contactOnline) return@combine CallAvailability.Unavailable + if (callValue.inCall()) { + if (callValue.data?.publicKey == publicKey) { + CallAvailability.Active + } else { + CallAvailability.Unavailable + } + } else { + CallAvailability.Available + } + /* + val callState = callValue.state when (callState) { CallState.NotInCall -> CallAvailability.Available is CallState.InCall -> { @@ -89,7 +100,7 @@ class ChatViewModel @Inject constructor( CallAvailability.Unavailable } } - } + }*/ }.asLiveData() var contactOnline = false diff --git a/atox/src/main/res/layout/fragment_call.xml b/atox/src/main/res/layout/fragment_call.xml index 2fc54c936..7978cc9b4 100644 --- a/atox/src/main/res/layout/fragment_call.xml +++ b/atox/src/main/res/layout/fragment_call.xml @@ -12,6 +12,25 @@ android:layout_alignParentStart="true" android:layout_alignParentEnd="true" android:layout_above="@id/control_container"> + + Export history Message history exported Message history export failed: %1$s + ringing... + This app plays audio \ No newline at end of file diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index 7a8240991..b97f99015 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -16,17 +16,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.av.AudioCapture import ltd.evilcorp.domain.tox.Tox -sealed class CallState { - object NotInCall : CallState() - data class InCall(val publicKey: PublicKey, val startTime: Long) : CallState() +data class Call(public val state: State = State.IDLE, public val data: Data? = null) { + enum class State { IDLE, CALLING_OUT, PENDING, ANSWERED } + enum class InOrOut { NA, INCOMING, OUTGOING } + data class Data(val publicKey: PublicKey, val inOrOut: InOrOut, val startTime: Long) } +fun Call.setData(aPk: PublicKey, aInOrOut: Call.InOrOut, aSt: Long): Call = + this.copy(data = Call.Data(aPk, aInOrOut, aSt)) +fun Call.setState(newState: Call.State): Call { + if (newState == Call.State.IDLE) return Call(newState, null) + return Call(newState, data) +} +fun Call.inCall(): Boolean = state == Call.State.CALLING_OUT || state == Call.State.ANSWERED + private const val TAG = "CallManager" private const val AUDIO_CHANNELS = 1 @@ -35,8 +45,8 @@ private const val AUDIO_SEND_INTERVAL_MS = 20 @Singleton class CallManager @Inject constructor(private val tox: Tox, private val scope: CoroutineScope, context: Context) { - private val _inCall = MutableStateFlow(CallState.NotInCall) - val inCall: StateFlow get() = _inCall + private val _call = MutableStateFlow(Call(Call.State.IDLE, null)) + val call: StateFlow get() = _call private val _pendingCalls = MutableStateFlow>(mutableSetOf()) val pendingCalls: StateFlow> get() = _pendingCalls @@ -46,6 +56,23 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C private val audioManager = ContextCompat.getSystemService(context, AudioManager::class.java) + private fun toState(newState: Call.State) { + _call.update { current -> + current.setState(newState) + } + } + + private fun addCallData(newState: Call.State, aPk: PublicKey, aInOrOut: Call.InOrOut, aSt: Long) { + _call.update { current -> + current.setState(newState).setData(aPk, aInOrOut, aSt) + } + } + private fun addCallData(newState: Call.State, aPk: PublicKey, aInOrOut: Call.InOrOut) { + _call.update { current -> + current.setState(newState).setData(aPk, aInOrOut, SystemClock.elapsedRealtime()) + } + } + fun addPendingCall(from: Contact) { val calls = mutableSetOf().apply { addAll(_pendingCalls.value) } calls.addAll(_pendingCalls.value) @@ -53,6 +80,13 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C Log.i(TAG, "Added pending call ${from.publicKey.take(8)}") _pendingCalls.value = calls } + if (!_pendingCalls.value.isEmpty()) { + if (_call.value.state == Call.State.IDLE) { + toState(Call.State.PENDING) + } else { + Log.e(TAG, "Got pending call while state=${_call.value.state}") + } + } } fun removePendingCall(pk: PublicKey) { @@ -63,24 +97,32 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C calls.remove(removed) _pendingCalls.value = calls } + if (_pendingCalls.value.isEmpty() && _call.value.state == Call.State.PENDING) { + toState(Call.State.IDLE) + } } fun startCall(publicKey: PublicKey) { - if (pendingCalls.value.any { it.publicKey == publicKey.string() }) { + val toAnswer = pendingCalls.value.any { it.publicKey == publicKey.string() } + if (toAnswer) { tox.answerCall(publicKey) } else { tox.startCall(publicKey) } - _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION removePendingCall(publicKey) + if (toAnswer) { + addCallData(Call.State.ANSWERED, publicKey, Call.InOrOut.INCOMING) + } else { + addCallData(Call.State.CALLING_OUT, publicKey, Call.InOrOut.OUTGOING) + } } fun endCall(publicKey: PublicKey) { - val state = inCall.value - if (state is CallState.InCall && state.publicKey == publicKey) { + if (call.value.inCall() && call.value.data?.publicKey == publicKey) { audioManager?.mode = AudioManager.MODE_NORMAL - _inCall.value = CallState.NotInCall + // move to below ? + toState(Call.State.IDLE) } removePendingCall(publicKey) @@ -95,9 +137,16 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } fun startSendingAudio(): Boolean { - val to = (inCall.value as CallState.InCall?)?.publicKey ?: return false - val recorder = - AudioCapture.create(AUDIO_SAMPLING_RATE_HZ, AUDIO_CHANNELS, AUDIO_SEND_INTERVAL_MS) ?: return false + if (!call.value.inCall()) return false + val to = call.value.data?.publicKey ?: return false + if (_sendingAudio.value) { + return true + } + val recorder = AudioCapture.create( + AUDIO_SAMPLING_RATE_HZ, + AUDIO_CHANNELS, + AUDIO_SEND_INTERVAL_MS, + ) ?: return false startAudioSender(recorder, to) return true } @@ -116,11 +165,16 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C scope.launch { recorder.start() _sendingAudio.value = true - while (inCall.value is CallState.InCall && sendingAudio.value) { + while (call.value.inCall() && sendingAudio.value) { val start = System.currentTimeMillis() val audioFrame = recorder.read() try { - tox.sendAudio(to, audioFrame, AUDIO_CHANNELS, AUDIO_SAMPLING_RATE_HZ) + tox.sendAudio( + to, + audioFrame, + AUDIO_CHANNELS, + AUDIO_SAMPLING_RATE_HZ, + ) } catch (e: Exception) { Log.e(TAG, e.toString()) } @@ -134,4 +188,11 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C _sendingAudio.value = false } } + + fun setAnswered(pk: PublicKey) { + if (_call.value.state != Call.State.CALLING_OUT) { + Log.e(TAG, "Cot answer while in state ${_call.value.state}") + } + addCallData(Call.State.ANSWERED, pk, Call.InOrOut.OUTGOING) + } }