Skip to content
This repository was archived by the owner on May 2, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions atox/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

<uses-feature android:name="android.hardware.camera" android:required="false"/>

<attribution android:tag="audioPlayback" android:label="@string/audio_playback_attribution" />

<application
android:name=".App"
android:allowBackup="false"
Expand Down
4 changes: 2 additions & 2 deletions atox/src/main/kotlin/ActionReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions atox/src/main/kotlin/ToxService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
}
Expand Down
9 changes: 8 additions & 1 deletion atox/src/main/kotlin/tox/EventListenerCallbacks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,14 @@ class EventListenerCallbacks @Inject constructor(

callStateHandler = { pk, callState ->
Log.e(TAG, "callState ${pk.fingerprint()} $callState")
if (callState.contains(ToxavFriendCallState.FINISHED) || callState.contains(ToxavFriendCallState.ERROR)) {
if (callState.contains(ToxavFriendCallState.SENDING_A) ||
callState.contains(ToxavFriendCallState.ACCEPTING_A)
) {
callManager.setAnswered(PublicKey(pk))
}
if (callState.contains(ToxavFriendCallState.FINISHED) ||
callState.contains(ToxavFriendCallState.ERROR)
) {
audioPlayer?.stop()
audioPlayer?.release()
audioPlayer = null
Expand Down
168 changes: 132 additions & 36 deletions atox/src/main/kotlin/ui/call/CallFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@
package ltd.evilcorp.atox.ui.call

import android.Manifest
import android.media.MediaPlayer
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ltd.evilcorp.atox.R
import ltd.evilcorp.atox.databinding.FragmentCallBinding
import ltd.evilcorp.atox.hasPermission
Expand All @@ -24,9 +34,11 @@
import ltd.evilcorp.atox.ui.chat.CONTACT_PUBLIC_KEY
import ltd.evilcorp.atox.vmFactory
import ltd.evilcorp.core.vo.PublicKey
import ltd.evilcorp.domain.feature.CallState
import ltd.evilcorp.domain.feature.Call
import ltd.evilcorp.domain.feature.inCall

private const val PERMISSION = Manifest.permission.RECORD_AUDIO
private const val TAG = "CallFragment"

class CallFragment : BaseFragment<FragmentCallBinding>(FragmentCallBinding::inflate) {
private val vm: CallViewModel by viewModels { vmFactory }
Expand All @@ -35,13 +47,20 @@
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
vm.startSendingAudio()
vm.setMicrophoneOn()
updateMicrophoneControlIcon()
} else {
Log.d(TAG, "Got no permission")
Toast.makeText(requireContext(), getString(R.string.call_mic_permission_needed), Toast.LENGTH_LONG).show()
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) = binding.run {
private var mediaPlayer: MediaPlayer? = null
private var timerNHandle: Job? = null

override fun onViewCreated(view: View, savedInstanceState: Bundle?): Unit = binding.run {
Log.d(TAG, "onViewCreated here")

ViewCompat.setOnApplyWindowInsetsListener(view) { _, compat ->
val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars())
controlContainer.updatePadding(bottom = insets.bottom + controlContainer.paddingTop)
Expand All @@ -51,31 +70,31 @@
vm.setActiveContact(PublicKey(requireStringArg(CONTACT_PUBLIC_KEY)))
vm.contact.observe(viewLifecycleOwner) {
avatarImageView.setFrom(it)
tvData.setText(it.name)
}

endCall.setOnClickListener {
Log.d(TAG, "finishing by End Call")
vm.endCall()
findNavController().popBackStack()
adoptState(Call.State.IDLE)
}

vm.sendingAudio.asLiveData().observe(viewLifecycleOwner) { sending ->
if (sending) {
microphoneControl.setImageResource(R.drawable.ic_mic)
} else {
microphoneControl.setImageResource(R.drawable.ic_mic_off)
}
}
vm.micOn = requireContext().hasPermission(PERMISSION)
updateMicrophoneControlIcon()

microphoneControl.setOnClickListener {
if (vm.sendingAudio.value) {
vm.stopSendingAudio()
if (!requireContext().hasPermission(PERMISSION)) {
vm.micOn = false
/*Toast.makeText(
context,
R.string.call_mic_permission_needed,
Toast.LENGTH_LONG
).show()*/
requestPermissionLauncher.launch(PERMISSION)
} else {
if (requireContext().hasPermission(PERMISSION)) {
vm.startSendingAudio()
} else {
requestPermissionLauncher.launch(PERMISSION)
}
vm.toggleMicrophoneControl()
}
updateMicrophoneControlIcon()
}

updateSpeakerphoneIcon()
Expand All @@ -88,33 +107,110 @@
findNavController().popBackStack()
}

if (vm.inCall.value is CallState.InCall) {
vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall ->
if (inCall == CallState.NotInCall) {
findNavController().popBackStack()
}
}
return
vm.callLiveData.observe(viewLifecycleOwner) { call ->
Log.d(TAG, "observer here")
adoptState()
}

startCall()

if (requireContext().hasPermission(PERMISSION)) {
vm.startSendingAudio()
if (vm.call.value.state != Call.State.IDLE &&
vm.call.value.state != Call.State.PENDING
) {
adoptState()
return@run
}
}
binding.tvState.setText("startinng a call...") // normally, not to be seen
vm.startCall()
} // end onViewCreated

/*override fun onResume() = binding.run {
val nme = vm.call.value.state
Log.d(TAG, "onResume here, state=$nme")
super.onResume()
}*/

private fun updateSpeakerphoneIcon() {
val icon = if (vm.speakerphoneOn) R.drawable.ic_speakerphone else R.drawable.ic_speakerphone_off
val icon = if (vm.speakerphoneOn) {
R.drawable.ic_speakerphone
} else {
R.drawable.ic_speakerphone_off
}
binding.speakerphone.setImageResource(icon)
}

private fun startCall() {
vm.startCall()
vm.inCall.asLiveData().observe(viewLifecycleOwner) { inCall ->
if (inCall == CallState.NotInCall) {
private fun updateMicrophoneControlIcon() {
val icon = if (vm.micOn) {
R.drawable.ic_mic
} else {
R.drawable.ic_mic_off
}
binding.microphoneControl.setImageResource(icon)
}
private fun adoptState() {
adoptState(vm.call.value.state)
}
private fun adoptState(state: Call.State) {
// may be called repeatedly, so must be idempotent
Log.d(TAG, "adoptState, state = $state")
when (state) {
Call.State.CALLING_OUT -> {
binding.tvState.setText(getString(R.string.ringing))
playConnecting()
}
Call.State.ANSWERED -> {
stopPlay()
binding.tvState.setText("talking")
startTimer()
if (!vm.sendingAudio.value && vm.micOn) {
if (requireContext().hasPermission(PERMISSION)) {
vm.startSendingAudio()
}
}
}
// as LiveData never emits its init value, IDLE means the call is finished
Call.State.IDLE -> {
binding.tvState.setText("00000")
stopPlay()
findNavController().popBackStack()
}
else -> Log.e(TAG, "STATE = $state")
}
}

private fun playConnecting() {
val audioAttrContext =
if (Build.VERSION.SDK_INT >= 30) {

Check warning on line 181 in atox/src/main/kotlin/ui/call/CallFragment.kt

View check run for this annotation

GitHub Advanced Security / detekt

Report magic numbers. Magic number is a numeric literal that is not defined as a constant and hence it's unclear what the purpose of this number is. It's better to declare such numbers as constants and give them a proper name. By default, -1, 0, 1, and 2 are not considered to be magic numbers.

This expression contains a magic number. Consider defining it to a well named constant.

Check warning

Code scanning / detekt

Report magic numbers. Magic number is a numeric literal that is not defined as a constant and hence it's unclear what the purpose of this number is. It's better to declare such numbers as constants and give them a proper name. By default, -1, 0, 1, and 2 are not considered to be magic numbers. Warning

This expression contains a magic number. Consider defining it to a well named constant.
context?.createAttributionContext("audioPlayback")
} else {
context
}
if (mediaPlayer == null) {
mediaPlayer = MediaPlayer.create(audioAttrContext, R.raw.connecting_ringtone)
mediaPlayer?.setLooping(true)
}
mediaPlayer?.start()
}

private fun stopPlay() {
mediaPlayer?.stop()
mediaPlayer = null
}

private fun startTimer() {
if (!vm.call.value.inCall()) return
if (timerNHandle?.isActive == true) return
val from: Long = vm.call.value.data?.startTime ?: 0
timerNHandle = lifecycleScope.launch(Dispatchers.IO) {
while (vm.call.value.inCall()) {
lifecycleScope.launch {
val elapsed: Duration = (SystemClock.elapsedRealtime() - from).milliseconds
val s = elapsed.toComponents { hours, minutes, seconds, nanoseconds ->
// String.format("%01d:%02d:%02d", hours, minutes, seconds)
vm.presentTime(hours, minutes, seconds, nanoseconds)
}
binding.tvState.setText(s)
}
delay(1000L)

Check warning on line 212 in atox/src/main/kotlin/ui/call/CallFragment.kt

View check run for this annotation

GitHub Advanced Security / detekt

Report magic numbers. Magic number is a numeric literal that is not defined as a constant and hence it's unclear what the purpose of this number is. It's better to declare such numbers as constants and give them a proper name. By default, -1, 0, 1, and 2 are not considered to be magic numbers.

This expression contains a magic number. Consider defining it to a well named constant.
Comment thread Fixed
}
}
}
}
41 changes: 39 additions & 2 deletions atox/src/main/kotlin/ui/call/CallViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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
Expand All @@ -16,6 +17,7 @@
import ltd.evilcorp.atox.ui.NotificationHelper
import ltd.evilcorp.core.vo.Contact
import ltd.evilcorp.core.vo.PublicKey
import ltd.evilcorp.domain.feature.Call
import ltd.evilcorp.domain.feature.CallManager
import ltd.evilcorp.domain.feature.ContactManager

Expand All @@ -26,6 +28,7 @@
private val contactManager: ContactManager,
private val proximityScreenOff: ProximityScreenOff,
) : ViewModel() {
val vmContext = viewModelScope.coroutineContext
private var publicKey = PublicKey("")

val contact: LiveData<Contact> by lazy {
Expand Down Expand Up @@ -58,8 +61,42 @@
}
}

val inCall = callManager.inCall
val sendingAudio = callManager.sendingAudio
var micOn = false
fun toggleMicrophoneControl() {
if (micOn) {
micOn = false
if (sendingAudio.value) {
stopSendingAudio()
}
} else {
setMicrophoneOn()
}
}

fun setMicrophoneOn() {
micOn = true
if (!sendingAudio.value && call.value.state == Call.State.ANSWERED) {
startSendingAudio()
}
}

fun presentTime(hours: Long, minutes: Int, seconds: Int, nanoseconds: Int): String {

Check warning on line 83 in atox/src/main/kotlin/ui/call/CallViewModel.kt

View check run for this annotation

GitHub Advanced Security / detekt

Function parameter is unused and should be removed.

Function parameter `nanoseconds` is unused.

Check warning

Code scanning / detekt

Function parameter is unused and should be removed. Warning

Function parameter nanoseconds is unused.
Comment thread Fixed
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)

Check warning on line 90 in atox/src/main/kotlin/ui/call/CallViewModel.kt

View check run for this annotation

GitHub Advanced Security / detekt

Implicit default locale used for string processing. Consider using explicit locale.

String.format("%02d:%02d", minutes, seconds) uses implicitly default locale for string formatting.

Check warning

Code scanning / detekt

Implicit default locale used for string processing. Consider using explicit locale. Warning

String.format("%02d:%02d", minutes, seconds) uses implicitly default locale for string formatting.
} else {
String.format("%01d:%02d:%02d", hours, minutes, seconds)

Check warning on line 92 in atox/src/main/kotlin/ui/call/CallViewModel.kt

View check run for this annotation

GitHub Advanced Security / detekt

Implicit default locale used for string processing. Consider using explicit locale.

String.format("%01d:%02d:%02d", hours, minutes, seconds) uses implicitly default locale for string formatting.
Comment thread Fixed

Check warning

Code scanning / detekt

Implicit default locale used for string processing. Consider using explicit locale. Warning

String.format("%01d:%02d:%02d", hours, minutes, seconds) uses implicitly default locale for string formatting.
}

return sf
}

val call = callManager.call
val callLiveData = callManager.call.asLiveData(vmContext)
val sendingAudio = callManager.sendingAudio
var speakerphoneOn by callManager::speakerphoneOn
}
6 changes: 3 additions & 3 deletions atox/src/main/kotlin/ui/chat/ChatFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -252,11 +252,11 @@ class ChatFragment : BaseFragment<FragmentChatBinding>(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 {
Expand Down
Loading
Loading