From 0651a9ddb470121b7ce011260dbdd9bf610fa500 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Wed, 19 Nov 2025 01:14:04 +0300 Subject: [PATCH 01/13] 3.1.1 experimental --- .../main/kotlin/tox/EventListenerCallbacks.kt | 7 +++++- atox/src/main/kotlin/ui/call/CallFragment.kt | 23 ++++++++++++++++--- atox/src/main/kotlin/ui/call/CallViewModel.kt | 2 ++ atox/src/main/res/layout/fragment_call.xml | 16 +++++++++++++ atox/src/main/res/values/strings.xml | 1 + 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt index 3b61968aa..4fcc52d43 100644 --- a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt +++ b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt @@ -197,7 +197,12 @@ 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() + } + if (callState.contains(ToxavFriendCallState.FINISHED) + || callState.contains(ToxavFriendCallState.ERROR)) { audioPlayer?.stop() audioPlayer?.release() audioPlayer = null diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 1b2fa540e..17050b6ca 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -7,6 +7,7 @@ package ltd.evilcorp.atox.ui.call import android.Manifest import android.os.Bundle +import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -28,6 +29,8 @@ import ltd.evilcorp.domain.feature.CallState 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,6 +38,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) { + Log.d(TAG, "Attempt to start sending audio while hot in call 2") vm.startSendingAudio() } else { Toast.makeText(requireContext(), getString(R.string.call_mic_permission_needed), Toast.LENGTH_LONG).show() @@ -51,6 +55,7 @@ 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 { @@ -71,6 +76,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl vm.stopSendingAudio() } else { if (requireContext().hasPermission(PERMISSION)) { + Log.d(TAG, "Attempt to start sending audio while hot in call 3") vm.startSendingAudio() } else { requestPermissionLauncher.launch(PERMISSION) @@ -97,11 +103,21 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl return } + vm.established.asLiveData().observe(viewLifecycleOwner) { established -> + when (established) { + CallState.RINGING -> tvState.setText(getString(R.string.ringing)) + CallState.ANSWERED -> { + tvState.setText("") + if (requireContext().hasPermission(PERMISSION)) { + vm.startSendingAudio() + } + } + else -> Log.e(TAG, "ESTABLISHED = ${established}") + } + } + startCall() - if (requireContext().hasPermission(PERMISSION)) { - vm.startSendingAudio() - } } private fun updateSpeakerphoneIcon() { @@ -117,4 +133,5 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } } } + } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index 5adcbfacd..e724c320a 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -61,5 +61,7 @@ class CallViewModel @Inject constructor( val inCall = callManager.inCall val sendingAudio = callManager.sendingAudio + val established = callManager.established + var speakerphoneOn by callManager::speakerphoneOn } diff --git a/atox/src/main/res/layout/fragment_call.xml b/atox/src/main/res/layout/fragment_call.xml index 2fc54c936..e11e1ab0b 100644 --- a/atox/src/main/res/layout/fragment_call.xml +++ b/atox/src/main/res/layout/fragment_call.xml @@ -12,6 +12,22 @@ 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... \ No newline at end of file From e5362dc47b2d20c5a465136c6d3f9d52d6684949 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Wed, 19 Nov 2025 01:29:26 +0300 Subject: [PATCH 02/13] 3.1.2 experimental2 --- domain/src/main/kotlin/feature/CallManager.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index 7a8240991..bb23aecc6 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -25,6 +25,12 @@ import ltd.evilcorp.domain.tox.Tox sealed class CallState { object NotInCall : CallState() data class InCall(val publicKey: PublicKey, val startTime: Long) : CallState() + + companion object { + const val IDLE: Int = 0 + const val RINGING = 1 + const val ANSWERED = 2 + } } private const val TAG = "CallManager" @@ -38,6 +44,9 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C private val _inCall = MutableStateFlow(CallState.NotInCall) val inCall: StateFlow get() = _inCall + private val _established = MutableStateFlow(CallState.IDLE) + val established : StateFlow get() = _established + private val _pendingCalls = MutableStateFlow>(mutableSetOf()) val pendingCalls: StateFlow> get() = _pendingCalls @@ -68,8 +77,10 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C fun startCall(publicKey: PublicKey) { if (pendingCalls.value.any { it.publicKey == publicKey.string() }) { tox.answerCall(publicKey) + _established.value = CallState.ANSWERED } else { tox.startCall(publicKey) + _established.value = CallState.RINGING } _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION @@ -92,6 +103,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C throw e } } + _established.value = CallState.IDLE } fun startSendingAudio(): Boolean { @@ -134,4 +146,8 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C _sendingAudio.value = false } } + + fun setAnswered() : Unit { + _established.value = CallState.ANSWERED + } } From 243b3ce4c1a8309a4e4a1cf30627ac7dd0bf0ead Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sat, 22 Nov 2025 01:22:48 +0300 Subject: [PATCH 03/13] 3.2.0 added dialing beeps --- atox/src/main/kotlin/ui/call/CallFragment.kt | 25 +++++++++++++++++- atox/src/main/res/layout/fragment_call.xml | 2 ++ atox/src/main/res/raw/connecting_ringtone.ogg | Bin 0 -> 23538 bytes domain/src/main/kotlin/feature/CallManager.kt | 21 ++++++++++----- 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 atox/src/main/res/raw/connecting_ringtone.ogg diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 17050b6ca..6e033fc1d 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -6,6 +6,7 @@ package ltd.evilcorp.atox.ui.call import android.Manifest +import android.media.MediaPlayer import android.os.Bundle import android.util.Log import android.view.View @@ -45,6 +46,8 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } } + private lateinit var mediaPlayer: MediaPlayer + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars()) @@ -104,14 +107,22 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } vm.established.asLiveData().observe(viewLifecycleOwner) { established -> + //Log.d(TAG, "ESTABLISHED = ${established}") when (established) { - CallState.RINGING -> tvState.setText(getString(R.string.ringing)) + CallState.CALLING_OUT -> { + tvState.setText(getString(R.string.ringing)) + playConnecting() + } CallState.ANSWERED -> { + stopPlay() tvState.setText("") if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } } + CallState.NOT_IN_CALL -> { + stopPlay() + } else -> Log.e(TAG, "ESTABLISHED = ${established}") } } @@ -134,4 +145,16 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } } + private fun playConnecting() : Unit { + if (! this::mediaPlayer.isInitialized) { + mediaPlayer = MediaPlayer.create(context, R.raw.connecting_ringtone) + mediaPlayer.setLooping(true) + } + mediaPlayer.start() + } + + private fun stopPlay(): Unit { + mediaPlayer?.stop() + } + } diff --git a/atox/src/main/res/layout/fragment_call.xml b/atox/src/main/res/layout/fragment_call.xml index e11e1ab0b..23578ecdb 100644 --- a/atox/src/main/res/layout/fragment_call.xml +++ b/atox/src/main/res/layout/fragment_call.xml @@ -17,6 +17,7 @@ android:layout_alignParentTop="true" android:layout_width="match_parent" android:layout_height="wrap_content" + android:gravity="center_horizontal" android:padding="10dp" android:textSize="20sp" /> @@ -24,6 +25,7 @@ android:id="@+id/tvData" android:layout_width="match_parent" android:layout_height="wrap_content" + android:gravity="center_horizontal" android:layout_below="@id/tvState" android:padding="10dp" android:textSize="30sp" diff --git a/atox/src/main/res/raw/connecting_ringtone.ogg b/atox/src/main/res/raw/connecting_ringtone.ogg new file mode 100644 index 0000000000000000000000000000000000000000..bc835b95fbc46f84e6240673510c863289dfe8ed GIT binary patch literal 23538 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 literal 0 HcmV?d00001 diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index bb23aecc6..98b6078c8 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -20,6 +20,7 @@ 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.feature.CallState import ltd.evilcorp.domain.tox.Tox sealed class CallState { @@ -27,9 +28,10 @@ sealed class CallState { data class InCall(val publicKey: PublicKey, val startTime: Long) : CallState() companion object { - const val IDLE: Int = 0 - const val RINGING = 1 - const val ANSWERED = 2 + const val NOT_IN_CALL = 0 + const val CALLING_OUT = 1 + const val PENDING = 2 + const val ANSWERED = 3 } } @@ -44,7 +46,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C private val _inCall = MutableStateFlow(CallState.NotInCall) val inCall: StateFlow get() = _inCall - private val _established = MutableStateFlow(CallState.IDLE) + private val _established = MutableStateFlow(CallState.NOT_IN_CALL) val established : StateFlow get() = _established private val _pendingCalls = MutableStateFlow>(mutableSetOf()) @@ -62,6 +64,8 @@ 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()) && _established.value == CallState.NOT_IN_CALL) + _established.value = CallState.PENDING } fun removePendingCall(pk: PublicKey) { @@ -72,6 +76,8 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C calls.remove(removed) _pendingCalls.value = calls } + if ( _pendingCalls.value.isEmpty() && _established.value == CallState.PENDING) + _established.value = CallState.NOT_IN_CALL } fun startCall(publicKey: PublicKey) { @@ -80,7 +86,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C _established.value = CallState.ANSWERED } else { tox.startCall(publicKey) - _established.value = CallState.RINGING + _established.value = CallState.CALLING_OUT } _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION @@ -92,6 +98,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C if (state is CallState.InCall && state.publicKey == publicKey) { audioManager?.mode = AudioManager.MODE_NORMAL _inCall.value = CallState.NotInCall + _established.value = CallState.NOT_IN_CALL } removePendingCall(publicKey) @@ -103,7 +110,6 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C throw e } } - _established.value = CallState.IDLE } fun startSendingAudio(): Boolean { @@ -148,6 +154,9 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } fun setAnswered() : Unit { + if (_established.value != CallState.CALLING_OUT) { + Log.e(TAG, "Cot answer while in state ${_established.value}") + } _established.value = CallState.ANSWERED } } From e668c1e8aeaae3cf97075e647cd09d8522f541f8 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sat, 22 Nov 2025 21:45:29 +0300 Subject: [PATCH 04/13] 3.3.0 added call time indication --- atox/src/main/kotlin/ui/call/CallFragment.kt | 61 ++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 6e033fc1d..ed7b93523 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -8,8 +8,10 @@ package ltd.evilcorp.atox.ui.call import android.Manifest import android.media.MediaPlayer import android.os.Bundle +import android.os.SystemClock import android.util.Log import android.view.View +import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat @@ -17,7 +19,11 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentCallBinding import ltd.evilcorp.atox.hasPermission @@ -27,6 +33,8 @@ 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 kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds private const val PERMISSION = Manifest.permission.RECORD_AUDIO @@ -106,6 +114,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl return } + /* vm.established.asLiveData().observe(viewLifecycleOwner) { established -> //Log.d(TAG, "ESTABLISHED = ${established}") when (established) { @@ -116,6 +125,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl CallState.ANSWERED -> { stopPlay() tvState.setText("") + startTimer(tvState) if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } @@ -125,10 +135,34 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } else -> Log.e(TAG, "ESTABLISHED = ${established}") } - } + }*/ startCall() + }// end onViewCreated + override fun onResume() = binding.run { + vm.established.asLiveData().observe(viewLifecycleOwner) { established -> + Log.d(TAG, "ESTABLISHED = ${established}") + when (established) { + CallState.CALLING_OUT -> { + tvState.setText(getString(R.string.ringing)) + playConnecting() + } + CallState.ANSWERED -> { + stopPlay() + tvState.setText("") + startTimer(tvState) + if (requireContext().hasPermission(PERMISSION)) { + vm.startSendingAudio() + } + } + CallState.NOT_IN_CALL -> { + stopPlay() + } + else -> Log.e(TAG, "ESTABLISHED = ${established}") + } + } + super.onResume() } private fun updateSpeakerphoneIcon() { @@ -145,7 +179,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } } - private fun playConnecting() : Unit { + private fun playConnecting() { if (! this::mediaPlayer.isInitialized) { mediaPlayer = MediaPlayer.create(context, R.raw.connecting_ringtone) mediaPlayer.setLooping(true) @@ -153,8 +187,27 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl mediaPlayer.start() } - private fun stopPlay(): Unit { - mediaPlayer?.stop() + private fun stopPlay() { + if (! this::mediaPlayer.isInitialized) return + mediaPlayer.stop() } + private fun startTimer(tvState: TextView) { + val isActive = true + if (vm.inCall.value !is CallState.InCall) return + val timerName = lifecycleScope.launch(Dispatchers.IO) { + while (isActive) { + lifecycleScope.launch { + val from = (vm.inCall.value as CallState.InCall).startTime + val elapsed : Duration = (SystemClock.elapsedRealtime() - from).milliseconds + val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> + String.format("%01d:%02d:%02d", hours, minutes, seconds) + } + tvState.setText(s) + } + delay(1000L) + } + } + + } } From eff413626523580dd0733533694d7c2dfbe15669 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Tue, 25 Nov 2025 02:04:13 +0300 Subject: [PATCH 05/13] 3.3.1 code improvements related to LiveData --- atox/src/main/AndroidManifest.xml | 2 + atox/src/main/kotlin/ui/call/CallFragment.kt | 111 +++++++++--------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 6 + atox/src/main/res/values/strings.xml | 1 + domain/src/main/kotlin/feature/CallManager.kt | 11 +- 5 files changed, 70 insertions(+), 61 deletions(-) diff --git a/atox/src/main/AndroidManifest.xml b/atox/src/main/AndroidManifest.xml index 262a534e0..00be222d5 100644 --- a/atox/src/main/AndroidManifest.xml +++ b/atox/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + (FragmentCallBinding::infl } } - private lateinit var mediaPlayer: MediaPlayer + private var mediaPlayer: MediaPlayer? = null + private var timerNHandle: Job? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> @@ -106,62 +110,30 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } if (vm.inCall.value is CallState.InCall) { - vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> + //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> + vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { + stopPlay() findNavController().popBackStack() } } return } - /* - vm.established.asLiveData().observe(viewLifecycleOwner) { established -> - //Log.d(TAG, "ESTABLISHED = ${established}") - when (established) { - CallState.CALLING_OUT -> { - tvState.setText(getString(R.string.ringing)) - playConnecting() - } - CallState.ANSWERED -> { - stopPlay() - tvState.setText("") - startTimer(tvState) - if (requireContext().hasPermission(PERMISSION)) { - vm.startSendingAudio() - } - } - CallState.NOT_IN_CALL -> { - stopPlay() - } - else -> Log.e(TAG, "ESTABLISHED = ${established}") - } - }*/ + //vm.established.asLiveData().observe(viewLifecycleOwner) { established -> + vm.establishedLiveData.observe(viewLifecycleOwner) { established -> + Log.d(TAG, "observer here") + adoptEstablished(established, tvState) + } startCall() }// end onViewCreated override fun onResume() = binding.run { - vm.established.asLiveData().observe(viewLifecycleOwner) { established -> - Log.d(TAG, "ESTABLISHED = ${established}") - when (established) { - CallState.CALLING_OUT -> { - tvState.setText(getString(R.string.ringing)) - playConnecting() - } - CallState.ANSWERED -> { - stopPlay() - tvState.setText("") - startTimer(tvState) - if (requireContext().hasPermission(PERMISSION)) { - vm.startSendingAudio() - } - } - CallState.NOT_IN_CALL -> { - stopPlay() - } - else -> Log.e(TAG, "ESTABLISHED = ${established}") - } - } + Log.d(TAG, "onResume here") + val nme = vm.established.value + adoptEstablished(nme, tvState) + super.onResume() } @@ -172,31 +144,60 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl private fun startCall() { vm.startCall() - vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> + //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> + vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { findNavController().popBackStack() } } } + private fun adoptEstablished(established: Int, tvState: TextView) { + Log.d(TAG, "ESTABLISHED = ${established}") + when (established) { + CallState.CALLING_OUT -> { + tvState.setText(getString(R.string.ringing)) + playConnecting() + } + CallState.ANSWERED -> { + stopPlay() + tvState.setText("") + startTimer(tvState) + if (! vm.sendingAudio.value) { + if (requireContext().hasPermission(PERMISSION)) { + vm.startSendingAudio() + } + } + } + CallState.IDLE -> { + tvState.setText("00000") + stopPlay() + } + else -> Log.e(TAG, "ESTABLISHED = ${established}") + } + } + private fun playConnecting() { - if (! this::mediaPlayer.isInitialized) { - mediaPlayer = MediaPlayer.create(context, R.raw.connecting_ringtone) - mediaPlayer.setLooping(true) + 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() + mediaPlayer?.start() } private fun stopPlay() { - if (! this::mediaPlayer.isInitialized) return - mediaPlayer.stop() + mediaPlayer?.stop() + mediaPlayer = null } private fun startTimer(tvState: TextView) { - val isActive = true if (vm.inCall.value !is CallState.InCall) return - val timerName = lifecycleScope.launch(Dispatchers.IO) { - while (isActive) { + if (timerNHandle?.isActive == true) return + timerNHandle = lifecycleScope.launch(Dispatchers.IO) { + while (vm.inCall.value is CallState.InCall) { lifecycleScope.launch { val from = (vm.inCall.value as CallState.InCall).startTime val elapsed : Duration = (SystemClock.elapsedRealtime() - from).milliseconds diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index e724c320a..e913f151f 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -8,6 +8,7 @@ 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 @@ -17,6 +18,7 @@ import ltd.evilcorp.atox.ui.NotificationHelper import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.feature.CallManager +import ltd.evilcorp.domain.feature.CallState import ltd.evilcorp.domain.feature.ContactManager class CallViewModel @Inject constructor( @@ -59,9 +61,13 @@ class CallViewModel @Inject constructor( } val inCall = callManager.inCall + val inCallLiveData = callManager.inCall.asLiveData( + context = viewModelScope.coroutineContext) val sendingAudio = callManager.sendingAudio val established = callManager.established + val establishedLiveData = callManager.established.asLiveData( + context = viewModelScope.coroutineContext) var speakerphoneOn by callManager::speakerphoneOn } diff --git a/atox/src/main/res/values/strings.xml b/atox/src/main/res/values/strings.xml index 17daed454..fa5ae7a2f 100644 --- a/atox/src/main/res/values/strings.xml +++ b/atox/src/main/res/values/strings.xml @@ -193,4 +193,5 @@ 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 98b6078c8..bc8ea0c3c 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -20,7 +20,6 @@ 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.feature.CallState import ltd.evilcorp.domain.tox.Tox sealed class CallState { @@ -28,7 +27,7 @@ sealed class CallState { data class InCall(val publicKey: PublicKey, val startTime: Long) : CallState() companion object { - const val NOT_IN_CALL = 0 + const val IDLE = 0 const val CALLING_OUT = 1 const val PENDING = 2 const val ANSWERED = 3 @@ -46,7 +45,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C private val _inCall = MutableStateFlow(CallState.NotInCall) val inCall: StateFlow get() = _inCall - private val _established = MutableStateFlow(CallState.NOT_IN_CALL) + private val _established = MutableStateFlow(CallState.IDLE) val established : StateFlow get() = _established private val _pendingCalls = MutableStateFlow>(mutableSetOf()) @@ -64,7 +63,7 @@ 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()) && _established.value == CallState.NOT_IN_CALL) + if ((! _pendingCalls.value.isEmpty()) && _established.value == CallState.IDLE) _established.value = CallState.PENDING } @@ -77,7 +76,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C _pendingCalls.value = calls } if ( _pendingCalls.value.isEmpty() && _established.value == CallState.PENDING) - _established.value = CallState.NOT_IN_CALL + _established.value = CallState.IDLE } fun startCall(publicKey: PublicKey) { @@ -98,7 +97,7 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C if (state is CallState.InCall && state.publicKey == publicKey) { audioManager?.mode = AudioManager.MODE_NORMAL _inCall.value = CallState.NotInCall - _established.value = CallState.NOT_IN_CALL + _established.value = CallState.IDLE } removePendingCall(publicKey) From f6b626d9a966f5ca8cb8e4de3ce3b0e3c5c9024c Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Tue, 25 Nov 2025 20:18:42 +0300 Subject: [PATCH 06/13] 3.3.2 cleaning --- atox/src/main/kotlin/ui/call/CallFragment.kt | 14 ++++++++++---- atox/src/main/kotlin/ui/call/CallViewModel.kt | 2 ++ domain/src/main/kotlin/feature/CallManager.kt | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index f45295115..219b90c8e 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -74,11 +74,13 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } endCall.setOnClickListener { + Log.d(TAG, "finishing by End Call") vm.endCall() findNavController().popBackStack() } - vm.sendingAudio.asLiveData().observe(viewLifecycleOwner) { sending -> + //vm.sendingAudio.asLiveData().observe(viewLifecycleOwner) { sending -> + vm.sendingAudioLiveData.observe(viewLifecycleOwner) { sending -> if (sending) { microphoneControl.setImageResource(R.drawable.ic_mic) } else { @@ -113,6 +115,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { + Log.d(TAG, "finishing by inCall 1") stopPlay() findNavController().popBackStack() } @@ -130,9 +133,10 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl }// end onViewCreated override fun onResume() = binding.run { - Log.d(TAG, "onResume here") val nme = vm.established.value - adoptEstablished(nme, tvState) + Log.d(TAG, "onResume here, ESTABLISHED=$nme") + startTimer(tvState) + //adoptEstablished(nme, tvState) super.onResume() } @@ -147,6 +151,8 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { + Log.d(TAG, "finishing by inCall 2") + stopPlay() findNavController().popBackStack() } } @@ -196,10 +202,10 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl private fun startTimer(tvState: TextView) { if (vm.inCall.value !is CallState.InCall) return if (timerNHandle?.isActive == true) return + val from = (vm.inCall.value as CallState.InCall).startTime timerNHandle = lifecycleScope.launch(Dispatchers.IO) { while (vm.inCall.value is CallState.InCall) { lifecycleScope.launch { - val from = (vm.inCall.value as CallState.InCall).startTime val elapsed : Duration = (SystemClock.elapsedRealtime() - from).milliseconds val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> String.format("%01d:%02d:%02d", hours, minutes, seconds) diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index e913f151f..f5b7c2f8e 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -64,6 +64,8 @@ class CallViewModel @Inject constructor( val inCallLiveData = callManager.inCall.asLiveData( context = viewModelScope.coroutineContext) val sendingAudio = callManager.sendingAudio + val sendingAudioLiveData = callManager.sendingAudio.asLiveData( + context = viewModelScope.coroutineContext) val established = callManager.established val establishedLiveData = callManager.established.asLiveData( diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index bc8ea0c3c..05fef1df6 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -96,8 +96,8 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C val state = inCall.value if (state is CallState.InCall && state.publicKey == publicKey) { audioManager?.mode = AudioManager.MODE_NORMAL - _inCall.value = CallState.NotInCall _established.value = CallState.IDLE + _inCall.value = CallState.NotInCall } removePendingCall(publicKey) From 772c7fc3dc67a39b72e82270a19dc8992cc244b9 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Thu, 27 Nov 2025 01:41:44 +0300 Subject: [PATCH 07/13] 3.4.0 revized CallFragment flow and mic control --- atox/src/main/kotlin/ui/call/CallFragment.kt | 70 +++++++++++-------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 12 ++-- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 219b90c8e..4c80ebe7f 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -12,6 +12,7 @@ import android.os.Bundle import android.os.SystemClock import android.util.Log import android.view.View +import android.widget.ImageButton import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -26,6 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentCallBinding import ltd.evilcorp.atox.hasPermission @@ -48,19 +50,22 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), - ) { granted -> + ) { } + /*granted -> if (granted) { Log.d(TAG, "Attempt to start sending audio while hot in call 2") vm.startSendingAudio() } else { Toast.makeText(requireContext(), getString(R.string.call_mic_permission_needed), Toast.LENGTH_LONG).show() } - } + }*/ private var mediaPlayer: MediaPlayer? = null private var timerNHandle: Job? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) = 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) @@ -75,30 +80,36 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl endCall.setOnClickListener { Log.d(TAG, "finishing by End Call") + stopPlay() vm.endCall() findNavController().popBackStack() } - //vm.sendingAudio.asLiveData().observe(viewLifecycleOwner) { sending -> - vm.sendingAudioLiveData.observe(viewLifecycleOwner) { sending -> - if (sending) { - microphoneControl.setImageResource(R.drawable.ic_mic) - } else { - microphoneControl.setImageResource(R.drawable.ic_mic_off) - } - } + val hasPerm = requireContext().hasPermission(PERMISSION) + vm.micOn = hasPerm + 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 (vm.micOn) { + vm.micOn = false + if (vm.sendingAudio.value) { + vm.stopSendingAudio() + } } else { - if (requireContext().hasPermission(PERMISSION)) { - Log.d(TAG, "Attempt to start sending audio while hot in call 3") + vm.micOn = true + if (!vm.sendingAudio.value && vm.established.value == CallState.ANSWERED) { vm.startSendingAudio() - } else { - requestPermissionLauncher.launch(PERMISSION) } } + updateMicrophoneControlIcon() } updateSpeakerphoneIcon() @@ -111,8 +122,13 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl findNavController().popBackStack() } + vm.establishedLiveData.observe(viewLifecycleOwner) { established -> + Log.d(TAG, "observer here") + adoptEstablished(established, tvState) + } + adoptEstablished(vm.established.value, tvState) + if (vm.inCall.value is CallState.InCall) { - //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { Log.d(TAG, "finishing by inCall 1") @@ -122,33 +138,31 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } return } - - //vm.established.asLiveData().observe(viewLifecycleOwner) { established -> - vm.establishedLiveData.observe(viewLifecycleOwner) { established -> - Log.d(TAG, "observer here") - adoptEstablished(established, tvState) - } - startCall() }// end onViewCreated override fun onResume() = binding.run { val nme = vm.established.value Log.d(TAG, "onResume here, ESTABLISHED=$nme") - startTimer(tvState) + //if (nme == CallState.ANSWERED) startTimer(tvState) //adoptEstablished(nme, tvState) 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 updateMicrophoneControlIcon() { + val icon = if (vm.micOn) R.drawable.ic_mic + else R.drawable.ic_mic_off + binding.microphoneControl.setImageResource(icon) + } private fun startCall() { vm.startCall() - //vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall -> vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> if (inCall == CallState.NotInCall) { Log.d(TAG, "finishing by inCall 2") @@ -169,7 +183,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() tvState.setText("") startTimer(tvState) - if (! vm.sendingAudio.value) { + if (! vm.sendingAudio.value && vm.micOn) { if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index f5b7c2f8e..d5a805ddb 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -28,6 +28,7 @@ class CallViewModel @Inject constructor( private val contactManager: ContactManager, private val proximityScreenOff: ProximityScreenOff, ) : ViewModel() { + val vmContext = viewModelScope.coroutineContext private var publicKey = PublicKey("") val contact: LiveData by lazy { @@ -60,16 +61,15 @@ class CallViewModel @Inject constructor( } } + var micOn = false + val inCall = callManager.inCall - val inCallLiveData = callManager.inCall.asLiveData( - context = viewModelScope.coroutineContext) + val inCallLiveData = callManager.inCall.asLiveData(vmContext) val sendingAudio = callManager.sendingAudio - val sendingAudioLiveData = callManager.sendingAudio.asLiveData( - context = viewModelScope.coroutineContext) + val sendingAudioLiveData = callManager.sendingAudio.asLiveData(vmContext) val established = callManager.established - val establishedLiveData = callManager.established.asLiveData( - context = viewModelScope.coroutineContext) + val establishedLiveData = callManager.established.asLiveData(vmContext) var speakerphoneOn by callManager::speakerphoneOn } From 9911fbcf67928bc283136d6f0a17d693c5127710 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Thu, 27 Nov 2025 18:18:36 +0300 Subject: [PATCH 08/13] 3.4.1 refactoring --- atox/src/main/kotlin/ui/call/CallFragment.kt | 94 +++++++------------ atox/src/main/kotlin/ui/call/CallViewModel.kt | 17 +++- domain/src/main/kotlin/feature/CallManager.kt | 25 +++-- 3 files changed, 65 insertions(+), 71 deletions(-) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 4c80ebe7f..e8890b52f 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -12,22 +12,18 @@ import android.os.Bundle import android.os.SystemClock import android.util.Log import android.view.View -import android.widget.ImageButton import android.widget.TextView -import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import ltd.evilcorp.atox.R import ltd.evilcorp.atox.databinding.FragmentCallBinding import ltd.evilcorp.atox.hasPermission @@ -80,13 +76,11 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl endCall.setOnClickListener { Log.d(TAG, "finishing by End Call") - stopPlay() vm.endCall() - findNavController().popBackStack() + adoptState(CallState.IDLE) } - val hasPerm = requireContext().hasPermission(PERMISSION) - vm.micOn = hasPerm + vm.micOn = requireContext().hasPermission(PERMISSION) updateMicrophoneControlIcon() microphoneControl.setOnClickListener { @@ -98,57 +92,40 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl Toast.LENGTH_LONG ).show()*/ requestPermissionLauncher.launch(PERMISSION) - } else if (vm.micOn) { - vm.micOn = false - if (vm.sendingAudio.value) { - vm.stopSendingAudio() - } } else { - vm.micOn = true - if (!vm.sendingAudio.value && vm.established.value == CallState.ANSWERED) { - vm.startSendingAudio() - } + vm.toggleMicrophoneControl() } updateMicrophoneControlIcon() } - updateSpeakerphoneIcon() - speakerphone.setOnClickListener { + updateSpeakerphoneIcon() + speakerphone.setOnClickListener { vm.toggleSpeakerphone() updateSpeakerphoneIcon() - } + } - backToChat.setOnClickListener { + backToChat.setOnClickListener { findNavController().popBackStack() - } + } - vm.establishedLiveData.observe(viewLifecycleOwner) { established -> + vm.establishedLiveData.observe(viewLifecycleOwner) { established -> Log.d(TAG, "observer here") - adoptEstablished(established, tvState) - } - adoptEstablished(vm.established.value, tvState) - - if (vm.inCall.value is CallState.InCall) { - vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> - if (inCall == CallState.NotInCall) { - Log.d(TAG, "finishing by inCall 1") - stopPlay() - findNavController().popBackStack() - } - } - return - } - startCall() + adoptState() + } + + if (vm.established.value != CallState.IDLE) { + adoptState() + return@run + } + binding.tvState.setText("startinng a call...") // normally, not to be seen + vm.startCall() }// end onViewCreated - override fun onResume() = binding.run { + /*override fun onResume() = binding.run { val nme = vm.established.value Log.d(TAG, "onResume here, ESTABLISHED=$nme") - //if (nme == CallState.ANSWERED) startTimer(tvState) - //adoptEstablished(nme, tvState) - super.onResume() - } + }*/ private fun updateSpeakerphoneIcon() { val icon = if (vm.speakerphoneOn) R.drawable.ic_speakerphone @@ -161,37 +138,32 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl else R.drawable.ic_mic_off binding.microphoneControl.setImageResource(icon) } - private fun startCall() { - vm.startCall() - vm.inCallLiveData.observe(viewLifecycleOwner) { inCall -> - if (inCall == CallState.NotInCall) { - Log.d(TAG, "finishing by inCall 2") - stopPlay() - findNavController().popBackStack() - } - } + private fun adoptState() { + adoptState(vm.established.value) } - - private fun adoptEstablished(established: Int, tvState: TextView) { - Log.d(TAG, "ESTABLISHED = ${established}") + private fun adoptState(established: Int) { + // may be called repeatedly, so must be idempotent + Log.d(TAG, "adoptState, ESTABLISHED = ${established}") when (established) { CallState.CALLING_OUT -> { - tvState.setText(getString(R.string.ringing)) + binding.tvState.setText(getString(R.string.ringing)) playConnecting() } CallState.ANSWERED -> { stopPlay() - tvState.setText("") - startTimer(tvState) + 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 CallState.IDLE -> { - tvState.setText("00000") + binding.tvState.setText("00000") stopPlay() + findNavController().popBackStack() } else -> Log.e(TAG, "ESTABLISHED = ${established}") } @@ -213,7 +185,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl mediaPlayer = null } - private fun startTimer(tvState: TextView) { + private fun startTimer() { if (vm.inCall.value !is CallState.InCall) return if (timerNHandle?.isActive == true) return val from = (vm.inCall.value as CallState.InCall).startTime @@ -224,7 +196,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> String.format("%01d:%02d:%02d", hours, minutes, seconds) } - tvState.setText(s) + binding.tvState.setText(s) } delay(1000L) } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index d5a805ddb..9bb143eb2 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -62,11 +62,24 @@ class CallViewModel @Inject constructor( } var micOn = false + fun toggleMicrophoneControl() { + if (micOn) { + micOn = false + if (sendingAudio.value) { + stopSendingAudio() + } + } else { + micOn = true + if (!sendingAudio.value && established.value == CallState.ANSWERED) { + startSendingAudio() + } + } + } val inCall = callManager.inCall - val inCallLiveData = callManager.inCall.asLiveData(vmContext) + //val inCallLiveData = callManager.inCall.asLiveData(vmContext) val sendingAudio = callManager.sendingAudio - val sendingAudioLiveData = callManager.sendingAudio.asLiveData(vmContext) + //val sendingAudioLiveData = callManager.sendingAudio.asLiveData(vmContext) val established = callManager.established val establishedLiveData = callManager.established.asLiveData(vmContext) diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index 05fef1df6..00985fb38 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -80,24 +80,26 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } 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) - _established.value = CallState.ANSWERED } else { tox.startCall(publicKey) - _established.value = CallState.CALLING_OUT } - _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION removePendingCall(publicKey) + _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) + _established.value = if (toAnswer) CallState.ANSWERED + else CallState.CALLING_OUT } fun endCall(publicKey: PublicKey) { val state = inCall.value if (state is CallState.InCall && state.publicKey == publicKey) { audioManager?.mode = AudioManager.MODE_NORMAL - _established.value = CallState.IDLE + // move to below ? _inCall.value = CallState.NotInCall + _established.value = CallState.IDLE } removePendingCall(publicKey) @@ -113,8 +115,11 @@ 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 + val recorder = AudioCapture.create( + AUDIO_SAMPLING_RATE_HZ, + AUDIO_CHANNELS, + AUDIO_SEND_INTERVAL_MS + ) ?: return false startAudioSender(recorder, to) return true } @@ -137,7 +142,11 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C 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()) } From 6220cf15ef778cc3a8600208c622f9c0e8252292 Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sat, 29 Nov 2025 19:25:21 +0300 Subject: [PATCH 09/13] 3.4.2 bug fixes related to merge 4 --- atox/src/main/kotlin/ui/call/CallFragment.kt | 32 +++++++++---------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 7 ++++ atox/src/main/res/layout/fragment_call.xml | 3 +- domain/src/main/kotlin/feature/CallManager.kt | 1 + 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index e8890b52f..81bf62ead 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -12,7 +12,7 @@ import android.os.Bundle import android.os.SystemClock import android.util.Log import android.view.View -import android.widget.TextView +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -38,7 +38,6 @@ import kotlin.time.Duration.Companion.milliseconds private const val PERMISSION = Manifest.permission.RECORD_AUDIO - private const val TAG = "CallFragment" class CallFragment : BaseFragment(FragmentCallBinding::inflate) { @@ -46,20 +45,20 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), - ) { } - /*granted -> + ) { granted -> if (granted) { - Log.d(TAG, "Attempt to start sending audio while hot in call 2") - 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() } - }*/ + } private var mediaPlayer: MediaPlayer? = null private var timerNHandle: Job? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run { + override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run { Log.d(TAG, "onViewCreated here") ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat -> @@ -100,22 +99,23 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateSpeakerphoneIcon() speakerphone.setOnClickListener { - vm.toggleSpeakerphone() - updateSpeakerphoneIcon() + vm.toggleSpeakerphone() + updateSpeakerphoneIcon() } backToChat.setOnClickListener { - findNavController().popBackStack() + findNavController().popBackStack() } vm.establishedLiveData.observe(viewLifecycleOwner) { established -> - Log.d(TAG, "observer here") - adoptState() + Log.d(TAG, "observer here") + adoptState() } - if (vm.established.value != CallState.IDLE) { - adoptState() - return@run + if (vm.established.value != CallState.IDLE + && vm.established.value != CallState.PENDING) { + adoptState() + return@run } binding.tvState.setText("startinng a call...") // normally, not to be seen vm.startCall() diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index 9bb143eb2..42b99121d 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -76,6 +76,13 @@ class CallViewModel @Inject constructor( } } + fun setMicrophoneOn() { + micOn = true + if (!sendingAudio.value && established.value == CallState.ANSWERED) { + startSendingAudio() + } + } + val inCall = callManager.inCall //val inCallLiveData = callManager.inCall.asLiveData(vmContext) val sendingAudio = callManager.sendingAudio diff --git a/atox/src/main/res/layout/fragment_call.xml b/atox/src/main/res/layout/fragment_call.xml index 23578ecdb..7978cc9b4 100644 --- a/atox/src/main/res/layout/fragment_call.xml +++ b/atox/src/main/res/layout/fragment_call.xml @@ -18,7 +18,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" - android:padding="10dp" + android:paddingBottom="5dp" + android:paddingTop="30dp" android:textSize="20sp" /> Date: Fri, 5 Dec 2025 20:58:39 +0300 Subject: [PATCH 10/13] 6.1.0 refactored CallManager CallState > Call --- atox/src/main/kotlin/ActionReceiver.kt | 4 +- atox/src/main/kotlin/ToxService.kt | 6 +- .../main/kotlin/tox/EventListenerCallbacks.kt | 2 +- atox/src/main/kotlin/ui/call/CallFragment.kt | 40 +++++---- atox/src/main/kotlin/ui/call/CallViewModel.kt | 28 +++--- atox/src/main/kotlin/ui/chat/ChatFragment.kt | 6 +- atox/src/main/kotlin/ui/chat/ChatViewModel.kt | 21 +++-- domain/src/main/kotlin/feature/CallManager.kt | 90 ++++++++++++------- 8 files changed, 121 insertions(+), 76 deletions(-) diff --git a/atox/src/main/kotlin/ActionReceiver.kt b/atox/src/main/kotlin/ActionReceiver.kt index 67fe0562d..af90478e3 100644 --- a/atox/src/main/kotlin/ActionReceiver.kt +++ b/atox/src/main/kotlin/ActionReceiver.kt @@ -26,9 +26,9 @@ import ltd.evilcorp.core.vo.Contact import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.core.vo.UserStatus 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.inCall import ltd.evilcorp.domain.tox.Tox const val KEY_TEXT_REPLY = "key_text_reply" @@ -121,7 +121,7 @@ class ActionReceiver : BroadcastReceiver() { } } - if (callManager.inCall.value is CallState.InCall) { + if (callManager.call.value.inCall()) { withContext(Dispatchers.Main) { Toast.makeText(context, context.getString(R.string.error_simultaneous_calls), Toast.LENGTH_LONG).show() notificationHelper.showPendingCallNotification(UserStatus.Busy, contact) diff --git a/atox/src/main/kotlin/ToxService.kt b/atox/src/main/kotlin/ToxService.kt index 81c005fd5..375edec98 100644 --- a/atox/src/main/kotlin/ToxService.kt +++ b/atox/src/main/kotlin/ToxService.kt @@ -33,8 +33,8 @@ import ltd.evilcorp.core.repository.UserRepository import ltd.evilcorp.core.vo.ConnectionStatus import ltd.evilcorp.core.vo.FriendRequest import ltd.evilcorp.domain.feature.CallManager -import ltd.evilcorp.domain.feature.CallState import ltd.evilcorp.domain.feature.FriendRequestManager +import ltd.evilcorp.domain.feature.inCall import ltd.evilcorp.domain.tox.Tox import ltd.evilcorp.domain.tox.ToxSaveStatus @@ -178,8 +178,8 @@ class ToxService : LifecycleService() { } lifecycleScope.launch { - callManager.inCall.collect { - if (it is CallState.InCall) { + callManager.call.collect { + if (it.inCall()) { if (!callManager.speakerphoneOn) { proximityScreenOff.acquire() } diff --git a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt index 4fcc52d43..107927141 100644 --- a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt +++ b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt @@ -199,7 +199,7 @@ class EventListenerCallbacks @Inject constructor( Log.e(TAG, "callState ${pk.fingerprint()} $callState") if (callState.contains(ToxavFriendCallState.SENDING_A) || callState.contains(ToxavFriendCallState.ACCEPTING_A)) { - callManager.setAnswered() + callManager.setAnswered(PublicKey(pk)) } if (callState.contains(ToxavFriendCallState.FINISHED) || callState.contains(ToxavFriendCallState.ERROR)) { diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 81bf62ead..bd5be8dd1 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -32,7 +32,8 @@ 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 import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -76,7 +77,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl endCall.setOnClickListener { Log.d(TAG, "finishing by End Call") vm.endCall() - adoptState(CallState.IDLE) + adoptState(Call.State.IDLE) } vm.micOn = requireContext().hasPermission(PERMISSION) @@ -107,13 +108,13 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl findNavController().popBackStack() } - vm.establishedLiveData.observe(viewLifecycleOwner) { established -> + vm.callLiveData.observe(viewLifecycleOwner) { call -> Log.d(TAG, "observer here") adoptState() } - if (vm.established.value != CallState.IDLE - && vm.established.value != CallState.PENDING) { + if (vm.call.value.state != Call.State.IDLE + && vm.call.value.state != Call.State.PENDING) { adoptState() return@run } @@ -122,8 +123,8 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl }// end onViewCreated /*override fun onResume() = binding.run { - val nme = vm.established.value - Log.d(TAG, "onResume here, ESTABLISHED=$nme") + val nme = vm.call.value.state + Log.d(TAG, "onResume here, state=$nme") super.onResume() }*/ @@ -139,17 +140,17 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl binding.microphoneControl.setImageResource(icon) } private fun adoptState() { - adoptState(vm.established.value) + adoptState(vm.call.value.state) } - private fun adoptState(established: Int) { + private fun adoptState(state: Call.State) { // may be called repeatedly, so must be idempotent - Log.d(TAG, "adoptState, ESTABLISHED = ${established}") - when (established) { - CallState.CALLING_OUT -> { + Log.d(TAG, "adoptState, state = ${state}") + when (state) { + Call.State.CALLING_OUT -> { binding.tvState.setText(getString(R.string.ringing)) playConnecting() } - CallState.ANSWERED -> { + Call.State.ANSWERED -> { stopPlay() binding.tvState.setText("talking") startTimer() @@ -160,12 +161,12 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } } // as LiveData never emits its init value, IDLE means the call is finished - CallState.IDLE -> { + Call.State.IDLE -> { binding.tvState.setText("00000") stopPlay() findNavController().popBackStack() } - else -> Log.e(TAG, "ESTABLISHED = ${established}") + else -> Log.e(TAG, "STATE = ${state}") } } @@ -186,15 +187,16 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun startTimer() { - if (vm.inCall.value !is CallState.InCall) return + if (! vm.call.value.inCall()) return if (timerNHandle?.isActive == true) return - val from = (vm.inCall.value as CallState.InCall).startTime + val from: Long = vm.call.value.data?.startTime ?: 0 timerNHandle = lifecycleScope.launch(Dispatchers.IO) { - while (vm.inCall.value is CallState.InCall) { + 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) + //String.format("%01d:%02d:%02d", hours, minutes, seconds) + vm.presentTime(hours, minutes, seconds, nanoseconds) } binding.tvState.setText(s) } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index 42b99121d..c2d7150e4 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -17,8 +17,8 @@ import ltd.evilcorp.atox.ProximityScreenOff 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.CallState import ltd.evilcorp.domain.feature.ContactManager class CallViewModel @Inject constructor( @@ -69,27 +69,31 @@ class CallViewModel @Inject constructor( stopSendingAudio() } } else { - micOn = true - if (!sendingAudio.value && established.value == CallState.ANSWERED) { - startSendingAudio() - } + setMicrophoneOn() } } fun setMicrophoneOn() { micOn = true - if (!sendingAudio.value && established.value == CallState.ANSWERED) { + if (!sendingAudio.value && call.value.state == Call.State.ANSWERED) { startSendingAudio() } } - val inCall = callManager.inCall - //val inCallLiveData = callManager.inCall.asLiveData(vmContext) - val sendingAudio = callManager.sendingAudio - //val sendingAudioLiveData = callManager.sendingAudio.asLiveData(vmContext) + 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) - val established = callManager.established - val establishedLiveData = callManager.established.asLiveData(vmContext) + return sf + } + 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..ee93873c9 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 -> - if (!contactOnline) return@combine CallAvailability.Unavailable + .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/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index 3dfca434a..0c43fbdfa 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -16,22 +16,31 @@ 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) +} - companion object { - const val IDLE = 0 - const val CALLING_OUT = 1 - const val PENDING = 2 - const val ANSWERED = 3 - } +fun Call.setData(aPk: PublicKey, aInOrOut: Call.InOrOut, aSt: Long): Call { + return 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 { + return state == Call.State.CALLING_OUT || state == Call.State.ANSWERED } private const val TAG = "CallManager" @@ -42,11 +51,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 _established = MutableStateFlow(CallState.IDLE) - val established : StateFlow get() = _established + private val _call = MutableStateFlow(Call(Call.State.IDLE, null)) + val call: StateFlow get() = _call private val _pendingCalls = MutableStateFlow>(mutableSetOf()) val pendingCalls: StateFlow> get() = _pendingCalls @@ -56,6 +62,24 @@ 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) @@ -63,8 +87,14 @@ 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()) && _established.value == CallState.IDLE) - _established.value = CallState.PENDING + 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) { @@ -75,8 +105,8 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C calls.remove(removed) _pendingCalls.value = calls } - if ( _pendingCalls.value.isEmpty() && _established.value == CallState.PENDING) - _established.value = CallState.IDLE + if ( _pendingCalls.value.isEmpty() && _call.value.state == Call.State.PENDING) + toState(Call.State.IDLE) } fun startCall(publicKey: PublicKey) { @@ -88,18 +118,15 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } audioManager?.mode = AudioManager.MODE_IN_COMMUNICATION removePendingCall(publicKey) - _inCall.value = CallState.InCall(publicKey, SystemClock.elapsedRealtime()) - _established.value = if (toAnswer) CallState.ANSWERED - else CallState.CALLING_OUT + 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 // move to below ? - _inCall.value = CallState.NotInCall - _established.value = CallState.IDLE + toState(Call.State.IDLE) } removePendingCall(publicKey) @@ -114,7 +141,8 @@ 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 + 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, @@ -139,7 +167,7 @@ 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 { @@ -162,10 +190,10 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } } - fun setAnswered() : Unit { - if (_established.value != CallState.CALLING_OUT) { - Log.e(TAG, "Cot answer while in state ${_established.value}") + fun setAnswered(pk: PublicKey) : Unit { + if (_call.value.state != Call.State.CALLING_OUT) { + Log.e(TAG, "Cot answer while in state ${_call.value.state}") } - _established.value = CallState.ANSWERED + addCallData(Call.State.ANSWERED, pk, Call.InOrOut.OUTGOING ) } } From fcb0df270160b3282545fc2eefb6966022b79edc Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sun, 14 Dec 2025 01:12:10 +0300 Subject: [PATCH 11/13] ktlint --- .../main/kotlin/tox/EventListenerCallbacks.kt | 10 ++- atox/src/main/kotlin/ui/call/CallFragment.kt | 86 ++++++++++--------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 9 +- atox/src/main/kotlin/ui/chat/ChatViewModel.kt | 6 +- domain/src/main/kotlin/feature/CallManager.kt | 67 +++++++-------- 5 files changed, 95 insertions(+), 83 deletions(-) diff --git a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt index 107927141..1ac348b57 100644 --- a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt +++ b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt @@ -197,12 +197,14 @@ class EventListenerCallbacks @Inject constructor( callStateHandler = { pk, callState -> Log.e(TAG, "callState ${pk.fingerprint()} $callState") - if (callState.contains(ToxavFriendCallState.SENDING_A) - || callState.contains(ToxavFriendCallState.ACCEPTING_A)) { + if (callState.contains(ToxavFriendCallState.SENDING_A) || + callState.contains(ToxavFriendCallState.ACCEPTING_A) + ) { callManager.setAnswered(PublicKey(pk)) } - if (callState.contains(ToxavFriendCallState.FINISHED) - || callState.contains(ToxavFriendCallState.ERROR)) { + if (callState.contains(ToxavFriendCallState.FINISHED) || + callState.contains(ToxavFriendCallState.ERROR) + ) { audioPlayer?.stop() audioPlayer?.release() audioPlayer = null diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index bd5be8dd1..134fd69db 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -20,6 +20,8 @@ import androidx.core.view.updatePadding import androidx.fragment.app.viewModels 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 @@ -34,9 +36,6 @@ import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.feature.Call import ltd.evilcorp.domain.feature.inCall -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - private const val PERMISSION = Manifest.permission.RECORD_AUDIO private const val TAG = "CallFragment" @@ -84,7 +83,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() microphoneControl.setOnClickListener { - if (! requireContext().hasPermission(PERMISSION)) { + if (!requireContext().hasPermission(PERMISSION)) { vm.micOn = false /*Toast.makeText( context, @@ -98,29 +97,30 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() } - updateSpeakerphoneIcon() - speakerphone.setOnClickListener { - vm.toggleSpeakerphone() - updateSpeakerphoneIcon() - } - - backToChat.setOnClickListener { - findNavController().popBackStack() - } - - vm.callLiveData.observe(viewLifecycleOwner) { call -> - Log.d(TAG, "observer here") - adoptState() - } - - if (vm.call.value.state != Call.State.IDLE - && vm.call.value.state != Call.State.PENDING) { - adoptState() - return@run - } + updateSpeakerphoneIcon() + speakerphone.setOnClickListener { + vm.toggleSpeakerphone() + updateSpeakerphoneIcon() + } + + backToChat.setOnClickListener { + findNavController().popBackStack() + } + + vm.callLiveData.observe(viewLifecycleOwner) { call -> + Log.d(TAG, "observer here") + adoptState() + } + + 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 + } // end onViewCreated /*override fun onResume() = binding.run { val nme = vm.call.value.state @@ -129,14 +129,20 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl }*/ 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 updateMicrophoneControlIcon() { - val icon = if (vm.micOn) R.drawable.ic_mic - else R.drawable.ic_mic_off + val icon = if (vm.micOn) { + R.drawable.ic_mic + } else { + R.drawable.ic_mic_off + } binding.microphoneControl.setImageResource(icon) } private fun adoptState() { @@ -144,9 +150,9 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun adoptState(state: Call.State) { // may be called repeatedly, so must be idempotent - Log.d(TAG, "adoptState, state = ${state}") + Log.d(TAG, "adoptState, state = $state") when (state) { - Call.State.CALLING_OUT -> { + Call.State.CALLING_OUT -> { binding.tvState.setText(getString(R.string.ringing)) playConnecting() } @@ -154,7 +160,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() binding.tvState.setText("talking") startTimer() - if (! vm.sendingAudio.value && vm.micOn) { + if (!vm.sendingAudio.value && vm.micOn) { if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } @@ -166,14 +172,17 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() findNavController().popBackStack() } - else -> Log.e(TAG, "STATE = ${state}") + else -> Log.e(TAG, "STATE = $state") } } private fun playConnecting() { val audioAttrContext = - if (Build.VERSION.SDK_INT >= 30) context?.createAttributionContext("audioPlayback") - else context + 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) @@ -187,15 +196,15 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun startTimer() { - if (! vm.call.value.inCall()) return + 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 elapsed: Duration = (SystemClock.elapsedRealtime() - from).milliseconds val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> - //String.format("%01d:%02d:%02d", hours, minutes, seconds) + // String.format("%01d:%02d:%02d", hours, minutes, seconds) vm.presentTime(hours, minutes, seconds, nanoseconds) } binding.tvState.setText(s) @@ -203,6 +212,5 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl delay(1000L) } } - } } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index c2d7150e4..b4fa87554 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -61,7 +61,7 @@ class CallViewModel @Inject constructor( } } - var micOn = false + var micOn = false fun toggleMicrophoneControl() { if (micOn) { micOn = false @@ -86,8 +86,11 @@ class CallViewModel @Inject constructor( 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 += if (hours == 0L) { + String.format("%02d:%02d", minutes, seconds) + } else { + String.format("%01d:%02d:%02d", hours, minutes, seconds) + } return sf } diff --git a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt index ee93873c9..6689887b8 100644 --- a/atox/src/main/kotlin/ui/chat/ChatViewModel.kt +++ b/atox/src/main/kotlin/ui/chat/ChatViewModel.kt @@ -79,14 +79,14 @@ class ChatViewModel @Inject constructor( .filterNotNull() .transform { emit(it.connectionStatus != ConnectionStatus.None) } .combine(callManager.call) { contactOnline, callValue -> - if (! contactOnline) return@combine CallAvailability.Unavailable - if (callValue.inCall()) + if (!contactOnline) return@combine CallAvailability.Unavailable + if (callValue.inCall()) { if (callValue.data?.publicKey == publicKey) { CallAvailability.Active } else { CallAvailability.Unavailable } - else { + } else { CallAvailability.Available } /* diff --git a/domain/src/main/kotlin/feature/CallManager.kt b/domain/src/main/kotlin/feature/CallManager.kt index 0c43fbdfa..b97f99015 100644 --- a/domain/src/main/kotlin/feature/CallManager.kt +++ b/domain/src/main/kotlin/feature/CallManager.kt @@ -23,25 +23,19 @@ import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.av.AudioCapture import ltd.evilcorp.domain.tox.Tox -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) +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 { - return this.copy( data = Call.Data(aPk, aInOrOut,aSt) ) -} +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 { - return state == Call.State.CALLING_OUT || state == Call.State.ANSWERED -} +fun Call.inCall(): Boolean = state == Call.State.CALLING_OUT || state == Call.State.ANSWERED private const val TAG = "CallManager" @@ -68,18 +62,17 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } } - private fun addCallData(newState: Call.State, aPk: PublicKey, aInOrOut: Call.InOrOut, aSt: Long) { + 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) { + private fun addCallData(newState: Call.State, aPk: PublicKey, aInOrOut: Call.InOrOut) { _call.update { current -> - current.setState(newState).setData(aPk, aInOrOut,SystemClock.elapsedRealtime()) + current.setState(newState).setData(aPk, aInOrOut, SystemClock.elapsedRealtime()) } } - fun addPendingCall(from: Contact) { val calls = mutableSetOf().apply { addAll(_pendingCalls.value) } calls.addAll(_pendingCalls.value) @@ -87,14 +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 (!_pendingCalls.value.isEmpty()) { if (_call.value.state == Call.State.IDLE) { - toState( Call.State.PENDING) - } else { + toState(Call.State.PENDING) + } else { Log.e(TAG, "Got pending call while state=${_call.value.state}") } } - } fun removePendingCall(pk: PublicKey) { @@ -105,8 +97,9 @@ 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) + if (_pendingCalls.value.isEmpty() && _call.value.state == Call.State.PENDING) { toState(Call.State.IDLE) + } } fun startCall(publicKey: PublicKey) { @@ -118,8 +111,11 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } 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 ) + if (toAnswer) { + addCallData(Call.State.ANSWERED, publicKey, Call.InOrOut.INCOMING) + } else { + addCallData(Call.State.CALLING_OUT, publicKey, Call.InOrOut.OUTGOING) + } } fun endCall(publicKey: PublicKey) { @@ -141,14 +137,16 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } fun startSendingAudio(): Boolean { - if (! call.value.inCall()) 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 + 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 } @@ -175,7 +173,8 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C to, audioFrame, AUDIO_CHANNELS, - AUDIO_SAMPLING_RATE_HZ) + AUDIO_SAMPLING_RATE_HZ, + ) } catch (e: Exception) { Log.e(TAG, e.toString()) } @@ -190,10 +189,10 @@ class CallManager @Inject constructor(private val tox: Tox, private val scope: C } } - fun setAnswered(pk: PublicKey) : Unit { + 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 ) + addCallData(Call.State.ANSWERED, pk, Call.InOrOut.OUTGOING) } } From 55270e4af246327f9330951be5e67df710ab4e4c Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sun, 14 Dec 2025 17:35:29 +0300 Subject: [PATCH 12/13] 6.2.0 added framerate counter --- .../main/kotlin/tox/EventListenerCallbacks.kt | 19 ++-- atox/src/main/kotlin/ui/call/CallFragment.kt | 88 +++++++++---------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 28 ++++-- 3 files changed, 75 insertions(+), 60 deletions(-) diff --git a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt index 1ac348b57..67ad28d2c 100644 --- a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt +++ b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt @@ -41,6 +41,7 @@ import ltd.evilcorp.domain.tox.Tox import ltd.evilcorp.domain.tox.ToxAvEventListener import ltd.evilcorp.domain.tox.ToxEventListener import ltd.evilcorp.domain.tox.toMessageType +import java.util.concurrent.atomic.AtomicInteger private const val MAX_ACTIVE_FRIEND_REQUESTS = 32 private const val TAG = "EventListenerCallbacks" @@ -81,6 +82,12 @@ class EventListenerCallbacks @Inject constructor( private fun notifyMessage(contact: Contact, message: String) = notificationHelper.showMessageNotification(contact, message, silent = tox.getStatus() == UserStatus.Busy) + private val frameCount: AtomicInteger = AtomicInteger(0) + + fun getFrameCount(): Int { + return frameCount.get() + } + fun setUp(listener: ToxEventListener) = with(listener) { friendStatusMessageHandler = { publicKey, message -> contactRepository.setStatusMessage(publicKey, message) @@ -197,14 +204,12 @@ class EventListenerCallbacks @Inject constructor( callStateHandler = { pk, callState -> Log.e(TAG, "callState ${pk.fingerprint()} $callState") - if (callState.contains(ToxavFriendCallState.SENDING_A) || - callState.contains(ToxavFriendCallState.ACCEPTING_A) - ) { + if (callState.contains(ToxavFriendCallState.SENDING_A) + || callState.contains(ToxavFriendCallState.ACCEPTING_A)) { callManager.setAnswered(PublicKey(pk)) } - if (callState.contains(ToxavFriendCallState.FINISHED) || - callState.contains(ToxavFriendCallState.ERROR) - ) { + if (callState.contains(ToxavFriendCallState.FINISHED) + || callState.contains(ToxavFriendCallState.ERROR)) { audioPlayer?.stop() audioPlayer?.release() audioPlayer = null @@ -245,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 134fd69db..3cb8ba6b4 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -20,8 +20,6 @@ import androidx.core.view.updatePadding import androidx.fragment.app.viewModels 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 @@ -36,6 +34,9 @@ import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.feature.Call import ltd.evilcorp.domain.feature.inCall +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + private const val PERMISSION = Manifest.permission.RECORD_AUDIO private const val TAG = "CallFragment" @@ -83,7 +84,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() microphoneControl.setOnClickListener { - if (!requireContext().hasPermission(PERMISSION)) { + if (! requireContext().hasPermission(PERMISSION)) { vm.micOn = false /*Toast.makeText( context, @@ -97,30 +98,29 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() } - updateSpeakerphoneIcon() - speakerphone.setOnClickListener { - vm.toggleSpeakerphone() - updateSpeakerphoneIcon() - } - - backToChat.setOnClickListener { - findNavController().popBackStack() - } - - vm.callLiveData.observe(viewLifecycleOwner) { call -> - Log.d(TAG, "observer here") - adoptState() - } - - if (vm.call.value.state != Call.State.IDLE && - vm.call.value.state != Call.State.PENDING - ) { - adoptState() - return@run - } + updateSpeakerphoneIcon() + speakerphone.setOnClickListener { + vm.toggleSpeakerphone() + updateSpeakerphoneIcon() + } + + backToChat.setOnClickListener { + findNavController().popBackStack() + } + + vm.callLiveData.observe(viewLifecycleOwner) { call -> + Log.d(TAG, "observer here") + adoptState() + } + + 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 + }// end onViewCreated /*override fun onResume() = binding.run { val nme = vm.call.value.state @@ -129,20 +129,14 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl }*/ 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 updateMicrophoneControlIcon() { - val icon = if (vm.micOn) { - R.drawable.ic_mic - } else { - R.drawable.ic_mic_off - } + val icon = if (vm.micOn) R.drawable.ic_mic + else R.drawable.ic_mic_off binding.microphoneControl.setImageResource(icon) } private fun adoptState() { @@ -150,9 +144,9 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun adoptState(state: Call.State) { // may be called repeatedly, so must be idempotent - Log.d(TAG, "adoptState, state = $state") + Log.d(TAG, "adoptState, state = ${state}") when (state) { - Call.State.CALLING_OUT -> { + Call.State.CALLING_OUT -> { binding.tvState.setText(getString(R.string.ringing)) playConnecting() } @@ -160,7 +154,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() binding.tvState.setText("talking") startTimer() - if (!vm.sendingAudio.value && vm.micOn) { + if (! vm.sendingAudio.value && vm.micOn) { if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } @@ -172,17 +166,14 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() findNavController().popBackStack() } - else -> Log.e(TAG, "STATE = $state") + else -> Log.e(TAG, "STATE = ${state}") } } private fun playConnecting() { val audioAttrContext = - if (Build.VERSION.SDK_INT >= 30) { - context?.createAttributionContext("audioPlayback") - } else { - context - } + 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) @@ -196,21 +187,22 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun startTimer() { - if (!vm.call.value.inCall()) return + 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 elapsed : Duration = (SystemClock.elapsedRealtime() - from).milliseconds val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> - // String.format("%01d:%02d:%02d", hours, minutes, seconds) + //String.format("%01d:%02d:%02d", hours, minutes, seconds) vm.presentTime(hours, minutes, seconds, nanoseconds) } binding.tvState.setText(s) } - delay(1000L) + delay(vm.SCREEN_TIMER_MS) } } + } } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index b4fa87554..256cd3de8 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -14,6 +14,7 @@ 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 @@ -29,6 +30,11 @@ class CallViewModel @Inject constructor( private val proximityScreenOff: ProximityScreenOff, ) : ViewModel() { val vmContext = viewModelScope.coroutineContext + var storedFrameCount: Int = 0 + @Inject + lateinit var eventListenerCallbacks: EventListenerCallbacks + + val SCREEN_TIMER_MS = 1000L private var publicKey = PublicKey("") val contact: LiveData by lazy { @@ -61,7 +67,7 @@ class CallViewModel @Inject constructor( } } - var micOn = false + var micOn = false fun toggleMicrophoneControl() { if (micOn) { micOn = false @@ -86,15 +92,25 @@ class CallViewModel @Inject constructor( 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 += 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 = SCREEN_TIMER_MS / 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 From 0fca8bbf639b0eba7639668df73492e76774a15c Mon Sep 17 00:00:00 2001 From: TrueWatcher Date: Sun, 14 Dec 2025 18:08:03 +0300 Subject: [PATCH 13/13] 6.2.1 fixed name --- .../main/kotlin/tox/EventListenerCallbacks.kt | 16 ++-- atox/src/main/kotlin/ui/call/CallFragment.kt | 88 ++++++++++--------- atox/src/main/kotlin/ui/call/CallViewModel.kt | 27 +++--- 3 files changed, 73 insertions(+), 58 deletions(-) diff --git a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt index 67ad28d2c..1b5fc20db 100644 --- a/atox/src/main/kotlin/tox/EventListenerCallbacks.kt +++ b/atox/src/main/kotlin/tox/EventListenerCallbacks.kt @@ -10,6 +10,7 @@ import im.tox.tox4j.av.enums.ToxavFriendCallState import im.tox.tox4j.core.enums.ToxFileControl import java.net.URLConnection import java.util.Date +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -41,7 +42,6 @@ import ltd.evilcorp.domain.tox.Tox import ltd.evilcorp.domain.tox.ToxAvEventListener import ltd.evilcorp.domain.tox.ToxEventListener import ltd.evilcorp.domain.tox.toMessageType -import java.util.concurrent.atomic.AtomicInteger private const val MAX_ACTIVE_FRIEND_REQUESTS = 32 private const val TAG = "EventListenerCallbacks" @@ -84,9 +84,7 @@ class EventListenerCallbacks @Inject constructor( private val frameCount: AtomicInteger = AtomicInteger(0) - fun getFrameCount(): Int { - return frameCount.get() - } + fun getFrameCount(): Int = frameCount.get() fun setUp(listener: ToxEventListener) = with(listener) { friendStatusMessageHandler = { publicKey, message -> @@ -204,12 +202,14 @@ class EventListenerCallbacks @Inject constructor( callStateHandler = { pk, callState -> Log.e(TAG, "callState ${pk.fingerprint()} $callState") - if (callState.contains(ToxavFriendCallState.SENDING_A) - || callState.contains(ToxavFriendCallState.ACCEPTING_A)) { + if (callState.contains(ToxavFriendCallState.SENDING_A) || + callState.contains(ToxavFriendCallState.ACCEPTING_A) + ) { callManager.setAnswered(PublicKey(pk)) } - if (callState.contains(ToxavFriendCallState.FINISHED) - || callState.contains(ToxavFriendCallState.ERROR)) { + if (callState.contains(ToxavFriendCallState.FINISHED) || + callState.contains(ToxavFriendCallState.ERROR) + ) { audioPlayer?.stop() audioPlayer?.release() audioPlayer = null diff --git a/atox/src/main/kotlin/ui/call/CallFragment.kt b/atox/src/main/kotlin/ui/call/CallFragment.kt index 3cb8ba6b4..609332e0c 100644 --- a/atox/src/main/kotlin/ui/call/CallFragment.kt +++ b/atox/src/main/kotlin/ui/call/CallFragment.kt @@ -20,6 +20,8 @@ import androidx.core.view.updatePadding import androidx.fragment.app.viewModels 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 @@ -34,9 +36,6 @@ import ltd.evilcorp.atox.vmFactory import ltd.evilcorp.core.vo.PublicKey import ltd.evilcorp.domain.feature.Call import ltd.evilcorp.domain.feature.inCall -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - private const val PERMISSION = Manifest.permission.RECORD_AUDIO private const val TAG = "CallFragment" @@ -84,7 +83,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() microphoneControl.setOnClickListener { - if (! requireContext().hasPermission(PERMISSION)) { + if (!requireContext().hasPermission(PERMISSION)) { vm.micOn = false /*Toast.makeText( context, @@ -98,29 +97,30 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl updateMicrophoneControlIcon() } - updateSpeakerphoneIcon() - speakerphone.setOnClickListener { - vm.toggleSpeakerphone() - updateSpeakerphoneIcon() - } - - backToChat.setOnClickListener { - findNavController().popBackStack() - } - - vm.callLiveData.observe(viewLifecycleOwner) { call -> - Log.d(TAG, "observer here") - adoptState() - } - - if (vm.call.value.state != Call.State.IDLE - && vm.call.value.state != Call.State.PENDING) { - adoptState() - return@run - } + updateSpeakerphoneIcon() + speakerphone.setOnClickListener { + vm.toggleSpeakerphone() + updateSpeakerphoneIcon() + } + + backToChat.setOnClickListener { + findNavController().popBackStack() + } + + vm.callLiveData.observe(viewLifecycleOwner) { call -> + Log.d(TAG, "observer here") + adoptState() + } + + 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 + } // end onViewCreated /*override fun onResume() = binding.run { val nme = vm.call.value.state @@ -129,14 +129,20 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl }*/ 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 updateMicrophoneControlIcon() { - val icon = if (vm.micOn) R.drawable.ic_mic - else R.drawable.ic_mic_off + val icon = if (vm.micOn) { + R.drawable.ic_mic + } else { + R.drawable.ic_mic_off + } binding.microphoneControl.setImageResource(icon) } private fun adoptState() { @@ -144,9 +150,9 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun adoptState(state: Call.State) { // may be called repeatedly, so must be idempotent - Log.d(TAG, "adoptState, state = ${state}") + Log.d(TAG, "adoptState, state = $state") when (state) { - Call.State.CALLING_OUT -> { + Call.State.CALLING_OUT -> { binding.tvState.setText(getString(R.string.ringing)) playConnecting() } @@ -154,7 +160,7 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() binding.tvState.setText("talking") startTimer() - if (! vm.sendingAudio.value && vm.micOn) { + if (!vm.sendingAudio.value && vm.micOn) { if (requireContext().hasPermission(PERMISSION)) { vm.startSendingAudio() } @@ -166,14 +172,17 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl stopPlay() findNavController().popBackStack() } - else -> Log.e(TAG, "STATE = ${state}") + else -> Log.e(TAG, "STATE = $state") } } private fun playConnecting() { val audioAttrContext = - if (Build.VERSION.SDK_INT >= 30) context?.createAttributionContext("audioPlayback") - else context + 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) @@ -187,22 +196,21 @@ class CallFragment : BaseFragment(FragmentCallBinding::infl } private fun startTimer() { - if (! vm.call.value.inCall()) return + 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 elapsed: Duration = (SystemClock.elapsedRealtime() - from).milliseconds val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds -> - //String.format("%01d:%02d:%02d", hours, minutes, seconds) + // String.format("%01d:%02d:%02d", hours, minutes, seconds) vm.presentTime(hours, minutes, seconds, nanoseconds) } binding.tvState.setText(s) } - delay(vm.SCREEN_TIMER_MS) + delay(vm.screenTimerMs) } } - } } diff --git a/atox/src/main/kotlin/ui/call/CallViewModel.kt b/atox/src/main/kotlin/ui/call/CallViewModel.kt index 256cd3de8..44af8f776 100644 --- a/atox/src/main/kotlin/ui/call/CallViewModel.kt +++ b/atox/src/main/kotlin/ui/call/CallViewModel.kt @@ -31,10 +31,11 @@ class CallViewModel @Inject constructor( ) : ViewModel() { val vmContext = viewModelScope.coroutineContext var storedFrameCount: Int = 0 + @Inject lateinit var eventListenerCallbacks: EventListenerCallbacks - val SCREEN_TIMER_MS = 1000L + val screenTimerMs = 1000L private var publicKey = PublicKey("") val contact: LiveData by lazy { @@ -67,7 +68,7 @@ class CallViewModel @Inject constructor( } } - var micOn = false + var micOn = false fun toggleMicrophoneControl() { if (micOn) { micOn = false @@ -92,8 +93,11 @@ class CallViewModel @Inject constructor( 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 += if (hours == 0L) { + String.format("%02d:%02d", minutes, seconds) + } else { + String.format("%01d:%02d:%02d", hours, minutes, seconds) + } sf += presentFrameRate() return sf @@ -101,14 +105,17 @@ class CallViewModel @Inject constructor( fun presentFrameRate(): String { val new = eventListenerCallbacks.getFrameCount() - var delta = new - storedFrameCount - if (delta < 0) delta = 0 + var delta = new - storedFrameCount + if (delta < 0) delta = 0 storedFrameCount = new - // normal fps = SCREEN_TIMER_MS / CallManager.AUDIO_SEND_INTERVAL_MS = 50 - val asSymbol = if (delta > 25) " +" // audio link is working - else " X" // few or no frames are coming in + // 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" + // return " ${delta}fps" } val call = callManager.call