Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 15 additions & 1 deletion atox/src/main/kotlin/tox/EventListenerCallbacks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +82,10 @@ 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 = frameCount.get()

fun setUp(listener: ToxEventListener) = with(listener) {
friendStatusMessageHandler = { publicKey, message ->
contactRepository.setStatusMessage(publicKey, message)
Expand Down Expand Up @@ -197,7 +202,14 @@ class EventListenerCallbacks @Inject constructor(

callStateHandler = { pk, callState ->
Log.e(TAG, "callState ${pk.fingerprint()} $callState")
if (callState.contains(ToxavFriendCallState.FINISHED) || callState.contains(ToxavFriendCallState.ERROR)) {
if (callState.contains(ToxavFriendCallState.SENDING_A) ||
callState.contains(ToxavFriendCallState.ACCEPTING_A)
) {
callManager.setAnswered(PublicKey(pk))
}
if (callState.contains(ToxavFriendCallState.FINISHED) ||
callState.contains(ToxavFriendCallState.ERROR)
) {
audioPlayer?.stop()
audioPlayer?.release()
audioPlayer = null
Expand Down Expand Up @@ -238,8 +250,10 @@ class EventListenerCallbacks @Inject constructor(
if (audioPlayer == null) {
audioPlayer = AudioPlayer(samplingRate, channels)
audioPlayer?.start()
frameCount.set(0)
}
audioPlayer?.buffer(pcm)
frameCount.incrementAndGet()
}
}
}
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) {
context?.createAttributionContext("audioPlayback")
} else {
context
}
if (mediaPlayer == null) {
mediaPlayer = MediaPlayer.create(audioAttrContext, R.raw.connecting_ringtone)
mediaPlayer?.setLooping(true)
}
mediaPlayer?.start()
}

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

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