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 91fdef4428
commit e085b0a725
6 changed files with 309 additions and 49 deletions

View file

@ -19,16 +19,23 @@
*/
package org.linphone.ui.call.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.annotation.UiThread
import androidx.lifecycle.ViewModelProvider
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.CallIncomingFragmentBinding
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
class IncomingCallFragment : GenericCallFragment() {
@ -40,6 +47,66 @@ class IncomingCallFragment : GenericCallFragment() {
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(
inflater: LayoutInflater,
container: ViewGroup?,
@ -49,6 +116,7 @@ class IncomingCallFragment : GenericCallFragment() {
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -68,11 +136,14 @@ class IncomingCallFragment : GenericCallFragment() {
}
}
}
binding.bottomBar.slidingButton.setOnTouchListener(slidingButtonTouchListener)
}
override fun onResume() {
super.onResume()
callViewModel.refreshKeyguardLockedStatus()
coreContext.notificationsManager.setIncomingCallFragmentCurrentlyDisplayed(true)
}

View file

@ -20,6 +20,8 @@
package org.linphone.ui.call.viewmodel
import android.Manifest
import android.app.KeyguardManager
import android.content.Context
import android.content.pm.PackageManager
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
@ -249,6 +251,18 @@ class CurrentCallViewModel
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
private val contactsListener = object : ContactsListener {
@ -508,38 +522,16 @@ 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 {
fullScreenMode.value = false
operationInProgress.value = false
proximitySensorEnabled.value = false
videoUpdateInProgress.value = false
refreshKeyguardLockedStatus()
answerAlpha.value = 1f
declineAlpha.value = 1f
coreContext.postOnCoreThread { core ->
coreContext.contactsManager.addListener(contactsListener)
@ -602,6 +594,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
fun answer() {
coreContext.postOnCoreThread { core ->
@ -1511,4 +1511,30 @@ class CurrentCallViewModel
private fun showRecordingToast() {
showGreenToast(R.string.call_is_being_recorded, R.drawable.record_fill)
}
@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: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
android:id="@+id/hang_up"
android:layout_width="wrap_content"
@ -44,32 +184,12 @@
android:paddingTop="15dp"
android:paddingEnd="30dp"
android:paddingBottom="15dp"
android:src="@drawable/phone"
android:visibility="@{viewModel.isVideoEnabled ? View.GONE : View.VISIBLE}"
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"
android:src="@{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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/answer_call"
app:layout_constraintStart_toEndOf="@id/hang_up"
app:tint="@color/bc_white" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -65,6 +65,7 @@
<dimen name="call_round_corners_texture_view_radius">20dp</dimen>
<dimen name="call_pip_round_corners_texture_view_radius">5dp</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_dtmf_button_size">65dp</dimen>
<dimen name="call_extra_button_top_margin">30dp</dimen>