mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 02:58:45 +00:00
Call quality survey integration.
This commit is contained in:
committed by
jeffrey-signal
parent
804f479cb0
commit
54fb7ff23f
@@ -46,6 +46,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||||
@@ -92,6 +93,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
|||||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||||
|
import org.thoughtcrime.securesms.calls.quality.CallQuality
|
||||||
|
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
|
||||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
|
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
|
||||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
||||||
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
|
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
|
||||||
@@ -130,6 +133,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
|||||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||||
|
import org.thoughtcrime.securesms.main.MainSnackbar
|
||||||
import org.thoughtcrime.securesms.main.MainToolbar
|
import org.thoughtcrime.securesms.main.MainToolbar
|
||||||
import org.thoughtcrime.securesms.main.MainToolbarCallback
|
import org.thoughtcrime.securesms.main.MainToolbarCallback
|
||||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||||
@@ -306,6 +310,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportFragmentManager.setFragmentResultListener(
|
||||||
|
CallQualityBottomSheetFragment.REQUEST_KEY,
|
||||||
|
this
|
||||||
|
) { _, bundle ->
|
||||||
|
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
|
||||||
|
mainNavigationViewModel.setSnackbar(
|
||||||
|
SnackbarState(
|
||||||
|
message = getString(R.string.CallQualitySheet__thanks_for_your_feedback),
|
||||||
|
duration = SnackbarDuration.Short
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
|
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -526,6 +544,15 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
|||||||
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
|
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
|
||||||
paneExpansionState = paneExpansionState,
|
paneExpansionState = paneExpansionState,
|
||||||
contentWindowInsets = WindowInsets(),
|
contentWindowInsets = WindowInsets(),
|
||||||
|
snackbarHost = {
|
||||||
|
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) {
|
||||||
|
MainSnackbar(
|
||||||
|
snackbarState = snackbar,
|
||||||
|
onDismissed = mainBottomChromeCallback::onSnackbarDismissed,
|
||||||
|
modifier = Modifier.navigationBarsPadding()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
bottomNavContent = {
|
bottomNavContent = {
|
||||||
if (isNavigationBarVisible) {
|
if (isNavigationBarVisible) {
|
||||||
Column(
|
Column(
|
||||||
@@ -827,6 +854,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
|||||||
|
|
||||||
vitalsViewModel.checkSlowNotificationHeuristics()
|
vitalsViewModel.checkSlowNotificationHeuristics()
|
||||||
mainNavigationViewModel.refreshNavigationBarState()
|
mainNavigationViewModel.refreshNavigationBarState()
|
||||||
|
|
||||||
|
CallQuality.consumeQualityRequest()?.let {
|
||||||
|
CallQualityBottomSheetFragment.create(it).show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.securesms.calls.quality
|
||||||
|
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import org.signal.ringrtc.CallSummary
|
||||||
|
import org.signal.ringrtc.GroupCall
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper object for dealing with call quality ux
|
||||||
|
*/
|
||||||
|
object CallQuality {
|
||||||
|
|
||||||
|
private val errors = listOf(
|
||||||
|
"internalFailure",
|
||||||
|
"signalingFailure",
|
||||||
|
"connectionFailure",
|
||||||
|
"iceFailedAfterConnected"
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun handleOneToOneCallSummary(callSummary: CallSummary, isVideoCall: Boolean) {
|
||||||
|
val callType = if (isVideoCall) CallType.DIRECT_VIDEO else CallType.DIRECT_VOICE
|
||||||
|
|
||||||
|
handleCallSummary(callSummary, callType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun handleGroupCallSummary(callSummary: CallSummary, kind: GroupCall.Kind) {
|
||||||
|
val callType = when (kind) {
|
||||||
|
GroupCall.Kind.SIGNAL_GROUP -> CallType.GROUP
|
||||||
|
GroupCall.Kind.CALL_LINK -> CallType.CALL_LINK
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallSummary(callSummary, callType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
private fun handleCallSummary(callSummary: CallSummary, callType: CallType) {
|
||||||
|
if (isCallQualitySurveyRequired(callSummary)) {
|
||||||
|
SignalStore.callQuality.surveyRequest = SubmitCallQualitySurveyRequest.Builder()
|
||||||
|
.call_type(callType.code)
|
||||||
|
.start_timestamp(callSummary.startTime)
|
||||||
|
.end_timestamp(callSummary.endTime)
|
||||||
|
.success(isSuccess(callSummary))
|
||||||
|
.call_end_reason(callSummary.callEndReasonText)
|
||||||
|
.connection_rtt_median(callSummary.qualityStats.rttMedianConnectionMillis)
|
||||||
|
.audio_rtt_median(callSummary.qualityStats.audioStats.rttMedianMillis)
|
||||||
|
.video_rtt_median(callSummary.qualityStats.videoStats.rttMedianMillis)
|
||||||
|
.audio_recv_jitter_median(callSummary.qualityStats.audioStats.jitterMedianRecvMillis)
|
||||||
|
.video_recv_jitter_median(callSummary.qualityStats.videoStats.jitterMedianRecvMillis)
|
||||||
|
.audio_send_jitter_median(callSummary.qualityStats.audioStats.jitterMedianSendMillis)
|
||||||
|
.video_send_jitter_median(callSummary.qualityStats.videoStats.jitterMedianSendMillis)
|
||||||
|
.audio_send_packet_loss_fraction(callSummary.qualityStats.audioStats.packetLossPercentageSend)
|
||||||
|
.video_send_packet_loss_fraction(callSummary.qualityStats.videoStats.packetLossPercentageSend)
|
||||||
|
.audio_recv_packet_loss_fraction(callSummary.qualityStats.audioStats.packetLossPercentageRecv)
|
||||||
|
.video_recv_packet_loss_fraction(callSummary.qualityStats.videoStats.packetLossPercentageRecv)
|
||||||
|
.call_telemetry(callSummary.rawStats?.toByteString())
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
SignalStore.callQuality.surveyRequest = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeQualityRequest(): SubmitCallQualitySurveyRequest? {
|
||||||
|
val request = SignalStore.callQuality.surveyRequest
|
||||||
|
SignalStore.callQuality.surveyRequest = null
|
||||||
|
return if (isFeatureEnabled()) request else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCallQualitySurveyRequired(callSummary: CallSummary): Boolean {
|
||||||
|
if (!isFeatureEnabled() || !callSummary.isSurveyCandidate) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSuccess = isSuccess(callSummary)
|
||||||
|
val now = System.currentTimeMillis().milliseconds
|
||||||
|
val lastFailure = SignalStore.callQuality.lastFailureReportTime ?: 0.milliseconds
|
||||||
|
val failureDelta = now - lastFailure
|
||||||
|
|
||||||
|
if (!isSuccess && (failureDelta < 1.days)) {
|
||||||
|
SignalStore.callQuality.lastFailureReportTime = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
val lastSurveyPromptTime = SignalStore.callQuality.lastSurveyPromptTime ?: 0.milliseconds
|
||||||
|
val lastSurveyPromptDelta = now - lastSurveyPromptTime
|
||||||
|
val lastPromptWasTooRecent = lastSurveyPromptDelta < 1.days
|
||||||
|
|
||||||
|
if (lastPromptWasTooRecent) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val callLength = callSummary.endTime.milliseconds - callSummary.startTime.milliseconds
|
||||||
|
val isLongerThanTenMinutes = callLength > 10.minutes
|
||||||
|
val isLessThanOneMinute = callLength < 1.minutes
|
||||||
|
|
||||||
|
if (isLongerThanTenMinutes || isLessThanOneMinute) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val chance = RemoteConfig.callQualitySurveyPercent
|
||||||
|
val roll = (0 until 100).random()
|
||||||
|
|
||||||
|
if (roll < chance) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSuccess(callSummary: CallSummary): Boolean {
|
||||||
|
return callSummary.callEndReasonText !in errors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isFeatureEnabled(): Boolean {
|
||||||
|
return (RemoteConfig.callQualitySurvey || SignalStore.internal.callQualitySurveys) && SignalStore.callQuality.isQualitySurveyEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class CallType(val code: String) {
|
||||||
|
// "direct_voice", "direct_video", "group", and "call_link".
|
||||||
|
DIRECT_VOICE("direct_voice"),
|
||||||
|
DIRECT_VIDEO("direct_video"),
|
||||||
|
GROUP("group"),
|
||||||
|
CALL_LINK("call_link")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,12 @@ import androidx.core.os.bundleOf
|
|||||||
import androidx.fragment.app.setFragmentResult
|
import androidx.fragment.app.setFragmentResult
|
||||||
import androidx.fragment.app.setFragmentResultListener
|
import androidx.fragment.app.setFragmentResultListener
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
|
||||||
import org.thoughtcrime.securesms.util.viewModel
|
import org.thoughtcrime.securesms.util.viewModel
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fragment which manages sheets for walking the user through collecting call
|
* Fragment which manages sheets for walking the user through collecting call
|
||||||
@@ -27,14 +30,23 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey"
|
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey"
|
||||||
|
|
||||||
|
fun create(request: SubmitCallQualitySurveyRequest): CallQualityBottomSheetFragment {
|
||||||
|
return CallQualityBottomSheetFragment().apply {
|
||||||
|
arguments = bundleOf(REQUEST_KEY to request.encode())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel: CallQualityScreenViewModel by viewModel {
|
private val viewModel: CallQualityScreenViewModel by viewModel {
|
||||||
CallQualityScreenViewModel()
|
val bytes = requireArguments().getByteArray(REQUEST_KEY)!!
|
||||||
|
|
||||||
|
CallQualityScreenViewModel(SubmitCallQualitySurveyRequest.ADAPTER.decode(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { key, bundle ->
|
SignalStore.callQuality.lastSurveyPromptTime = System.currentTimeMillis().milliseconds
|
||||||
|
setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { _, bundle ->
|
||||||
val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
|
val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
|
||||||
|
|
||||||
viewModel.onSomethingElseDescriptionChanged(result)
|
viewModel.onSomethingElseDescriptionChanged(result)
|
||||||
@@ -64,6 +76,10 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) {
|
||||||
|
viewModel.setUserSatisfiedWithCall(isUserSatisfiedWithCall)
|
||||||
|
}
|
||||||
|
|
||||||
override fun describeYourIssue() {
|
override fun describeYourIssue() {
|
||||||
CallQualitySomethingElseFragment.create(
|
CallQualitySomethingElseFragment.create(
|
||||||
viewModel.state.value.somethingElseDescription
|
viewModel.state.value.somethingElseDescription
|
||||||
|
|||||||
@@ -9,12 +9,21 @@ import androidx.lifecycle.ViewModel
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
|
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||||
|
import org.thoughtcrime.securesms.jobs.CallQualitySurveySubmissionJob
|
||||||
|
|
||||||
class CallQualityScreenViewModel : ViewModel() {
|
class CallQualityScreenViewModel(
|
||||||
|
val initialRequest: SubmitCallQualitySurveyRequest
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internalState = MutableStateFlow(CallQualitySheetState())
|
private val internalState = MutableStateFlow(CallQualitySheetState())
|
||||||
val state: StateFlow<CallQualitySheetState> = internalState
|
val state: StateFlow<CallQualitySheetState> = internalState
|
||||||
|
|
||||||
|
fun setUserSatisfiedWithCall(userSatisfiedWithCall: Boolean) {
|
||||||
|
internalState.update { it.copy(isUserSatisfiedWithCall = userSatisfiedWithCall) }
|
||||||
|
}
|
||||||
|
|
||||||
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
|
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
|
||||||
internalState.update { it.copy(selectedQualityIssues = selection) }
|
internalState.update { it.copy(selectedQualityIssues = selection) }
|
||||||
}
|
}
|
||||||
@@ -28,6 +37,19 @@ class CallQualityScreenViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
// Enqueue job.
|
val stateSnapshot = state.value
|
||||||
|
val somethingElseDescription: String? = if (stateSnapshot.selectedQualityIssues.contains(CallQualityIssue.SOMETHING_ELSE)) {
|
||||||
|
stateSnapshot.somethingElseDescription.takeIf { it.isNotEmpty() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestToSubmitToJob = initialRequest.newBuilder()
|
||||||
|
.user_satisfied(stateSnapshot.isUserSatisfiedWithCall)
|
||||||
|
.call_quality_issues(stateSnapshot.selectedQualityIssues.map { it.code })
|
||||||
|
.additional_issues_description(somethingElseDescription)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
AppDependencies.jobManager.add(CallQualitySurveySubmissionJob(requestToSubmitToJob, stateSnapshot.isShareDebugLogSelected))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ package org.thoughtcrime.securesms.calls.quality
|
|||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -73,9 +80,11 @@ fun CallQualitySheet(
|
|||||||
when (navEntry) {
|
when (navEntry) {
|
||||||
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
|
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
|
||||||
onGreatClick = {
|
onGreatClick = {
|
||||||
|
callback.onUserSatisfiedWithCall(true)
|
||||||
navEntry = CallQualitySheetNavEntry.HelpUsImprove
|
navEntry = CallQualitySheetNavEntry.HelpUsImprove
|
||||||
},
|
},
|
||||||
onHadIssuesClick = {
|
onHadIssuesClick = {
|
||||||
|
callback.onUserSatisfiedWithCall(true)
|
||||||
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
|
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
|
||||||
},
|
},
|
||||||
onCancelClick = callback::dismiss
|
onCancelClick = callback::dismiss
|
||||||
@@ -146,7 +155,6 @@ private fun WhatIssuesDidYouHave(
|
|||||||
SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have))
|
SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have))
|
||||||
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply))
|
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply))
|
||||||
|
|
||||||
val qualityIssueDisplaySet = rememberQualityDisplaySet(selectedQualityIssues)
|
|
||||||
val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) {
|
val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) {
|
||||||
{ issue ->
|
{ issue ->
|
||||||
val isRemoving = issue in selectedQualityIssues
|
val isRemoving = issue in selectedQualityIssues
|
||||||
@@ -172,6 +180,9 @@ private fun WhatIssuesDidYouHave(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isAudioExpanded = CallQualityIssue.AUDIO_ISSUE in selectedQualityIssues
|
||||||
|
val isVideoExpanded = CallQualityIssue.VIDEO_ISSUE in selectedQualityIssues
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateContentSize()
|
.animateContentSize()
|
||||||
@@ -179,104 +190,206 @@ private fun WhatIssuesDidYouHave(
|
|||||||
.horizontalGutters(),
|
.horizontalGutters(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
qualityIssueDisplaySet.forEach { issue ->
|
IssueChip(
|
||||||
val isIssueSelected = issue in selectedQualityIssues
|
issue = CallQualityIssue.AUDIO_ISSUE,
|
||||||
|
isSelected = isAudioExpanded,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_ISSUE) }
|
||||||
|
)
|
||||||
|
|
||||||
InputChip(
|
AnimatedIssueChip(
|
||||||
selected = isIssueSelected,
|
visible = isAudioExpanded,
|
||||||
onClick = {
|
issue = CallQualityIssue.AUDIO_STUTTERING,
|
||||||
onCallQualityIssueClick(issue)
|
isSelected = CallQualityIssue.AUDIO_STUTTERING in selectedQualityIssues,
|
||||||
},
|
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_STUTTERING) }
|
||||||
colors = InputChipDefaults.inputChipColors(
|
)
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
|
||||||
selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
AnimatedIssueChip(
|
||||||
labelColor = MaterialTheme.colorScheme.onSurface
|
visible = isAudioExpanded,
|
||||||
),
|
issue = CallQualityIssue.AUDIO_CUT_OUT,
|
||||||
leadingIcon = {
|
isSelected = CallQualityIssue.AUDIO_CUT_OUT in selectedQualityIssues,
|
||||||
Icon(
|
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_CUT_OUT) }
|
||||||
imageVector = if (isIssueSelected) {
|
)
|
||||||
ImageVector.vectorResource(R.drawable.symbol_check_24)
|
|
||||||
} else {
|
AnimatedIssueChip(
|
||||||
ImageVector.vectorResource(issue.category.icon)
|
visible = isAudioExpanded,
|
||||||
},
|
issue = CallQualityIssue.AUDIO_I_HEARD_ECHO,
|
||||||
contentDescription = null
|
isSelected = CallQualityIssue.AUDIO_I_HEARD_ECHO in selectedQualityIssues,
|
||||||
)
|
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_I_HEARD_ECHO) }
|
||||||
},
|
)
|
||||||
label = {
|
|
||||||
Text(text = stringResource(issue.label))
|
AnimatedIssueChip(
|
||||||
},
|
visible = isAudioExpanded,
|
||||||
modifier = Modifier.padding(horizontal = 4.dp)
|
issue = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO,
|
||||||
)
|
isSelected = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) }
|
||||||
|
)
|
||||||
|
|
||||||
|
IssueChip(
|
||||||
|
issue = CallQualityIssue.VIDEO_ISSUE,
|
||||||
|
isSelected = isVideoExpanded,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_ISSUE) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedIssueChip(
|
||||||
|
visible = isVideoExpanded,
|
||||||
|
issue = CallQualityIssue.VIDEO_POOR_QUALITY,
|
||||||
|
isSelected = CallQualityIssue.VIDEO_POOR_QUALITY in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_POOR_QUALITY) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedIssueChip(
|
||||||
|
visible = isVideoExpanded,
|
||||||
|
issue = CallQualityIssue.VIDEO_LOW_RESOLUTION,
|
||||||
|
isSelected = CallQualityIssue.VIDEO_LOW_RESOLUTION in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_LOW_RESOLUTION) }
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedIssueChip(
|
||||||
|
visible = isVideoExpanded,
|
||||||
|
issue = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION,
|
||||||
|
isSelected = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) }
|
||||||
|
)
|
||||||
|
|
||||||
|
IssueChip(
|
||||||
|
issue = CallQualityIssue.CALL_DROPPED,
|
||||||
|
isSelected = CallQualityIssue.CALL_DROPPED in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.CALL_DROPPED) }
|
||||||
|
)
|
||||||
|
|
||||||
|
IssueChip(
|
||||||
|
issue = CallQualityIssue.SOMETHING_ELSE,
|
||||||
|
isSelected = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues,
|
||||||
|
onClick = { onCallQualityIssueClick(CallQualityIssue.SOMETHING_ELSE) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
val text = somethingElseDescription.ifEmpty {
|
||||||
|
stringResource(R.string.CallQualitySheet__describe_your_issue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues) {
|
val textColor = if (somethingElseDescription.isNotEmpty()) {
|
||||||
val text = somethingElseDescription.ifEmpty {
|
MaterialTheme.colorScheme.onSurface
|
||||||
stringResource(R.string.CallQualitySheet__describe_your_issue)
|
} else {
|
||||||
}
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
val textColor = if (somethingElseDescription.isNotEmpty()) {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
|
||||||
|
|
||||||
val textUnderlineStrokeWidthPx = with(LocalDensity.current) {
|
|
||||||
1.dp.toPx()
|
|
||||||
}
|
|
||||||
|
|
||||||
val textUnderlineColor = MaterialTheme.colorScheme.outline
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
color = textColor,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
role = Role.Button,
|
|
||||||
onClick = onDescribeYourIssueClick
|
|
||||||
)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 24.dp)
|
|
||||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
|
|
||||||
val width = size.width
|
|
||||||
val height = size.height - textUnderlineStrokeWidthPx / 2f
|
|
||||||
|
|
||||||
drawLine(
|
|
||||||
color = textUnderlineColor,
|
|
||||||
start = Offset(x = 0f, y = height),
|
|
||||||
end = Offset(x = width, y = height),
|
|
||||||
strokeWidth = textUnderlineStrokeWidthPx
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
val textUnderlineStrokeWidthPx = with(LocalDensity.current) {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
1.dp.toPx()
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
}
|
||||||
|
|
||||||
|
val textUnderlineColor = MaterialTheme.colorScheme.outline
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
role = Role.Button,
|
||||||
|
onClick = onDescribeYourIssueClick
|
||||||
|
)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 32.dp, bottom = 24.dp)
|
.horizontalGutters()
|
||||||
) {
|
.padding(top = 24.dp)
|
||||||
CancelButton(
|
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||||
onClick = onCancelClick
|
.drawWithContent {
|
||||||
)
|
drawContent()
|
||||||
|
|
||||||
Buttons.LargeTonal(
|
val width = size.width
|
||||||
onClick = onContinueClick
|
val height = size.height - textUnderlineStrokeWidthPx / 2f
|
||||||
) {
|
|
||||||
Text(text = stringResource(R.string.CallQualitySheet__continue))
|
drawLine(
|
||||||
}
|
color = textUnderlineColor,
|
||||||
|
start = Offset(x = 0f, y = height),
|
||||||
|
end = Offset(x = width, y = height),
|
||||||
|
strokeWidth = textUnderlineStrokeWidthPx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons - outside FlowRow, stable position
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalGutters()
|
||||||
|
.padding(top = 32.dp, bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
CancelButton(
|
||||||
|
onClick = onCancelClick
|
||||||
|
)
|
||||||
|
|
||||||
|
Buttons.LargeTonal(
|
||||||
|
onClick = onContinueClick
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.CallQualitySheet__continue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IssueChip(
|
||||||
|
issue: CallQualityIssue,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
InputChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = onClick,
|
||||||
|
colors = InputChipDefaults.inputChipColors(
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
labelColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isSelected) {
|
||||||
|
ImageVector.vectorResource(R.drawable.symbol_check_24)
|
||||||
|
} else {
|
||||||
|
ImageVector.vectorResource(issue.category.icon)
|
||||||
|
},
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(text = stringResource(issue.label))
|
||||||
|
},
|
||||||
|
modifier = modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnimatedIssueChip(
|
||||||
|
visible: Boolean,
|
||||||
|
issue: CallQualityIssue,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + expandHorizontally(),
|
||||||
|
exit = fadeOut() + shrinkHorizontally()
|
||||||
|
) {
|
||||||
|
IssueChip(
|
||||||
|
issue = issue,
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun HelpUsImprove(
|
private fun HelpUsImprove(
|
||||||
@@ -549,33 +662,8 @@ private fun SomethingElseContentPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberQualityDisplaySet(userSelection: Set<CallQualityIssue>): Set<CallQualityIssue> {
|
|
||||||
return remember(userSelection) {
|
|
||||||
val displaySet = mutableSetOf<CallQualityIssue>()
|
|
||||||
displaySet.add(CallQualityIssue.AUDIO_ISSUE)
|
|
||||||
if (CallQualityIssue.AUDIO_ISSUE in userSelection) {
|
|
||||||
displaySet.add(CallQualityIssue.AUDIO_STUTTERING)
|
|
||||||
displaySet.add(CallQualityIssue.AUDIO_CUT_OUT)
|
|
||||||
displaySet.add(CallQualityIssue.AUDIO_I_HEARD_ECHO)
|
|
||||||
displaySet.add(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO)
|
|
||||||
}
|
|
||||||
|
|
||||||
displaySet.add(CallQualityIssue.VIDEO_ISSUE)
|
|
||||||
if (CallQualityIssue.VIDEO_ISSUE in userSelection) {
|
|
||||||
displaySet.add(CallQualityIssue.VIDEO_POOR_QUALITY)
|
|
||||||
displaySet.add(CallQualityIssue.VIDEO_LOW_RESOLUTION)
|
|
||||||
displaySet.add(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION)
|
|
||||||
}
|
|
||||||
|
|
||||||
displaySet.add(CallQualityIssue.CALL_DROPPED)
|
|
||||||
displaySet.add(CallQualityIssue.SOMETHING_ELSE)
|
|
||||||
|
|
||||||
displaySet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CallQualitySheetState(
|
data class CallQualitySheetState(
|
||||||
|
val isUserSatisfiedWithCall: Boolean = false,
|
||||||
val selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
|
val selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
|
||||||
val somethingElseDescription: String = "",
|
val somethingElseDescription: String = "",
|
||||||
val isShareDebugLogSelected: Boolean = false
|
val isShareDebugLogSelected: Boolean = false
|
||||||
@@ -584,6 +672,7 @@ data class CallQualitySheetState(
|
|||||||
interface CallQualitySheetCallback {
|
interface CallQualitySheetCallback {
|
||||||
fun dismiss()
|
fun dismiss()
|
||||||
fun viewDebugLog()
|
fun viewDebugLog()
|
||||||
|
fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean)
|
||||||
fun describeYourIssue()
|
fun describeYourIssue()
|
||||||
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
|
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
|
||||||
fun onShareDebugLogChanged(shareDebugLog: Boolean)
|
fun onShareDebugLogChanged(shareDebugLog: Boolean)
|
||||||
@@ -592,6 +681,7 @@ interface CallQualitySheetCallback {
|
|||||||
object Empty : CallQualitySheetCallback {
|
object Empty : CallQualitySheetCallback {
|
||||||
override fun dismiss() = Unit
|
override fun dismiss() = Unit
|
||||||
override fun viewDebugLog() = Unit
|
override fun viewDebugLog() = Unit
|
||||||
|
override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) = Unit
|
||||||
override fun describeYourIssue() = Unit
|
override fun describeYourIssue() = Unit
|
||||||
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
|
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
|
||||||
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
|
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
|
||||||
@@ -615,18 +705,19 @@ enum class CallQualityIssueCategory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class CallQualityIssue(
|
enum class CallQualityIssue(
|
||||||
|
val code: String,
|
||||||
val category: CallQualityIssueCategory,
|
val category: CallQualityIssueCategory,
|
||||||
@param:StringRes val label: Int
|
@param:StringRes val label: Int
|
||||||
) {
|
) {
|
||||||
AUDIO_ISSUE(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue),
|
AUDIO_ISSUE(code = "audio", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue),
|
||||||
AUDIO_STUTTERING(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering),
|
AUDIO_STUTTERING(code = "audio_stuttering", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering),
|
||||||
AUDIO_CUT_OUT(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out),
|
AUDIO_CUT_OUT(code = "audio_drop", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out),
|
||||||
AUDIO_I_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo),
|
AUDIO_I_HEARD_ECHO(code = "audio_remote_echo", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo),
|
||||||
AUDIO_OTHERS_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo),
|
AUDIO_OTHERS_HEARD_ECHO(code = "audio_local_echo", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo),
|
||||||
VIDEO_ISSUE(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue),
|
VIDEO_ISSUE(code = "video", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue),
|
||||||
VIDEO_POOR_QUALITY(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality),
|
VIDEO_POOR_QUALITY(code = "video_low_quality", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality),
|
||||||
VIDEO_LOW_RESOLUTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution),
|
VIDEO_LOW_RESOLUTION(code = "video_low_resolution", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution),
|
||||||
VIDEO_CAMERA_MALFUNCTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work),
|
VIDEO_CAMERA_MALFUNCTION(code = "video_no_camera", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work),
|
||||||
CALL_DROPPED(category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped),
|
CALL_DROPPED(code = "call_dropped", category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped),
|
||||||
SOMETHING_ELSE(category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else)
|
SOMETHING_ELSE(code = "other", category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,10 +580,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
clickPref(
|
switchPref(
|
||||||
title = DSLSettingsText.from("Display Call Quality Survey UX"),
|
title = DSLSettingsText.from("Enable call quality surveys"),
|
||||||
|
isChecked = state.callQualitySurveys,
|
||||||
onClick = {
|
onClick = {
|
||||||
CallQualityBottomSheetFragment().show(parentFragmentManager, null)
|
viewModel.setEnableCallQualitySurveys(!state.callQualitySurveys)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ data class InternalSettingsState(
|
|||||||
val hasPendingOneTimeDonation: Boolean,
|
val hasPendingOneTimeDonation: Boolean,
|
||||||
val hevcEncoding: Boolean,
|
val hevcEncoding: Boolean,
|
||||||
val newCallingUi: Boolean,
|
val newCallingUi: Boolean,
|
||||||
|
val callQualitySurveys: Boolean,
|
||||||
val forceSplitPane: Boolean
|
val forceSplitPane: Boolean
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
|||||||
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
|
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
|
||||||
hevcEncoding = SignalStore.internal.hevcEncoding,
|
hevcEncoding = SignalStore.internal.hevcEncoding,
|
||||||
newCallingUi = SignalStore.internal.newCallingUi,
|
newCallingUi = SignalStore.internal.newCallingUi,
|
||||||
|
callQualitySurveys = SignalStore.internal.callQualitySurveys,
|
||||||
forceSplitPane = SignalStore.internal.forceSplitPane
|
forceSplitPane = SignalStore.internal.forceSplitPane
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,6 +214,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
|||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setEnableCallQualitySurveys(enabled: Boolean) {
|
||||||
|
SignalStore.internal.callQualitySurveys = enabled
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
fun setForceSplitPane(forceSplitPane: Boolean) {
|
fun setForceSplitPane(forceSplitPane: Boolean) {
|
||||||
SignalStore.internal.forceSplitPane = forceSplitPane
|
SignalStore.internal.forceSplitPane = forceSplitPane
|
||||||
refresh()
|
refresh()
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ object AppDependencies {
|
|||||||
fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.UnauthenticatedWebSocket
|
fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.UnauthenticatedWebSocket
|
||||||
fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi
|
fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi
|
||||||
fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi
|
fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi
|
||||||
fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi
|
fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi
|
||||||
fun providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi
|
fun providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi
|
||||||
fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi
|
fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi
|
||||||
fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi
|
fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi
|
||||||
|
|||||||
@@ -525,8 +525,8 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull PushServiceSocket pushServiceSocket) {
|
public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket) {
|
||||||
return new CallingApi(authWebSocket, pushServiceSocket);
|
return new CallingApi(authWebSocket, unauthWebSocket, pushServiceSocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ class NetworkDependenciesModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val callingApi: CallingApi by lazy {
|
val callingApi: CallingApi by lazy {
|
||||||
provider.provideCallingApi(authWebSocket, pushServiceSocket)
|
provider.provideCallingApi(authWebSocket, unauthWebSocket, pushServiceSocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
val paymentsApi: PaymentsApi by lazy {
|
val paymentsApi: PaymentsApi by lazy {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.securesms.jobs
|
||||||
|
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
|
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||||
|
import org.thoughtcrime.securesms.jobs.protos.CallQualitySurveySubmissionJobData
|
||||||
|
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||||
|
import org.whispersystems.signalservice.api.NetworkResult
|
||||||
|
import kotlin.jvm.optionals.getOrNull
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job which will upload call quality survey data after submission by user.
|
||||||
|
*/
|
||||||
|
class CallQualitySurveySubmissionJob private constructor(
|
||||||
|
private var request: SubmitCallQualitySurveyRequest,
|
||||||
|
private val includeDebugLogs: Boolean,
|
||||||
|
parameters: Parameters
|
||||||
|
) : Job(parameters) {
|
||||||
|
|
||||||
|
constructor(request: SubmitCallQualitySurveyRequest, includeDebugLogs: Boolean) : this(
|
||||||
|
request,
|
||||||
|
includeDebugLogs,
|
||||||
|
Parameters.Builder()
|
||||||
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
|
.setMaxInstancesForFactory(1)
|
||||||
|
.setLifespan(6.hours.inWholeMilliseconds)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(CallQualitySurveySubmissionJob::class)
|
||||||
|
|
||||||
|
const val KEY = "CallQualitySurveySubmission"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun serialize(): ByteArray {
|
||||||
|
return CallQualitySurveySubmissionJobData(
|
||||||
|
request = request,
|
||||||
|
includeDebugLogs = includeDebugLogs
|
||||||
|
).encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFactoryKey(): String = KEY
|
||||||
|
|
||||||
|
override fun run(): Result {
|
||||||
|
Log.i(TAG, "Starting call quality survey submission.")
|
||||||
|
|
||||||
|
val request = handleRequestDebugLogs(request)
|
||||||
|
|
||||||
|
Log.i(TAG, "Sending survey data to server.")
|
||||||
|
|
||||||
|
return submitRequestData(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure() = Unit
|
||||||
|
|
||||||
|
private fun handleRequestDebugLogs(request: SubmitCallQualitySurveyRequest): SubmitCallQualitySurveyRequest {
|
||||||
|
return if (includeDebugLogs) {
|
||||||
|
Log.i(TAG, "Including debug logs.")
|
||||||
|
|
||||||
|
if (request.debug_log_url != null) {
|
||||||
|
Log.i(TAG, "Already included debug logs.")
|
||||||
|
request
|
||||||
|
} else {
|
||||||
|
uploadDebugLogs(request)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Skipping debug logs.")
|
||||||
|
request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadDebugLogs(request: SubmitCallQualitySurveyRequest): SubmitCallQualitySurveyRequest {
|
||||||
|
val repository = SubmitDebugLogRepository()
|
||||||
|
val url = repository.buildAndSubmitLogSync(request.end_timestamp).getOrNull()
|
||||||
|
|
||||||
|
if (url == null) {
|
||||||
|
Log.w(TAG, "Failed to upload debug log. Proceeding with survey capture.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.newBuilder().debug_log_url(url).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitRequestData(request: SubmitCallQualitySurveyRequest): Result {
|
||||||
|
return when (val networkResult = AppDependencies.callingApi.submitCallQualitySurvey(request)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
Log.i(TAG, "Successfully submitted survey.")
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
is NetworkResult.ApplicationError -> {
|
||||||
|
Log.w(TAG, "Failed to submit due to an application error.", networkResult.getCause())
|
||||||
|
Result.failure()
|
||||||
|
}
|
||||||
|
is NetworkResult.NetworkError -> {
|
||||||
|
Log.w(TAG, "Failed to submit due to a network error. Scheduling a retry.", networkResult.getCause())
|
||||||
|
Result.retry(defaultBackoff())
|
||||||
|
}
|
||||||
|
is NetworkResult.StatusCodeError -> {
|
||||||
|
when (networkResult.code) {
|
||||||
|
429 -> {
|
||||||
|
val retryIn: Long = networkResult.header("Retry-After")?.toLongOrNull()?.let { it * 1000 } ?: defaultBackoff()
|
||||||
|
Log.w(TAG, "Failed to submit survey due to rate limit, status code ${networkResult.code} retrying in ${retryIn}ms", networkResult.getCause())
|
||||||
|
Result.retry(retryIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "Failed to submit survey, status code ${networkResult.code}", networkResult.getCause())
|
||||||
|
Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory : Job.Factory<CallQualitySurveySubmissionJob> {
|
||||||
|
override fun create(parameters: Parameters, serializedData: ByteArray?): CallQualitySurveySubmissionJob {
|
||||||
|
val jobData = CallQualitySurveySubmissionJobData.ADAPTER.decode(serializedData!!)
|
||||||
|
|
||||||
|
return CallQualitySurveySubmissionJob(jobData.request!!, jobData.includeDebugLogs, parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,6 +151,7 @@ public final class JobManagerFactories {
|
|||||||
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
|
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
|
||||||
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());
|
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());
|
||||||
put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory());
|
put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory());
|
||||||
|
put(CallQualitySurveySubmissionJob.KEY, new CallQualitySurveySubmissionJob.Factory());
|
||||||
put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory());
|
put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory());
|
||||||
put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory());
|
put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory());
|
||||||
put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.Factory());
|
put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.Factory());
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.thoughtcrime.securesms.keyvalue
|
||||||
|
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class CallQualityValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SURVEY_REQUEST = "callQualityValues.survey_request"
|
||||||
|
const val IS_CALL_QUALITY_SURVEY_ENABLED = "callQualityValues.is_call_quality_survey_enabled"
|
||||||
|
const val LAST_FAILURE_REPORT_TIME = "callQualityValues.last_failure_report_time"
|
||||||
|
const val LAST_SURVEY_PROMPT_TIME = "callQualityValues.last_survey_prompt_time"
|
||||||
|
}
|
||||||
|
|
||||||
|
var surveyRequest: SubmitCallQualitySurveyRequest? by protoValue(SURVEY_REQUEST, SubmitCallQualitySurveyRequest.ADAPTER)
|
||||||
|
var isQualitySurveyEnabled: Boolean by booleanValue(IS_CALL_QUALITY_SURVEY_ENABLED, true)
|
||||||
|
var lastFailureReportTime: Duration? by durationValue(LAST_FAILURE_REPORT_TIME, null)
|
||||||
|
var lastSurveyPromptTime: Duration? by durationValue(LAST_SURVEY_PROMPT_TIME, null)
|
||||||
|
|
||||||
|
public override fun onFirstEverAppLaunch() = Unit
|
||||||
|
|
||||||
|
public override fun getKeysToIncludeInBackup(): List<String> = emptyList()
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
|
|||||||
const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats"
|
const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats"
|
||||||
const val ENCODE_HEVC: String = "internal.hevc_encoding"
|
const val ENCODE_HEVC: String = "internal.hevc_encoding"
|
||||||
const val NEW_CALL_UI: String = "internal.new.call.ui"
|
const val NEW_CALL_UI: String = "internal.new.call.ui"
|
||||||
|
const val CALL_QUALITY_SURVEYS: String = "internal.call_quality_surveys"
|
||||||
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
|
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
|
||||||
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
|
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
|
||||||
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
|
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
|
||||||
@@ -170,6 +171,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
|
|||||||
|
|
||||||
var newCallingUi: Boolean by booleanValue(NEW_CALL_UI, false).defaultForExternalUsers()
|
var newCallingUi: Boolean by booleanValue(NEW_CALL_UI, false).defaultForExternalUsers()
|
||||||
|
|
||||||
|
var callQualitySurveys: Boolean by booleanValue(CALL_QUALITY_SURVEYS, false).defaultForExternalUsers()
|
||||||
|
|
||||||
var lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers()
|
var lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers()
|
||||||
|
|
||||||
var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).defaultForExternalUsers()
|
var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).defaultForExternalUsers()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
|||||||
val storyValues = StoryValues(store)
|
val storyValues = StoryValues(store)
|
||||||
val apkUpdateValues = ApkUpdateValues(store)
|
val apkUpdateValues = ApkUpdateValues(store)
|
||||||
val backupValues = BackupValues(store)
|
val backupValues = BackupValues(store)
|
||||||
|
val callQualityValues = CallQualityValues(store)
|
||||||
|
|
||||||
val plainTextValues = PlainTextSharedPrefsDataStore(context)
|
val plainTextValues = PlainTextSharedPrefsDataStore(context)
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
|||||||
story.onFirstEverAppLaunch()
|
story.onFirstEverAppLaunch()
|
||||||
apkUpdate.onFirstEverAppLaunch()
|
apkUpdate.onFirstEverAppLaunch()
|
||||||
backup.onFirstEverAppLaunch()
|
backup.onFirstEverAppLaunch()
|
||||||
|
callQuality.onFirstEverAppLaunch()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@@ -115,7 +117,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
|||||||
releaseChannel.keysToIncludeInBackup +
|
releaseChannel.keysToIncludeInBackup +
|
||||||
story.keysToIncludeInBackup +
|
story.keysToIncludeInBackup +
|
||||||
apkUpdate.keysToIncludeInBackup +
|
apkUpdate.keysToIncludeInBackup +
|
||||||
backup.keysToIncludeInBackup
|
backup.keysToIncludeInBackup +
|
||||||
|
callQuality.keysToIncludeInBackup
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,6 +269,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
|
|||||||
val backup: BackupValues
|
val backup: BackupValues
|
||||||
get() = instance!!.backupValues
|
get() = instance!!.backupValues
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@get:JvmName("callQuality")
|
||||||
|
val callQuality: CallQualityValues
|
||||||
|
get() = instance!!.callQualityValues
|
||||||
|
|
||||||
val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache
|
val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache
|
||||||
get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store)
|
get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store)
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ public class SubmitDebugLogRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
public Optional<String> buildAndSubmitLogSync(long untilTime) {
|
||||||
|
Log.blockUntilAllWritesFinished();
|
||||||
|
LogDatabase.getInstance(context).logs().trimToSize();
|
||||||
|
return submitLogInternal(untilTime, getPrefixLogLinesInternal(), Tracer.getInstance().serialize());
|
||||||
|
}
|
||||||
|
|
||||||
public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback<Optional<String>> callback) {
|
public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback<Optional<String>> callback) {
|
||||||
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace)));
|
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ fun MainBottomChrome(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val snackBarModifier = if (!windowSizeClass.isSplitPane() && state.mainToolbarMode == MainToolbarMode.BASIC) {
|
if (windowSizeClass.isSplitPane()) {
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackBarModifier = if (state.mainToolbarMode == MainToolbarMode.BASIC) {
|
||||||
Modifier.navigationBarsPadding()
|
Modifier.navigationBarsPadding()
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
@@ -121,7 +125,7 @@ fun MainBottomChrome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainSnackbar(
|
fun MainSnackbar(
|
||||||
snackbarState: SnackbarState?,
|
snackbarState: SnackbarState?,
|
||||||
onDismissed: () -> Unit,
|
onDismissed: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import org.signal.ringrtc.NetworkRoute;
|
|||||||
import org.signal.ringrtc.PeekInfo;
|
import org.signal.ringrtc.PeekInfo;
|
||||||
import org.signal.ringrtc.Remote;
|
import org.signal.ringrtc.Remote;
|
||||||
import org.signal.storageservice.protos.groups.GroupExternalCredential;
|
import org.signal.storageservice.protos.groups.GroupExternalCredential;
|
||||||
|
import org.thoughtcrime.securesms.calls.quality.CallQuality;
|
||||||
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
|
import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent;
|
||||||
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil;
|
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil;
|
||||||
import org.thoughtcrime.securesms.database.CallLinkTable;
|
import org.thoughtcrime.securesms.database.CallLinkTable;
|
||||||
@@ -69,7 +70,6 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
|
|||||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
|
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
|
||||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||||
import org.thoughtcrime.securesms.util.RecipientAccessList;
|
import org.thoughtcrime.securesms.util.RecipientAccessList;
|
||||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.rx.RxStore;
|
import org.thoughtcrime.securesms.util.rx.RxStore;
|
||||||
@@ -611,7 +611,15 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
|||||||
|
|
||||||
Log.i(TAG, "onCallEnded(): call_id: " + remotePeer.getCallId() + ", state: " + remotePeer.getState() + ", reason: " + reason);
|
Log.i(TAG, "onCallEnded(): call_id: " + remotePeer.getCallId() + ", state: " + remotePeer.getState() + ", reason: " + reason);
|
||||||
|
|
||||||
// TODO: Handle the call summary.
|
if (s.getCallInfoState().getGroupCall() != null) {
|
||||||
|
Log.i(TAG, "onCallEnded(): call_id: bypassing call summary handling for group call, this is handled in onEnded(groupCall, ...)");
|
||||||
|
} else {
|
||||||
|
boolean isRemoteVideoEnabled = Objects.requireNonNull(s.getCallInfoState().getRemoteCallParticipant(s.getCallInfoState().getCallRecipient())).isVideoEnabled();
|
||||||
|
CameraState cameraState = s.getLocalDeviceState().getCameraState();
|
||||||
|
boolean isLocalVideoEnabled = cameraState.isEnabled() && cameraState.getCameraCount() > 0;
|
||||||
|
|
||||||
|
CallQuality.handleOneToOneCallSummary(summary, isRemoteVideoEnabled || isLocalVideoEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case LOCAL_HANGUP:
|
case LOCAL_HANGUP:
|
||||||
@@ -1025,7 +1033,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnded(@NonNull GroupCall groupCall, @NonNull CallManager.CallEndReason reason, @NonNull CallSummary summary) {
|
public void onEnded(@NonNull GroupCall groupCall, @NonNull CallManager.CallEndReason reason, @NonNull CallSummary summary) {
|
||||||
// TODO: Handle the call summary.
|
CallQuality.handleGroupCallSummary(summary, groupCall.getKind());
|
||||||
process((s, p) -> p.handleGroupCallEnded(s, groupCall.hashCode(), reason));
|
process((s, p) -> p.handleGroupCallEnded(s, groupCall.hashCode(), reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1229,5 +1229,21 @@ object RemoteConfig {
|
|||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
hotSwappable = true
|
hotSwappable = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@get:JvmName("callQualitySurvey")
|
||||||
|
val callQualitySurvey: Boolean by remoteBoolean(
|
||||||
|
key = "android.callQualitySurvey",
|
||||||
|
defaultValue = false,
|
||||||
|
hotSwappable = true
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@get:JvmName("callQualitySurveyPercent")
|
||||||
|
val callQualitySurveyPercent: Int by remoteInt(
|
||||||
|
key = "android.callQualitySurveyPercent",
|
||||||
|
defaultValue = 1,
|
||||||
|
hotSwappable = true
|
||||||
|
)
|
||||||
// endregion
|
// endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ syntax = "proto3";
|
|||||||
package signal;
|
package signal;
|
||||||
|
|
||||||
import "ResumableUploads.proto";
|
import "ResumableUploads.proto";
|
||||||
|
import "CallQualitySurvey.proto";
|
||||||
|
|
||||||
option java_package = "org.thoughtcrime.securesms.jobs.protos";
|
option java_package = "org.thoughtcrime.securesms.jobs.protos";
|
||||||
option java_multiple_files = true;
|
option java_multiple_files = true;
|
||||||
@@ -258,3 +259,8 @@ message UnpinJobData {
|
|||||||
repeated uint64 recipients = 2;
|
repeated uint64 recipients = 2;
|
||||||
uint32 initialRecipientCount = 3;
|
uint32 initialRecipientCount = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CallQualitySurveySubmissionJobData {
|
||||||
|
org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest request = 1;
|
||||||
|
bool includeDebugLogs = 2;
|
||||||
|
}
|
||||||
@@ -267,7 +267,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
|
|||||||
return mockk(relaxed = true)
|
return mockk(relaxed = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi {
|
override fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi {
|
||||||
return mockk(relaxed = true)
|
return mockk(relaxed = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.calling
|
|||||||
|
|
||||||
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest
|
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest
|
||||||
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
|
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse
|
||||||
|
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
|
||||||
import org.whispersystems.signalservice.api.NetworkResult
|
import org.whispersystems.signalservice.api.NetworkResult
|
||||||
import org.whispersystems.signalservice.api.messages.calls.CallingResponse
|
import org.whispersystems.signalservice.api.messages.calls.CallingResponse
|
||||||
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo
|
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo
|
||||||
@@ -17,6 +18,7 @@ import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthRequest
|
|||||||
import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthResponse
|
import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthResponse
|
||||||
import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse
|
import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse
|
||||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||||
|
import org.whispersystems.signalservice.internal.putCustom
|
||||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,9 +26,28 @@ import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessa
|
|||||||
*/
|
*/
|
||||||
class CallingApi(
|
class CallingApi(
|
||||||
private val auth: SignalWebSocket.AuthenticatedWebSocket,
|
private val auth: SignalWebSocket.AuthenticatedWebSocket,
|
||||||
|
private val unAuth: SignalWebSocket.UnauthenticatedWebSocket,
|
||||||
private val pushServiceSocket: PushServiceSocket
|
private val pushServiceSocket: PushServiceSocket
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit call quality information (with the user's permission) to the server on an unauthenticated channel.
|
||||||
|
*
|
||||||
|
* PUT /v1/call_quality_survey
|
||||||
|
* - 204: The survey response was submitted successfully
|
||||||
|
* - 422: The survey response could not be parsed
|
||||||
|
* - 429: Too many attempts, try after Retry-After seconds.
|
||||||
|
*/
|
||||||
|
fun submitCallQualitySurvey(request: SubmitCallQualitySurveyRequest): NetworkResult<Unit> {
|
||||||
|
val webSocketRequestMessage = WebSocketRequestMessage.putCustom(
|
||||||
|
path = "/v1/call_quality_survey",
|
||||||
|
body = request.encode(),
|
||||||
|
headers = mapOf("Content-Type" to "application/octet-stream")
|
||||||
|
)
|
||||||
|
|
||||||
|
return NetworkResult.fromWebSocketRequest(unAuth, webSocketRequestMessage)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.
|
* Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,25 +1,100 @@
|
|||||||
/*
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
* Copyright 2025 Signal Messenger, LLC
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
option java_package = "org.signal.storageservice.protos.calls.quality";
|
|
||||||
option java_multiple_files = true;
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
package org.signal.storageservice.protos.calls.quality;
|
||||||
|
|
||||||
message SubmitCallQualitySurveyRequest {
|
message SubmitCallQualitySurveyRequest {
|
||||||
optional bool user_satisfied = 1;
|
// Indicates whether the caller was generally satisfied with the quality of
|
||||||
repeated string call_quality_issues = 2;
|
// the call
|
||||||
|
bool user_satisfied = 1;
|
||||||
|
|
||||||
|
// A list of call quality issues selected by the caller
|
||||||
|
repeated string call_quality_issues = 2;
|
||||||
|
|
||||||
|
// A free-form description of any additional issues as written by the caller
|
||||||
optional string additional_issues_description = 3;
|
optional string additional_issues_description = 3;
|
||||||
optional string debug_log_url = 4;
|
|
||||||
optional int64 start_timestamp = 5;
|
// A URL for a set of debug logs associated with the call if the caller chose
|
||||||
optional int64 end_timestamp = 6;
|
// to submit debug logs
|
||||||
optional string call_type = 7;
|
optional string debug_log_url = 4;
|
||||||
optional bool success = 8;
|
|
||||||
optional string call_end_reason = 9;
|
// The time at which the call started in microseconds since the epoch (see
|
||||||
optional float rtt_median = 10;
|
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
|
||||||
optional float jitter_median = 11;
|
int64 start_timestamp = 5;
|
||||||
optional float packet_loss_fraction = 12;
|
|
||||||
optional bytes call_telemetry = 13;
|
// The time at which the call ended in microseconds since the epoch (see
|
||||||
|
// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
|
||||||
|
int64 end_timestamp = 6;
|
||||||
|
|
||||||
|
// The type of call; note that direct voice calls can become video calls and
|
||||||
|
// vice versa, and this field indicates which mode was selected at call
|
||||||
|
// initiation time. At the time of writing, expected call types are
|
||||||
|
// "direct_voice", "direct_video", "group", and "call_link".
|
||||||
|
string call_type = 7;
|
||||||
|
|
||||||
|
// Indicates whether the call completed without error or if it terminated
|
||||||
|
// abnormally
|
||||||
|
bool success = 8;
|
||||||
|
|
||||||
|
// A client-defined, but human-readable reason for call termination
|
||||||
|
string call_end_reason = 9;
|
||||||
|
|
||||||
|
// The median round-trip time, measured in milliseconds, for STUN/ICE packets
|
||||||
|
// (i.e. connection maintenance and establishment)
|
||||||
|
optional float connection_rtt_median = 10;
|
||||||
|
|
||||||
|
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
|
||||||
|
// for audio streams
|
||||||
|
optional float audio_rtt_median = 11;
|
||||||
|
|
||||||
|
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
|
||||||
|
// for video streams
|
||||||
|
optional float video_rtt_median = 12;
|
||||||
|
|
||||||
|
// The median jitter for audio streams, measured in milliseconds, for the
|
||||||
|
// duration of the call as measured by the client submitting the survey
|
||||||
|
optional float audio_recv_jitter_median = 13;
|
||||||
|
|
||||||
|
// The median jitter for video streams, measured in milliseconds, for the
|
||||||
|
// duration of the call as measured by the client submitting the survey
|
||||||
|
optional float video_recv_jitter_median = 14;
|
||||||
|
|
||||||
|
// The median jitter for audio streams, measured in milliseconds, for the
|
||||||
|
// duration of the call as measured by the remote endpoint in the call (either
|
||||||
|
// the peer of the client submitting the survey in a direct call or the SFU in
|
||||||
|
// a group call)
|
||||||
|
optional float audio_send_jitter_median = 15;
|
||||||
|
|
||||||
|
// The median jitter for video streams, measured in milliseconds, for the
|
||||||
|
// duration of the call as measured by the remote endpoint in the call (either
|
||||||
|
// the peer of the client submitting the survey in a direct call or the SFU in
|
||||||
|
// a group call)
|
||||||
|
optional float video_send_jitter_median = 16;
|
||||||
|
|
||||||
|
// The fraction of audio packets lost over the duration of the call as
|
||||||
|
// measured by the client submitting the survey
|
||||||
|
optional float audio_recv_packet_loss_fraction = 17;
|
||||||
|
|
||||||
|
// The fraction of video packets lost over the duration of the call as
|
||||||
|
// measured by the client submitting the survey
|
||||||
|
optional float video_recv_packet_loss_fraction = 18;
|
||||||
|
|
||||||
|
// The fraction of audio packets lost over the duration of the call as
|
||||||
|
// measured by the remote endpoint in the call (either the peer of the client
|
||||||
|
// submitting the survey in a direct call or the SFU in a group call)
|
||||||
|
optional float audio_send_packet_loss_fraction = 19;
|
||||||
|
|
||||||
|
// The fraction of video packets lost over the duration of the call as
|
||||||
|
// measured by the remote endpoint in the call (either the peer of the client
|
||||||
|
// submitting the survey in a direct call or the SFU in a group call)
|
||||||
|
optional float video_send_packet_loss_fraction = 20;
|
||||||
|
|
||||||
|
// Machine-generated telemetry from the call; this is a serialized protobuf
|
||||||
|
// entity generated (and, critically, explained to the user!) by the calling
|
||||||
|
// library
|
||||||
|
optional bytes call_telemetry = 21;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user