Added sliding button to answer/decline incoming call if device screen is locked

This commit is contained in:
Sylvain Berfini 2025-05-20 13:48:19 +02:00
parent 4cb7ea1965
commit 21398c7b37
6 changed files with 309 additions and 49 deletions

View file

@ -19,16 +19,23 @@
*/ */
package org.linphone.ui.call.fragment package org.linphone.ui.call.fragment
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.databinding.CallIncomingFragmentBinding import org.linphone.databinding.CallIncomingFragmentBinding
import org.linphone.ui.call.viewmodel.CurrentCallViewModel import org.linphone.ui.call.viewmodel.CurrentCallViewModel
import org.linphone.utils.AppUtils
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@UiThread @UiThread
class IncomingCallFragment : GenericCallFragment() { class IncomingCallFragment : GenericCallFragment() {
@ -40,6 +47,66 @@ class IncomingCallFragment : GenericCallFragment() {
private lateinit var callViewModel: CurrentCallViewModel private lateinit var callViewModel: CurrentCallViewModel
private val marginSize = AppUtils.getDimension(R.dimen.sliding_accept_decline_call_margin)
private val areaSize = AppUtils.getDimension(R.dimen.call_button_size) + marginSize
private var initialX = 0f
private var slidingButtonX = 0f
private val slidingButtonTouchListener = View.OnTouchListener { view, event ->
val width = binding.bottomBar.root.width.toFloat()
val aboveAnswer = view.x + view.width > width - areaSize
val aboveDecline = view.x < areaSize
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (initialX == 0f) {
initialX = view.x
}
slidingButtonX = view.x - event.rawX
true
}
MotionEvent.ACTION_UP -> {
if (aboveAnswer) {
// Accept
callViewModel.answer()
} else if (aboveDecline) {
// Decline
callViewModel.hangUp()
} else {
// Animate going back to initial position
view.animate()
.x(initialX)
.setDuration(500)
.start()
}
true
}
MotionEvent.ACTION_MOVE -> {
callViewModel.slidingButtonAboveAnswer.value = aboveAnswer
callViewModel.slidingButtonAboveDecline.value = aboveDecline
val offset = view.x - initialX
val percent = abs(offset) / (width / 2)
if (offset > 0) {
callViewModel.answerAlpha.value = 1f
callViewModel.declineAlpha.value = 1f - percent
} else if (offset < 0) {
callViewModel.answerAlpha.value = 1f - percent
callViewModel.declineAlpha.value = 1f
}
view.animate()
.x(min(max(marginSize, event.rawX + slidingButtonX), width - view.width - marginSize))
.setDuration(0)
.start()
true
}
else -> {
view.performClick()
false
}
}
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -49,6 +116,7 @@ class IncomingCallFragment : GenericCallFragment() {
return binding.root return binding.root
} }
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -68,11 +136,14 @@ class IncomingCallFragment : GenericCallFragment() {
} }
} }
} }
binding.bottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
callViewModel.refreshKeyguardLockedStatus()
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true) coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true)
} }

View file

