diff --git a/README.md b/README.md index 323e67b..39933cf 100644 --- a/README.md +++ b/README.md @@ -79,19 +79,19 @@ This demo support Android device with **Android 7.0** or later - For Full SDK ``` dependencies { - implementation 'com.ciscowebex:webexsdk:3.9.2' + implementation 'com.ciscowebex:webexsdk:3.10.0' } ``` - For Meeting SDK ``` dependencies { - implementation 'com.ciscowebex:webexsdk-meeting:3.9.2' + implementation 'com.ciscowebex:webexsdk-meeting:3.10.0' } ``` - For WebexCalling SDK ``` dependencies { - implementation 'com.ciscowebex:webexsdk-wxc:3.9.2' + implementation 'com.ciscowebex:webexsdk-wxc:3.10.0' } ``` diff --git a/app/build.gradle b/app/build.gradle index 74bdef8..091e162 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -32,8 +32,8 @@ android { applicationId "com.cisco.sdk_android" minSdkVersion Versions.minSdk targetSdkVersion Versions.targetSdk - versionCode 3090200 - versionName "3.9.2" + versionCode 3100000 + versionName "3.10.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -113,11 +113,12 @@ android { } dependencies { + + //At a time only one WebexSDK should be used. + implementation 'com.ciscowebex:webexsdk:3.10.0' // For full flavor + //implementation 'com.ciscowebex:webexsdk-wxc:3.10.0' //For webexCalling flavor + //implementation 'com.ciscowebex:webexsdk-meeting:3.10.0' // For meeting flavor - // At a time only one WebexSDK should be used. - implementation 'com.ciscowebex:webexsdk:3.9.2' // For full flavor -// implementation 'com.ciscowebex:webexsdk-wxc:3.9.2' //For webexCalling flavor - //implementation 'com.ciscowebex:webexsdk-meeting:3.9.2' // For meeting flavor implementation fileTree(dir: "libs", include: ["*.jar"]) implementation Dependencies.kotlinStdLib implementation Dependencies.coreKtx @@ -155,5 +156,4 @@ dependencies { implementation Dependencies.firebaseCrashlytics implementation Dependencies.gson implementation Dependencies.glide - } diff --git a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt index 2f25e77..489f4a2 100644 --- a/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt +++ b/app/src/androidTest/java/com/ciscowebex/androidsdk/kitchensink/KitchenSinkTest.kt @@ -10,6 +10,7 @@ import androidx.annotation.StringRes import androidx.lifecycle.Lifecycle import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.closeSoftKeyboard import androidx.test.espresso.PerformException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 84cb6c1..ceef1b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -76,8 +76,7 @@ android:name=".calling.LockScreenActivity" android:showOnLockScreen="true" android:showWhenLocked="true" - android:turnScreenOn="true" - /> + android:turnScreenOn="true" /> + + - + android:name="android.content.APP_RESTRICTIONS" + android:resource="@xml/app_restrictions" /> \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt index 9905b87..5cc336b 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexRepository.kt @@ -19,7 +19,18 @@ import com.ciscowebex.androidsdk.membership.Membership import com.ciscowebex.androidsdk.membership.MembershipObserver import com.ciscowebex.androidsdk.message.LocalFile import com.ciscowebex.androidsdk.message.MessageObserver -import com.ciscowebex.androidsdk.phone.* +import com.ciscowebex.androidsdk.phone.Breakout +import com.ciscowebex.androidsdk.phone.BreakoutSession +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.CallMembership +import com.ciscowebex.androidsdk.phone.MediaOption +import com.ciscowebex.androidsdk.phone.Phone +import com.ciscowebex.androidsdk.phone.VirtualBackground +import com.ciscowebex.androidsdk.phone.CallObserver +import com.ciscowebex.androidsdk.phone.NotificationCallType +import com.ciscowebex.androidsdk.phone.ReceivingNoiseInfo +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo import com.ciscowebex.androidsdk.space.SpaceObserver import java.io.PrintWriter @@ -660,6 +671,24 @@ class WebexRepository(val webex: Webex) : WebexUCLoginDelegate { } } } + + override fun onClosedCaptionsArrived(closedCaptions: CaptionItem) { + val observers: MutableList? = _callObservers[_callId] + observers?.let { it -> + it.forEach { observer -> + observer.onClosedCaptionsArrived(closedCaptions) + } + } + } + + override fun onClosedCaptionsInfoChanged(closedCaptionsInfo: ClosedCaptionsInfo) { + val observers: MutableList? = _callObservers[_callId] + observers?.let { it -> + it.forEach { observer -> + observer.onClosedCaptionsInfoChanged(closedCaptionsInfo) + } + } + } } private fun registerCallObserver(call: Call) { diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt index 1975eba..2a56ec5 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/WebexViewModel.kt @@ -19,8 +19,10 @@ import org.json.JSONObject import com.ciscowebex.androidsdk.auth.PhoneServiceRegistrationFailureReason import com.ciscowebex.androidsdk.auth.TokenAuthenticator import com.ciscowebex.androidsdk.auth.UCLoginServerConnectionStatus +import com.ciscowebex.androidsdk.internal.ResultImpl import com.ciscowebex.androidsdk.kitchensink.calling.CallObserverInterface import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage +import com.ciscowebex.androidsdk.kitchensink.utils.Constants import com.ciscowebex.androidsdk.message.LocalFile import com.ciscowebex.androidsdk.phone.ShareConfig import com.ciscowebex.androidsdk.phone.BreakoutSession.BreakoutSessionError @@ -45,6 +47,8 @@ import com.ciscowebex.androidsdk.phone.SwitchToAudioVideoCallResult import com.ciscowebex.androidsdk.phone.PhoneConnectionResult import com.ciscowebex.androidsdk.phone.ReceivingNoiseInfo import com.ciscowebex.androidsdk.phone.ReceivingNoiseRemovalEnableResult +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo import com.google.firebase.installations.FirebaseInstallations import java.io.PrintWriter @@ -541,6 +545,14 @@ class WebexViewModel(val webex: Webex, val repository: WebexRepository) : BaseVi override fun onReceivingNoiseInfoChanged(info: ReceivingNoiseInfo) { callObserverInterface?.onReceivingNoiseInfoChanged(info) } + + override fun onClosedCaptionsArrived(closedCaptions: CaptionItem) { + callObserverInterface?.onClosedCaptionsArrived(closedCaptions) + } + + override fun onClosedCaptionsInfoChanged(closedCaptionsInfo: ClosedCaptionsInfo) { + callObserverInterface?.onClosedCaptionsInfoChanged(closedCaptionsInfo) + } } val callObserverMap : HashMap = HashMap() @@ -756,7 +768,7 @@ class WebexViewModel(val webex: Webex, val repository: WebexRepository) : BaseVi fun setPushTokens(id: String, token: String){ if(BuildConfig.WEBHOOK_URL.isEmpty()) { - webex.phone.setPushTokens(KitchenSinkApp.applicationContext().packageName, id, token) + webex.phone.setPushTokens(KitchenSinkApp.applicationContext().packageName, id, token, Constants.Keys.appId) } } @@ -857,8 +869,20 @@ class WebexViewModel(val webex: Webex, val repository: WebexRepository) : BaseVi ?.getValue() as Boolean? } - fun switchAudioMode(mode: Call.AudioOutputMode) { - getCall(currentCallId.orEmpty())?.switchAudioOutput(mode) + fun switchAudioMode(mode: Call.AudioOutputMode, handler: CompletionHandler) { + getCall(currentCallId.orEmpty())?.switchAudioOutput(mode) { result -> + if (result.data == false) { + Log.d(tag, "ATSDK Error: Switch mode to ${mode.name} failed") + handler.onComplete(ResultImpl.success(false)) + } else { + Log.d(tag, "ATSDK Switch mode to ${mode.name} success") + handler.onComplete(ResultImpl.success(true)) + } + } + } + + fun getCurrentAudioOutputMode(): Call.AudioOutputMode? { + return getCall(currentCallId.orEmpty())?.getCurrentAudioOutput() } fun enableAudioBNR(value: Boolean) { diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt index a9d4a8f..20ac11c 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallActivity.kt @@ -29,6 +29,8 @@ import com.ciscowebex.androidsdk.kitchensink.databinding.ActivityCallBinding import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage import com.ciscowebex.androidsdk.kitchensink.utils.Constants import com.ciscowebex.androidsdk.phone.* +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -459,6 +461,14 @@ class CallActivity : BaseActivity(), CallControlsFragment.OnCallActionListener, TODO("Not yet implemented") } + override fun onClosedCaptionsArrived(closedCaptions: CaptionItem) { + TODO("Not yet implemented") + } + + override fun onClosedCaptionsInfoChanged(closedCaptionsInfo: ClosedCaptionsInfo) { + TODO("Not yet implemented") + } + override fun finish() { if(calls.size > 0){ //Resume a queued call diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt index 7cf0708..53ab424 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallBottomSheetFragment.kt @@ -26,7 +26,8 @@ class CallBottomSheetFragment(val showIncomingCallsClickListener: (Call?) -> Uni val cameraOptionsClickListener: (Call?) -> Unit, val multiStreamOptionsClickListener: (Call?) -> Unit, val sendDTMFClickListener: (Call?) -> Unit, - val showBreakoutSessions: () -> Unit): BottomSheetDialogFragment() { + val showBreakoutSessions: () -> Unit, + val closedCaptionOptions: (Call?) -> Unit): BottomSheetDialogFragment() { companion object { val TAG = "CallBottomSheetFragment" } @@ -215,6 +216,11 @@ class CallBottomSheetFragment(val showIncomingCallsClickListener: (Call?) -> Uni showBreakoutSessions() } + closedCaptionOptions.setOnClickListener { + dismiss() + closedCaptionOptions(call) + } + cancel.setOnClickListener { dismiss() } }.root } diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt index fae6e60..28c16f1 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallControlsFragment.kt @@ -46,6 +46,11 @@ import com.ciscowebex.androidsdk.WebexError import com.ciscowebex.androidsdk.kitchensink.R import com.ciscowebex.androidsdk.kitchensink.WebexRepository import com.ciscowebex.androidsdk.kitchensink.WebexViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.captions.ClosedCaptionsController +import com.ciscowebex.androidsdk.kitchensink.calling.captions.ClosedCaptionsViewModel +import com.ciscowebex.androidsdk.kitchensink.calling.captions.LanguageData +import com.ciscowebex.androidsdk.kitchensink.calling.captions.REQUEST_CODE_SPOKEN_LANGUAGE +import com.ciscowebex.androidsdk.kitchensink.calling.captions.REQUEST_CODE_TRANSLATION_LANGUAGE import com.ciscowebex.androidsdk.kitchensink.calling.participants.ParticipantsFragment import com.ciscowebex.androidsdk.kitchensink.calling.transcription.TranscriptionsDialogFragment import com.ciscowebex.androidsdk.kitchensink.databinding.DialogEnterMeetingPinBinding @@ -56,6 +61,7 @@ import com.ciscowebex.androidsdk.kitchensink.setup.BackgroundOptionsBottomSheetF import com.ciscowebex.androidsdk.kitchensink.utils.AudioManagerUtils import com.ciscowebex.androidsdk.kitchensink.utils.CallObjectStorage import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.kitchensink.utils.Constants.Intent.CLOSED_CAPTION_LANGUAGE_ITEM import com.ciscowebex.androidsdk.kitchensink.utils.extensions.toast import com.ciscowebex.androidsdk.kitchensink.utils.showDialogForDTMF import com.ciscowebex.androidsdk.kitchensink.utils.showDialogWithMessage @@ -80,18 +86,21 @@ import com.ciscowebex.androidsdk.phone.Breakout import com.ciscowebex.androidsdk.phone.BreakoutSession import com.ciscowebex.androidsdk.phone.ReceivingNoiseInfo import com.ciscowebex.androidsdk.phone.ShareConfig +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo import org.koin.android.ext.android.inject import com.ciscowebex.androidsdk.utils.internal.MimeUtils +import kotlinx.coroutines.CoroutineScope import java.io.File import java.util.Date import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.koin.android.ext.android.bind import org.koin.androidx.viewmodel.ext.android.viewModel class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface { private val TAG = "CallControlsFragment" val webexViewModel: WebexViewModel by viewModel() + val captionsViewModel: ClosedCaptionsViewModel by viewModel() private lateinit var binding: FragmentCallControlsBinding private var callFailed = false private var isIncomingActivity = false @@ -109,8 +118,10 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface private lateinit var mediaStreamBottomSheetFragment: MediaStreamBottomSheetFragment private lateinit var photoViewerBottomSheetFragment: PhotoViewerBottomSheetFragment private lateinit var breakoutSessionBottomSheetFragment: BreakoutSessionsBottomSheetFragment + private lateinit var switchAudioBottomSheetFragment: SwitchAudioBottomSheetFragment private lateinit var incomingInfoAdapter: IncomingCallBottomSheetFragment.IncomingInfoAdapter private lateinit var breakoutSessionsAdapter: BreakoutSessionsBottomSheetFragment.BreakoutSessionsAdapter + private lateinit var captionsController: ClosedCaptionsController private val mAuxStreamViewMap: HashMap = HashMap() private var callerId: String = "" var bottomSheetFragment: BackgroundOptionsBottomSheetFragment? = null @@ -786,6 +797,28 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface } } + override fun onClosedCaptionsArrived(captions: CaptionItem) { + CoroutineScope(Dispatchers.Main).launch { + captionsController.showCaptionView(binding.rootLayout, captions) + if(captions.isFinal) { + captionsViewModel.updateData(captions) + Log.d(TAG, " Captions are arrived from ${captions.getDisplayName()}") + } + } + } + + override fun onClosedCaptionsInfoChanged(closedCaptionsInfo: ClosedCaptionsInfo) { + Log.d( + TAG, + " Captions Info changed: current spkn: ${ + closedCaptionsInfo.getCurrentSpokenLanguage().getLanguageTitle() + } and trns ${closedCaptionsInfo.getCurrentTranslationLanguage().getLanguageTitle()}" + ) + captionsController.setLanguages(requireContext(), closedCaptionsInfo) { intent, code -> + startActivityForResult(intent, code) + } + } + @SuppressLint("NotifyDataSetChanged") private fun observerCallLiveData() { @@ -882,6 +915,10 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface dismissErrorDialog() } } + + call?.let { + captionsController = ClosedCaptionsController(call) + } } }) @@ -1351,21 +1388,22 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface } callOptionsBottomSheetFragment = CallBottomSheetFragment( - { call -> showIncomingCallBottomSheet()}, - { call -> showTranscriptions(call) }, - { call -> toggleWXAClickListener(call) }, - { call -> receivingVideoListener(call) }, - { call -> receivingAudioListener(call) }, - { call -> receivingSharingListener(call) }, - { call -> scalingModeClickListener(call) }, - { call -> virtualBackgroundOptionsClickListener(call) }, - { call -> compositeStreamLayoutClickListener(call) }, - { call -> swapVideoClickListener(call) }, - { call -> forceLandscapeClickListener(call) }, - { call -> cameraOptionsClickListener(call) }, - { call -> multiStreamOptionsClickListener(call) }, - { call -> sendDTMFClickListener(call) }, - { showBreakoutSessions()}) + { call -> showIncomingCallBottomSheet()}, + { call -> showTranscriptions(call) }, + { call -> toggleWXAClickListener(call) }, + { call -> receivingVideoListener(call) }, + { call -> receivingAudioListener(call) }, + { call -> receivingSharingListener(call) }, + { call -> scalingModeClickListener(call) }, + { call -> virtualBackgroundOptionsClickListener(call) }, + { call -> compositeStreamLayoutClickListener(call) }, + { call -> swapVideoClickListener(call) }, + { call -> forceLandscapeClickListener(call) }, + { call -> cameraOptionsClickListener(call) }, + { call -> multiStreamOptionsClickListener(call) }, + { call -> sendDTMFClickListener(call) }, + { showBreakoutSessions() }, + { call -> showCaptionDialog(call) }) multiStreamOptionsBottomSheetFragment = MultiStreamOptionsBottomSheetFragment({ call -> setCategoryAOptionClickListener(call) }, { call -> setCategoryBOptionClickListener(call) }, @@ -1400,6 +1438,9 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface photoViewerBottomSheetFragment = PhotoViewerBottomSheetFragment() + switchAudioBottomSheetFragment = SwitchAudioBottomSheetFragment({toggleAudioMode(AudioMode.EARPIECE)}, + {toggleAudioMode(AudioMode.SPEAKER)}, {toggleAudioMode(AudioMode.BLUETOOTH)}, {toggleAudioMode(AudioMode.WIRED_HEADSET)}) + callingActivity = bundle?.getInt(Constants.Intent.CALLING_ACTIVITY_ID, 0)!! val incomingCallId = bundle.getString(Constants.Intent.CALL_ID) ?: "" if (callingActivity == 1) { @@ -1461,7 +1502,7 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface binding.ibMute.setOnClickListener(this) binding.ibParticipants.setOnClickListener(this) - binding.ibSpeaker.setOnClickListener(this) + binding.ibAudioMode.setOnClickListener(this) binding.ibAdd.setOnClickListener(this) binding.ibTransferCall.setOnClickListener(this) binding.ibDirecttransferCall.setOnClickListener(this) @@ -1487,6 +1528,12 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface passwordDialog = Dialog(requireContext()) } + private fun showCaptionDialog(call: Call?) { + captionsController.showCaptionDialog(requireContext(), call) {intent, code -> + startActivityForResult(intent, code) + } + } + private fun showBreakoutSessions() { breakoutSessionsAdapter.sessions = breakoutSessions.toMutableList() breakoutSessionBottomSheetFragment.adapter = breakoutSessionsAdapter @@ -1496,6 +1543,7 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface fun answerCall(call: Call) { webexViewModel.answer(call, getMediaOption()) + captionsController = ClosedCaptionsController(call) } private fun initIncomingCallBottomSheet() { @@ -1519,8 +1567,8 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface val dialog = ParticipantsFragment.newInstance(callId) dialog.show(childFragmentManager, ParticipantsFragment::javaClass.name) } - binding.ibSpeaker -> { - toggleSpeaker(v) + binding.ibAudioMode -> { + switchAudioBottomSheetFragment.show(childFragmentManager, SwitchAudioBottomSheetFragment::javaClass.name) } binding.ibAdd -> { //while associating a call, existing call needs to be put on hold @@ -2077,9 +2125,19 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface screenShareButtonVisibilityState() directTransferButtonStateUpdate() videoViewTextColorState(webexViewModel.isRemoteVideoMuted) - + updateAudioModeButton() } + } + } + private fun updateAudioModeButton() { + webexViewModel.getCurrentAudioOutputMode()?.let { outputMode -> + when (outputMode) { + Call.AudioOutputMode.PHONE -> binding.ibAudioMode.setImageResource(R.drawable.ic_earpiece) + Call.AudioOutputMode.SPEAKER -> binding.ibAudioMode.setImageResource(R.drawable.ic_speaker) + Call.AudioOutputMode.BLUETOOTH_HEADSET -> binding.ibAudioMode.setImageResource(R.drawable.ic_bluetooth) + Call.AudioOutputMode.HEADSET -> binding.ibAudioMode.setImageResource(R.drawable.ic_headset) + } } } @@ -2302,24 +2360,42 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface return status } - private fun toggleSpeaker(v: View) { - v.isSelected = !v.isSelected - when { - v.isSelected -> { - webexViewModel.switchAudioMode(Call.AudioOutputMode.SPEAKER) + private fun toggleAudioMode(mode: AudioMode) { + when (mode) { + AudioMode.SPEAKER -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.SPEAKER) { + if (it.data == true) + binding.ibAudioMode.setImageResource(R.drawable.ic_speaker) + } } - audioManagerUtils?.isBluetoothHeadsetConnected == true -> { - webexViewModel.switchAudioMode(Call.AudioOutputMode.BLUETOOTH_HEADSET) + AudioMode.BLUETOOTH -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.BLUETOOTH_HEADSET) { + if (it.data == true) + binding.ibAudioMode.setImageResource(R.drawable.ic_bluetooth) + } } - audioManagerUtils?.isWiredHeadsetOn == true -> { - webexViewModel.switchAudioMode(Call.AudioOutputMode.HEADSET) + AudioMode.EARPIECE -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.PHONE) { + if (it.data == true) + binding.ibAudioMode.setImageResource(R.drawable.ic_earpiece) + } } - else -> { - webexViewModel.switchAudioMode(Call.AudioOutputMode.PHONE) + AudioMode.WIRED_HEADSET -> { + webexViewModel.switchAudioMode(Call.AudioOutputMode.HEADSET) { + if (it.data == true) + binding.ibAudioMode.setImageResource(R.drawable.ic_headset) + } } } } + enum class AudioMode { + SPEAKER, + EARPIECE, + BLUETOOTH, + WIRED_HEADSET + } + internal fun handleFCMIncomingCall(callId: String) { mHandler.post { webexViewModel.setFCMIncomingListenerObserver(callId) @@ -2599,9 +2675,18 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface val callNumber = data?.getStringExtra(CALLER_ID) ?: "" //start call association to add new person on call startAssociatedCall(callNumber, CallAssociationType.Transfer, true) - }else if (requestCode == REQUEST_CODE_BLINDTRANSFER && resultCode == Activity.RESULT_OK){ + } else if (requestCode == REQUEST_CODE_BLINDTRANSFER && resultCode == Activity.RESULT_OK) { val callNumber = data?.getStringExtra(CALLER_ID) ?: "" directTransferCall(callNumber) + } else if ( + (requestCode == REQUEST_CODE_SPOKEN_LANGUAGE || requestCode == REQUEST_CODE_TRANSLATION_LANGUAGE) && + resultCode == Activity.RESULT_OK + ) { + captionsController.handleLanguageSelection( + requireContext(), + requestCode, + data?.getParcelableExtra(CLOSED_CAPTION_LANGUAGE_ITEM) + ) } } @@ -3002,7 +3087,7 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface binding.optionButtonsContainer.visibility = currentView binding.ibMute.visibility = currentView binding.ibHoldCall.visibility = currentView - binding.ibSpeaker.visibility = currentView + binding.ibAudioMode.visibility = currentView binding.controlsRow2.visibility = currentView binding.ibVideo.visibility = currentView binding.ibParticipants.visibility = currentView @@ -3019,7 +3104,7 @@ class CallControlsFragment : Fragment(), OnClickListener, CallObserverInterface } binding.btnReturnToMainSession.visibility = returnToMainSessionVisibility } - + fun aspectRatio(): Rational { var width = binding.videoCallLayout.width.toInt() var height = binding.videoCallLayout.height.toInt() diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt index 7c61ecd..3f7918c 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallModule.kt @@ -1,10 +1,16 @@ package com.ciscowebex.androidsdk.kitchensink.calling +import com.ciscowebex.androidsdk.kitchensink.calling.captions.ClosedCaptionsRepository +import com.ciscowebex.androidsdk.kitchensink.calling.captions.ClosedCaptionsViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val callModule = module { viewModel { CallViewModel(get()) + ClosedCaptionsViewModel(get()) } + + single { ClosedCaptionsRepository() } + } \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt index 3167050..de3d588 100644 --- a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/CallObserverInterface.kt @@ -6,6 +6,8 @@ import com.ciscowebex.androidsdk.phone.Call import com.ciscowebex.androidsdk.phone.CallObserver import com.ciscowebex.androidsdk.phone.BreakoutSession.BreakoutSessionError import com.ciscowebex.androidsdk.phone.ReceivingNoiseInfo +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo /* * This interface is written to overcome the limitation of live data postValue. @@ -41,4 +43,8 @@ interface CallObserverInterface { fun onBreakoutUpdated(breakout: Breakout) fun onBreakoutError(error: BreakoutSessionError) fun onReceivingNoiseInfoChanged(info: ReceivingNoiseInfo) + + // Closedcaption + fun onClosedCaptionsArrived(closedCaptions: CaptionItem) + fun onClosedCaptionsInfoChanged(closedCaptionsInfo: ClosedCaptionsInfo) } \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SwitchAudioFragment.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SwitchAudioFragment.kt new file mode 100644 index 0000000..d97bc01 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/SwitchAudioFragment.kt @@ -0,0 +1,44 @@ +package com.ciscowebex.androidsdk.kitchensink.calling + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.ciscowebex.androidsdk.kitchensink.databinding.BottomSheetSwitchAudioOptionsBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment + +class SwitchAudioBottomSheetFragment(val onEarpieceSelected: () -> Unit, + val onSpeakerSelected: () -> Unit, + val onBluetoothSelected: () -> Unit, + val onHeadsetSelected: () -> Unit): BottomSheetDialogFragment() { + companion object { + val TAG = "SwitchAudioBottomSheetFragment" + } + + private lateinit var binding: BottomSheetSwitchAudioOptionsBinding + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return BottomSheetSwitchAudioOptionsBinding.inflate(inflater, container, false).also { binding = it }.apply { + + selectSpeakerOption.setOnClickListener { + dismiss() + onSpeakerSelected() + } + + selectBluetooth.setOnClickListener { + dismiss() + onBluetoothSelected() + } + + selectPhoneEarpieceOption.setOnClickListener { + dismiss() + onEarpieceSelected() + } + + selectHeadset.setOnClickListener { + dismiss() + onHeadsetSelected() + } + }.root + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/CaptionData.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/CaptionData.kt new file mode 100644 index 0000000..da14e98 --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/CaptionData.kt @@ -0,0 +1,34 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.captions + +import android.os.Parcel +import android.os.Parcelable + +data class CaptionData(val name:String?, val timestamp:String?, val content:String?):Parcelable { + constructor(parcel: Parcel) : this( + parcel.readString(), + parcel.readString(), + parcel.readString() + ) { + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(name) + parcel.writeString(timestamp) + parcel.writeString(content) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): CaptionData { + return CaptionData(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsActivity.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsActivity.kt new file mode 100644 index 0000000..68b8f3b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsActivity.kt @@ -0,0 +1,82 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.captions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ciscowebex.androidsdk.kitchensink.BaseActivity +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.util.ArrayList +import java.util.Collections + +class ClosedCaptionsActivity : BaseActivity() { + + val captionsViewModel: ClosedCaptionsViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_closed_captions) + val recyclerView = findViewById(R.id.ccRecyclerView) + findViewById(R.id.title).text = getString(R.string.closed_captions) + + var captions = intent?.getParcelableArrayListExtra(Constants.Intent.CLOSED_CAPTION_DATA) + captionsViewModel.captions.observe(this@ClosedCaptionsActivity) { caption -> + if (captions == null) { + captions = ArrayList(1) + updateViews(captions) + } + + captions?.let { + if (it[it.size - 1] != caption) + it.add(caption) + recyclerView.adapter?.let { recyclerView -> + recyclerView.notifyItemInserted(recyclerView.itemCount - 1) + } + } + } + updateViews(captions) + } + + fun updateViews(captions: ArrayList?) { + val recyclerView = findViewById(R.id.ccRecyclerView) + val noDataView = findViewById(R.id.ccNoData) + if (captions.isNullOrEmpty()) { + noDataView.visibility = View.VISIBLE + recyclerView.visibility = View.INVISIBLE + } else { + noDataView.visibility = View.INVISIBLE + recyclerView.visibility = View.VISIBLE + recyclerView.layoutManager = LinearLayoutManager(this) + recyclerView.adapter = CaptionAdapter(captions) + } + } + + class CaptionAdapter(private val captions: ArrayList) : + RecyclerView.Adapter() { + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val primaryTextView: TextView = itemView.findViewById(R.id.primaryText) + val subTextView: TextView = itemView.findViewById(R.id.subText) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.list_item_closed_caption, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val caption = captions[position] + holder.primaryTextView.text = "${caption.name} ${caption.timestamp}" + holder.subTextView.text = caption.content + } + + override fun getItemCount(): Int { + return captions.size + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsController.kt b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsController.kt new file mode 100644 index 0000000..d3cb52b --- /dev/null +++ b/app/src/main/java/com/ciscowebex/androidsdk/kitchensink/calling/captions/ClosedCaptionsController.kt @@ -0,0 +1,242 @@ +package com.ciscowebex.androidsdk.kitchensink.calling.captions + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.util.Log +import android.view.View +import android.widget.Button +import android.widget.Switch +import android.widget.TextView +import android.widget.Toast +import com.ciscowebex.androidsdk.kitchensink.R +import com.ciscowebex.androidsdk.kitchensink.utils.Constants +import com.ciscowebex.androidsdk.phone.Call +import com.ciscowebex.androidsdk.phone.closedCaptions.CaptionItem +import com.ciscowebex.androidsdk.phone.closedCaptions.ClosedCaptionsInfo +import com.ciscowebex.androidsdk.phone.closedCaptions.LanguageItem +import com.google.android.material.snackbar.Snackbar + +const val REQUEST_CODE_SPOKEN_LANGUAGE = 1214 +const val REQUEST_CODE_TRANSLATION_LANGUAGE = 1215 + +class ClosedCaptionsController(var call: Call?) { + + private val TAG = "ClosedCaptionsController" + private lateinit var dialog: Dialog + private var snackbar: Snackbar? = null + + private fun getParceableLanguages(languages: List?): ArrayList { + return languages?.mapNotNull { languageItem -> + LanguageData( + title = languageItem.getLanguageTitle(), + titleInEnglish = languageItem.getLanguageTitleInEnglish(), + code = languageItem.getLanguageCode() + ) + }?.toCollection(ArrayList()) ?: ArrayList() + + } + + private fun getParceableCaptions(captions: List?): ArrayList { + return captions?.mapNotNull { captionItem -> + CaptionData( + name = captionItem.getDisplayName(), + timestamp = captionItem.getTimeStamp(), + content = captionItem.getContent() + ) + }?.toCollection(ArrayList()) ?: ArrayList() + } + + fun showCaptionDialog( + context: Context, + data: Call?, + startLanguageActivityForResult: (intent: Intent, code: Int) -> Unit + ) { + call = data // update the call object , as this class will hold the current active call. + val isCaptionsEnabled = call?.isClosedCaptionEnabled ?: false + dialog = Dialog(context) + dialog.setTitle(context.getString(R.string.cc_title)) + dialog.setCancelable(true) + dialog.setContentView(R.layout.closed_caption_options_dialog) + + //caption enabled/disabled + val ccSwitch = dialog.findViewById(R.id.ccSwitch) + ccSwitch.isChecked = isCaptionsEnabled + ccSwitch.setOnCheckedChangeListener { _, isChecked -> + call?.toggleClosedCaption(isChecked) { + Log.d(TAG, "Caption state toggled to: ${it.data}") + if (isChecked) { + setLanguages( + context, + call?.getClosedCaptionsInfo(), + startLanguageActivityForResult + ) + } + } + } + + // language for captions + setLanguages(context, call?.getClosedCaptionsInfo(), startLanguageActivityForResult) + + // View all captions + dialog.findViewById(R.id.ccShowCaptions).setOnClickListener { + if(shouldDisableCLick(dialog)) return@setOnClickListener + + val closedCaptions = call?.getClosedCaptions() + val intent = Intent(context, ClosedCaptionsActivity::class.java) + intent.putParcelableArrayListExtra( + Constants.Intent.CLOSED_CAPTION_DATA, + getParceableCaptions(closedCaptions) + ) + context.startActivity(intent) + } + + //Close action + dialog.findViewById