Add recording consent feature

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
This commit is contained in:
Marcel Hibbe 2023-10-19 15:43:30 +02:00
parent 951f80315e
commit bfbc352448
No known key found for this signature in database
GPG key ID: C793F8B59F43CE7B
9 changed files with 305 additions and 11 deletions

View file

@ -130,6 +130,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWITCH
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.hasSpreedFeatureCapability
import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew.isCallRecordingAvailable
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
@ -376,6 +377,8 @@ class CallActivity : CallBaseActivity() {
AudioFormat.ENCODING_PCM_16BIT
)
private var recordingConsentGiven = false
@SuppressLint("ClickableViewAccessibility")
override fun onCreate(savedInstanceState: Bundle?) {
Log.d(TAG, "onCreate")
@ -496,11 +499,72 @@ class CallActivity : CallBaseActivity() {
callParticipants = HashMap()
participantDisplayItems = HashMap()
initViews()
if (!isConnectionEstablished) {
initiateCall()
}
updateSelfVideoViewPosition()
reactionAnimator = ReactionAnimator(context, binding!!.reactionAnimationWrapper, viewThemeUtils)
checkRecordingConsentAndInitiateCall()
}
private fun checkRecordingConsentAndInitiateCall() {
fun askForRecordingConsent() {
val materialAlertDialogBuilder = MaterialAlertDialogBuilder(this)
.setTitle(R.string.recording_consent_title)
.setMessage(R.string.recording_consent_description)
.setCancelable(false)
.setPositiveButton(R.string.nc_yes) { _, _ ->
recordingConsentGiven = true
initiateCall()
}
.setNegativeButton(R.string.nc_no) { _, _ ->
recordingConsentGiven = false
hangup(true)
}
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, materialAlertDialogBuilder)
val dialog = materialAlertDialogBuilder.show()
viewThemeUtils.platform.colorTextButtons(
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
)
}
when (CapabilitiesUtilNew.getRecordingConsentType(conversationUser)) {
CapabilitiesUtilNew.RECORDING_CONSENT_NOT_REQUIRED -> initiateCall()
CapabilitiesUtilNew.RECORDING_CONSENT_REQUIRED -> askForRecordingConsent()
CapabilitiesUtilNew.RECORDING_CONSENT_DEPEND_ON_CONVERSATION -> {
val getRoomApiVersion = ApiUtils.getConversationApiVersion(
conversationUser,
intArrayOf(ApiUtils.APIv4, 1)
)
ncApi!!.getRoom(credentials, ApiUtils.getUrlForRoom(getRoomApiVersion, baseUrl, roomToken))
.retry(API_RETRIES)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<RoomOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(roomOverall: RoomOverall) {
val conversation = roomOverall.ocs!!.data
if (conversation?.recordingConsentRequired == 1) {
askForRecordingConsent()
} else {
initiateCall()
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "Failed to get room", e)
Snackbar.make(binding!!.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
override fun onComplete() {
// unused atm
}
})
}
}
}
override fun onResume() {
@ -1660,7 +1724,8 @@ class CallActivity : CallBaseActivity() {
credentials,
ApiUtils.getUrlForCall(apiVersion, baseUrl, roomToken),
inCallFlag,
isCallWithoutNotification
isCallWithoutNotification,
recordingConsentGiven
)
.subscribeOn(Schedulers.io())
.retry(API_RETRIES)
@ -1676,6 +1741,8 @@ class CallActivity : CallBaseActivity() {
override fun onError(e: Throwable) {
Log.e(TAG, "Failed to join call", e)
Snackbar.make(binding!!.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
hangup(true)
}
override fun onComplete() {
@ -1804,6 +1871,10 @@ class CallActivity : CallBaseActivity() {
}
private fun initiateCall() {
if (isConnectionEstablished) {
Log.d(TAG, "connection already established")
return
}
checkDevicePermissions()
}

View file

@ -245,9 +245,11 @@ public interface NcApi {
@FormUrlEncoded
@POST
Observable<GenericOverall> joinCall(@Nullable @Header("Authorization") String authorization, @Url String url,
Observable<GenericOverall> joinCall(@Nullable @Header("Authorization") String authorization,
@Url String url,
@Field("flags") Integer inCall,
@Field("silent") Boolean callWithoutNotification);
@Field("silent") Boolean callWithoutNotification,
@Nullable @Field("recordingConsent") Boolean recordingConsent);
/*
Server URL is: baseUrl + ocsApiVersion + spreedApiVersion + /call/callToken
@ -686,4 +688,10 @@ public interface NcApi {
Observable<ReminderOverall> setReminder(@Header("Authorization") String authorization,
@Url String url,
@Field("timestamp") int timestamp);
@FormUrlEncoded
@PUT
Observable<GenericOverall> setRecordingConsent(@Header("Authorization") String authorization,
@Url String url,
@Field("recordingConsent") int recordingConsent);
}

View file

@ -247,7 +247,8 @@ class ConversationInfoActivity :
binding.notificationSettingsView.callNotificationsSwitch,
binding.notificationSettingsView.importantConversationSwitch,
binding.guestAccessView.allowGuestsSwitch,
binding.guestAccessView.passwordProtectionSwitch
binding.guestAccessView.passwordProtectionSwitch,
binding.recordingConsentView.recordingConsentForConversationSwitch
).forEach(viewThemeUtils.talk::colorSwitch)
}
}
@ -259,6 +260,7 @@ class ConversationInfoActivity :
binding.webinarInfoView.webinarSettingsCategory,
binding.guestAccessView.guestAccessSettingsCategory,
binding.sharedItemsTitle,
binding.recordingConsentView.recordingConsentSettingsCategory,
binding.conversationSettingsTitle,
binding.participantsListCategory
)
@ -707,6 +709,7 @@ class ConversationInfoActivity :
loadConversationAvatar()
adjustNotificationLevelUI()
initRecordingConsentOption()
initExpiringMessageOption()
binding.let {
@ -738,6 +741,86 @@ class ConversationInfoActivity :
})
}
private fun initRecordingConsentOption() {
fun hide() {
binding.recordingConsentView.recordingConsentSettingsCategory.visibility = GONE
binding.recordingConsentView.recordingConsentForConversation.visibility = GONE
binding.recordingConsentView.recordingConsentAll.visibility = GONE
}
fun showAlwaysRequiredInfo() {
binding.recordingConsentView.recordingConsentForConversation.visibility = GONE
binding.recordingConsentView.recordingConsentAll.visibility = VISIBLE
}
fun showSwitch() {
binding.recordingConsentView.recordingConsentForConversation.visibility = VISIBLE
binding.recordingConsentView.recordingConsentAll.visibility = GONE
if (conversation!!.hasCall) {
binding.recordingConsentView.recordingConsentForConversation.isEnabled = false
binding.recordingConsentView.recordingConsentForConversation.alpha = LOW_EMPHASIS_OPACITY
} else {
binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked =
conversation!!.recordingConsentRequired == RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION
binding.recordingConsentView.recordingConsentForConversation.setOnClickListener {
binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked =
!binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked
submitRecordingConsentChanges()
}
}
}
if (conversation!!.isParticipantOwnerOrModerator &&
!ConversationUtils.isNoteToSelfConversation(ConversationModel.mapToConversationModel(conversation!!))
) {
when (CapabilitiesUtilNew.getRecordingConsentType(conversationUser)) {
CapabilitiesUtilNew.RECORDING_CONSENT_NOT_REQUIRED -> hide()
CapabilitiesUtilNew.RECORDING_CONSENT_REQUIRED -> showAlwaysRequiredInfo()
CapabilitiesUtilNew.RECORDING_CONSENT_DEPEND_ON_CONVERSATION -> showSwitch()
}
} else {
hide()
}
}
private fun submitRecordingConsentChanges() {
val state = if (binding.recordingConsentView.recordingConsentForConversationSwitch.isChecked) {
RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION
} else {
RECORDING_CONSENT_NOT_REQUIRED_FOR_CONVERSATION
}
val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1))
ncApi.setRecordingConsent(
ApiUtils.getCredentials(conversationUser.username, conversationUser.token),
ApiUtils.getUrlForRecordingConsent(apiVersion, conversationUser.baseUrl, conversation!!.token),
state
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
override fun onComplete() {
// unused atm
}
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(t: GenericOverall) {
// unused atm
}
override fun onError(e: Throwable) {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "Error when setting recording consent option for conversation", e)
}
})
}
private fun initExpiringMessageOption() {
if (conversation!!.isParticipantOwnerOrModerator &&
!ConversationUtils.isNoteToSelfConversation(ConversationModel.mapToConversationModel(conversation!!)) &&
@ -1227,11 +1310,13 @@ class ConversationInfoActivity :
}
companion object {
private const val TAG = "ConversationInfo"
private val TAG = ConversationInfoActivity::class.java.simpleName
private const val NOTIFICATION_LEVEL_ALWAYS: Int = 1
private const val NOTIFICATION_LEVEL_MENTION: Int = 2
private const val NOTIFICATION_LEVEL_NEVER: Int = 3
private const val LOW_EMPHASIS_OPACITY: Float = 0.38f
private const val RECORDING_CONSENT_NOT_REQUIRED_FOR_CONVERSATION: Int = 0
private const val RECORDING_CONSENT_REQUIRED_FOR_CONVERSATION: Int = 1
}
/**

View file

@ -157,7 +157,10 @@ data class Conversation(
var hasCustomAvatar: Boolean? = null,
@JsonField(name = ["callStartTime"])
var callStartTime: Long? = null
var callStartTime: Long? = null,
@JsonField(name = ["recordingConsent"])
var recordingConsentRequired: Int = 0
) : Parcelable {
// This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject'

View file

@ -536,4 +536,8 @@ public class ApiUtils {
String url = ApiUtils.getUrlForChatMessage(version, user.getBaseUrl(), roomToken, messageId);
return url + "/reminder";
}
public static String getUrlForRecordingConsent(int version, String baseUrl, String token) {
return getUrlForRoom(version, baseUrl, token) + "/recording-consent";
}
}

View file

@ -250,5 +250,29 @@ object CapabilitiesUtilNew {
return false
}
fun getRecordingConsentType(user: User?): Int {
if (user?.capabilities != null) {
val capabilities = user.capabilities
if (
capabilities?.spreedCapability?.config?.containsKey("call") == true &&
capabilities.spreedCapability!!.config!!["call"] != null &&
capabilities.spreedCapability!!.config!!["call"]!!.containsKey("recording-consent")
) {
return when (
capabilities.spreedCapability!!.config!!["call"]!!["recording-consent"].toString()
.toInt()
) {
1 -> RECORDING_CONSENT_REQUIRED
2 -> RECORDING_CONSENT_DEPEND_ON_CONVERSATION
else -> RECORDING_CONSENT_NOT_REQUIRED
}
}
}
return RECORDING_CONSENT_NOT_REQUIRED
}
const val DEFAULT_CHAT_SIZE = 1000
const val RECORDING_CONSENT_NOT_REQUIRED = 0
const val RECORDING_CONSENT_REQUIRED = 1
const val RECORDING_CONSENT_DEPEND_ON_CONVERSATION = 2
}

View file

@ -226,6 +226,12 @@
</LinearLayout>
</LinearLayout>
<include
android:id="@+id/recording_consent_view"
layout="@layout/item_recording_consent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/standard_quarter_margin" />
<LinearLayout
android:id="@+id/conversation_settings"
@ -261,7 +267,6 @@
android:popupTheme="@style/ThemeOverlay.AppTheme.PopupMenu"
android:text="" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textview.MaterialTextView

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Nextcloud Talk application
~
~ @author Marcel Hibbe
~ Copyright (C) 2023 Marcel Hibbe <dev@mhibbe.de>
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recording_consent_settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/recording_consent_settings_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/standard_margin"
android:layout_marginEnd="@dimen/standard_margin"
android:paddingTop="@dimen/standard_padding"
android:paddingBottom="@dimen/standard_half_padding"
android:text="@string/recording_settings_title"
android:textSize="@dimen/headline_text_size"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/recording_consent_for_conversation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal"
android:paddingStart="@dimen/standard_margin"
android:paddingTop="@dimen/standard_margin"
android:paddingEnd="@dimen/standard_margin"
android:paddingBottom="@dimen/standard_half_margin">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<com.google.android.material.textview.MaterialTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/recording_consent_for_conversation_title"
android:textSize="@dimen/headline_text_size" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="@string/recording_consent_for_conversation_description"
android:textSize="@dimen/supporting_text_text_size" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/recording_consent_for_conversation_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="@dimen/standard_margin"
android:clickable="false" />
</LinearLayout>
<TextView
android:id="@+id/recording_consent_all"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/standard_margin"
android:paddingTop="@dimen/standard_margin"
android:paddingEnd="@dimen/standard_margin"
android:paddingBottom="@dimen/standard_half_margin"
android:text="@string/recording_consent_all" />
</LinearLayout>

View file

@ -606,6 +606,12 @@ How to translate with transifex:
<string name="record_cancel_start">Cancel recording start</string>
<string name="record_stopping">Stopping recording …</string>
<string name="record_failed_info">The recording failed. Please contact your administrator.</string>
<string name="recording_consent_title">The call might be recorded.</string>
<string name="recording_consent_description">The recording might include your voice, video from camera, and screen share. Your consent is required before joining the call. Do you consent?</string>
<string name="recording_settings_title">Recording</string>
<string name="recording_consent_for_conversation_title">Recording consent</string>
<string name="recording_consent_for_conversation_description">Require recording consent before joining call in this conversation</string>
<string name="recording_consent_all">Recording consent is required for all calls</string>
<!-- Shared items -->
<string name="nc_shared_items">Shared items</string>
@ -708,5 +714,4 @@ How to translate with transifex:
<string name="audio_call">Audio Call</string>
<string name="started_a_call">started a call</string>
<string name="nc_settings_phone_book_integration_phone_number_dialog_429">Error 429 Too Many Requests</string>
</resources>