@ -20,6 +20,8 @@
package org.linphone.ui.call.viewmodel package org.linphone.ui.call.viewmodel
import android.Manifest import android.Manifest
import android.app.KeyguardManager
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.UiThread import androidx.annotation.UiThread
@ -258,6 +260,18 @@ class CurrentCallViewModel
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
// Sliding answer/decline button
val isScreenLocked = MutableLiveData<Boolean>()
val slidingButtonAboveAnswer = MutableLiveData<Boolean>()
val slidingButtonAboveDecline = MutableLiveData<Boolean>()
val answerAlpha = MutableLiveData<Float>()
val declineAlpha = MutableLiveData<Float>()
lateinit var currentCall: Call lateinit var currentCall: Call
private val contactsListener = object : ContactsListener { private val contactsListener = object : ContactsListener {
@ -517,32 +531,6 @@ class CurrentCallViewModel
} }
} }
@WorkerThread
private fun updateProximitySensor() {
if (::currentCall.isInitialized) {
val callState = currentCall.state
if (LinphoneUtils.isCallIncoming(callState)) {
proximitySensorEnabled.postValue(false)
} else if (LinphoneUtils.isCallOutgoing(callState)) {
val videoEnabled = currentCall.params.isVideoEnabled
proximitySensorEnabled.postValue(!videoEnabled)
} else {
if (isSendingVideo.value == true || isReceivingVideo.value == true) {
proximitySensorEnabled.postValue(false)
} else {
val outputAudioDevice = currentCall.outputAudioDevice ?: coreContext.core.outputAudioDevice
if (outputAudioDevice != null && outputAudioDevice.type == AudioDevice.Type.Earpiece) {
proximitySensorEnabled.postValue(true)
} else {
proximitySensorEnabled.postValue(false)
}
}
}
} else {
proximitySensorEnabled.postValue(false)
}
}
init { init {
fullScreenMode.value = false fullScreenMode.value = false
operationInProgress.value = false operationInProgress.value = false
@ -550,6 +538,10 @@ class CurrentCallViewModel
videoUpdateInProgress.value = false videoUpdateInProgress.value = false
microphoneRecordingVolume.value = 0f microphoneRecordingVolume.value = 0f
refreshKeyguardLockedStatus()
answerAlpha.value = 1f
declineAlpha.value = 1f
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
hideSipAddresses.postValue(corePreferences.hideSipAddresses) hideSipAddresses.postValue(corePreferences.hideSipAddresses)
coreContext.contactsManager.addListener(contactsListener) coreContext.contactsManager.addListener(contactsListener)
@ -613,6 +605,14 @@ class CurrentCallViewModel
} }
} }
@UiThread
fun refreshKeyguardLockedStatus() {
val keyguardManager = coreContext.context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
val secure = keyguardManager.isKeyguardLocked
isScreenLocked.value = secure
Log.i("$TAG Device is [${if (secure) "locked" else "unlocked"}]")
}
@UiThread @UiThread
fun answer() { fun answer() {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
@ -1542,4 +1542,30 @@ class CurrentCallViewModel
if (volume > VU_METER_MAX) return 1f if (volume > VU_METER_MAX) return 1f
return (volume - VU_METER_MIN) / (VU_METER_MAX - VU_METER_MIN) return (volume - VU_METER_MIN) / (VU_METER_MAX - VU_METER_MIN)
} }
@WorkerThread
private fun updateProximitySensor() {
if (::currentCall.isInitialized) {
val callState = currentCall.state
if (LinphoneUtils.isCallIncoming(callState)) {
proximitySensorEnabled.postValue(false)
} else if (LinphoneUtils.isCallOutgoing(callState)) {
val videoEnabled = currentCall.params.isVideoEnabled
proximitySensorEnabled.postValue(!videoEnabled)
} else {
if (isSendingVideo.value == true || isReceivingVideo.value == true) {
proximitySensorEnabled.postValue(false)
} else {
val outputAudioDevice = currentCall.outputAudioDevice ?: coreContext.core.outputAudioDevice
if (outputAudioDevice != null && outputAudioDevice.type == AudioDevice.Type.Earpiece) {
proximitySensorEnabled.postValue(true)
} else {
proximitySensorEnabled.postValue(false)
}
}
}
} else {
proximitySensorEnabled.postValue(false)
}
}
} }

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M5.292,20.292L15.292,10.292C15.385,10.199 15.495,10.125 15.616,10.075C15.738,10.025 15.868,9.999 15.999,9.999C16.131,9.999 16.261,10.025 16.382,10.075C16.504,10.125 16.614,10.199 16.707,10.292L26.707,20.292C26.895,20.48 27,20.734 27,20.999C27,21.265 26.895,21.519 26.707,21.707C26.519,21.895 26.265,22 25.999,22C25.734,22 25.48,21.895 25.292,21.707L15.999,12.413L6.707,21.707C6.614,21.8 6.504,21.874 6.382,21.924C6.261,21.974 6.131,22 5.999,22C5.868,22 5.738,21.974 5.617,21.924C5.495,21.874 5.385,21.8 5.292,21.707C5.199,21.614 5.125,21.504 5.075,21.382C5.025,21.261 4.999,21.131 4.999,20.999C4.999,20.868 5.025,20.738 5.075,20.617C5.125,20.495 5.199,20.385 5.292,20.292Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="15.999"
android:startY="9.999"
android:endX="15.999"
android:endY="22"
android:type="linear">
<item android:offset="0" android:color="#FF85E4A8"/>
<item android:offset="1" android:color="#FF2E3030"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M26.708,11.708L16.708,21.708C16.615,21.801 16.505,21.875 16.383,21.925C16.262,21.975 16.132,22.001 16.001,22.001C15.869,22.001 15.739,21.975 15.618,21.925C15.496,21.875 15.386,21.801 15.293,21.708L5.293,11.708C5.105,11.52 5,11.266 5,11.001C5,10.735 5.105,10.481 5.293,10.293C5.481,10.105 5.735,10 6.001,10C6.266,10 6.52,10.105 6.708,10.293L16.001,19.587L25.293,10.293C25.386,10.2 25.496,10.126 25.618,10.076C25.739,10.026 25.869,10 26.001,10C26.132,10 26.262,10.026 26.383,10.076C26.505,10.126 26.615,10.2 26.708,10.293C26.801,10.386 26.875,10.496 26.925,10.618C26.975,10.739 27.001,10.869 27.001,11.001C27.001,11.132 26.975,11.262 26.925,11.384C26.875,11.505 26.801,11.615 26.708,11.708Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="16.001"
android:startY="22.001"
android:endX="16.001"
android:endY="10"
android:type="linear">
<item android:offset="0" android:color="#FFDD5F5F"/>
<item android:offset="1" android:color="#FF2E3030"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -14,6 +14,146 @@
android:layout_height="@dimen/call_main_actions_menu_height" android:layout_height="@dimen/call_main_actions_menu_height"
android:background="@drawable/shape_call_bottom_sheet_background"> android:background="@drawable/shape_call_bottom_sheet_background">
<androidx.constraintlayout.widget.Group
android:id="@+id/sliding_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{viewModel.isScreenLocked ? View.VISIBLE : View.GONE}"
app:constraint_referenced_ids="sliding_button, decline, answer, arrow_green_1, arrow_green_2, arrow_green_3, arrow_red_1, arrow_red_2, arrow_red_3" />
<androidx.constraintlayout.widget.Group
android:id="@+id/simple_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{viewModel.isScreenLocked ? View.GONE : View.VISIBLE, default=gone}"
app:constraint_referenced_ids="hang_up, answer_call"/>
<ImageView
style="@style/default_text_style_700"
android:id="@+id/decline"
android:layout_width="@dimen/call_button_size"
android:layout_height="@dimen/call_button_size"
android:layout_marginStart="@dimen/sliding_accept_decline_call_margin"
android:padding="@dimen/call_button_icon_padding"
android:src="@drawable/phone_disconnect"
android:alpha="@{viewModel.declineAlpha}"
android:contentDescription="@null"
app:tint="?attr/color_danger_500"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/arrow_red_1"/>
<ImageView
android:id="@+id/arrow_red_1"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:src="@drawable/arrow_red"
android:alpha="@{viewModel.declineAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/decline"
app:layout_constraintBottom_toBottomOf="@id/decline"
app:layout_constraintStart_toEndOf="@id/decline"
app:layout_constraintEnd_toStartOf="@id/arrow_red_2" />
<ImageView
android:id="@+id/arrow_red_2"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:src="@drawable/arrow_red"
android:alpha="@{viewModel.declineAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/decline"
app:layout_constraintBottom_toBottomOf="@id/decline"
app:layout_constraintStart_toEndOf="@id/arrow_red_1"
app:layout_constraintEnd_toStartOf="@id/arrow_red_3" />
<ImageView
android:id="@+id/arrow_red_3"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:src="@drawable/arrow_red"
android:alpha="@{viewModel.declineAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/decline"
app:layout_constraintBottom_toBottomOf="@id/decline"
app:layout_constraintStart_toEndOf="@id/arrow_red_2"
app:layout_constraintEnd_toStartOf="@id/sliding_button"/>
<ImageView
android:id="@+id/arrow_green_1"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:src="@drawable/arrow_green"
android:alpha="@{viewModel.answerAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/answer"
app:layout_constraintBottom_toBottomOf="@id/answer"
app:layout_constraintStart_toEndOf="@id/sliding_button"
app:layout_constraintEnd_toStartOf="@id/arrow_green_2" />
<ImageView
android:id="@+id/arrow_green_2"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:src="@drawable/arrow_green"
android:alpha="@{viewModel.answerAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/answer"
app:layout_constraintBottom_toBottomOf="@id/answer"
app:layout_constraintStart_toEndOf="@id/arrow_green_1"
app:layout_constraintEnd_toStartOf="@id/arrow_green_3" />
<ImageView
android:id="@+id/arrow_green_3"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:src="@drawable/arrow_green"
android:alpha="@{viewModel.answerAlpha}"
android:contentDescription="@null"
android:rotation="90"
app:layout_constraintTop_toTopOf="@id/answer"
app:layout_constraintBottom_toBottomOf="@id/answer"
app:layout_constraintStart_toEndOf="@id/arrow_green_2"
app:layout_constraintEnd_toStartOf="@id/answer" />
<ImageView
style="@style/default_text_style_700"
android:id="@+id/answer"
android:layout_width="@dimen/call_button_size"
android:layout_height="@dimen/call_button_size"
android:layout_marginEnd="@dimen/sliding_accept_decline_call_margin"
android:padding="@dimen/call_button_icon_padding"
android:src="@drawable/phone_call"
android:alpha="@{viewModel.answerAlpha}"
android:contentDescription="@null"
app:tint="?attr/color_success_500"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/arrow_green_3"
app:layout_constraintEnd_toEndOf="parent" />
<ImageView
android:id="@+id/sliding_button"
android:layout_width="@dimen/call_button_size"
android:layout_height="@dimen/call_button_size"
android:padding="@dimen/call_button_icon_padding"
android:background="@drawable/shape_round_in_call_button_background"
android:src="@{viewModel.slidingButtonAboveAnswer ? @drawable/phone_call : viewModel.slidingButtonAboveDecline ? @drawable/phone_disconnect : viewModel.isVideoEnabled ? @drawable/video_camera : @drawable/phone, default=@drawable/phone}"
android:contentDescription="@{viewModel.isVideoEnabled ? @string/content_description_answer_video_call : @string/content_description_answer_audio_call}"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/arrow_red_3"
app:layout_constraintEnd_toStartOf="@id/arrow_green_1"
app:tint="@color/bc_white"
android:backgroundTint="@{viewModel.slidingButtonAboveAnswer ? @color/green_success_500 : viewModel.slidingButtonAboveDecline ? @color/red_danger_500 : @color/gray_500, default=@color/gray_500}"/>
<ImageView <ImageView
android:id="@+id/hang_up" android:id="@+id/hang_up"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -44,32 +184,12 @@
android:paddingTop="15dp" android:paddingTop="15dp"
android:paddingEnd="30dp" android:paddingEnd="30dp"
android:paddingBottom="15dp" android:paddingBottom="15dp"
android:src="@drawable/phone" android:src="@{viewModel.isVideoEnabled ? @drawable/video_camera : @drawable/phone, default=@drawable/phone}"
android:visibility="@{viewModel.isVideoEnabled ? View.GONE : View.VISIBLE}" android:contentDescription="@{viewModel.isVideoEnabled ? @string/content_description_answer_video_call : @string/content_description_answer_audio_call}"
android:contentDescription="@string/content_description_answer_audio_call"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/answer_video_call"
app:layout_constraintStart_toEndOf="@id/hang_up"
app:tint="@color/bc_white" />
<ImageView
android:id="@+id/answer_video_call"
android:layout_width="wrap_content"
android:layout_height="@dimen/call_button_size"
android:background="@drawable/squircle_green_button_background"
android:onClick="@{() -> viewModel.answer()}"
android:paddingStart="30dp"
android:paddingTop="15dp"
android:paddingEnd="30dp"
android:paddingBottom="15dp"
android:src="@drawable/video_camera"
android:visibility="@{viewModel.isVideoEnabled ? View.VISIBLE : View.GONE, default=gone}"
android:contentDescription="@string/content_description_answer_video_call"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/answer_call" app:layout_constraintStart_toEndOf="@id/hang_up"
app:tint="@color/bc_white" /> app:tint="@color/bc_white" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -65,6 +65,7 @@
<dimen name="call_round_corners_texture_view_radius">20dp</dimen> <dimen name="call_round_corners_texture_view_radius">20dp</dimen>
<dimen name="call_pip_round_corners_texture_view_radius">5dp</dimen> <dimen name="call_pip_round_corners_texture_view_radius">5dp</dimen>
<dimen name="call_button_size">55dp</dimen> <dimen name="call_button_size">55dp</dimen>
<dimen name="sliding_accept_decline_call_margin">10dp</dimen>
<dimen name="call_button_icon_padding">15dp</dimen> <dimen name="call_button_icon_padding">15dp</dimen>
<dimen name="call_dtmf_button_size">65dp</dimen> <dimen name="call_dtmf_button_size">65dp</dimen>
<dimen name="call_extra_button_top_margin">30dp</dimen> <dimen name="call_extra_button_top_margin">30dp</dimen>