Merge pull request #2908 from vector-im/feature/bma/jitsi_pip

PIP support for Jitsi call
This commit is contained in:
Benoit Marty 2021-03-01 11:41:34 +01:00 committed by GitHub
commit c152964323
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 211 additions and 77 deletions

View File

@ -7,6 +7,7 @@ Features ✨:
Improvements 🙌: Improvements 🙌:
- Fetch homeserver type and version and display in a new setting screen and add info in rageshakes (#2831) - Fetch homeserver type and version and display in a new setting screen and add info in rageshakes (#2831)
- Improve initial sync performance (#983) - Improve initial sync performance (#983)
- PIP support for Jitsi call (#2418)
Bugfix 🐛: Bugfix 🐛:
- Try to fix crash about UrlPreview (#2640) - Try to fix crash about UrlPreview (#2640)

View File

@ -83,8 +83,7 @@
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity-alias> </activity-alias>
<activity <activity android:name=".features.home.HomeActivity" />
android:name=".features.home.HomeActivity" />
<activity <activity
android:name=".features.login.LoginActivity" android:name=".features.login.LoginActivity"
android:launchMode="singleTask" android:launchMode="singleTask"
@ -233,11 +232,15 @@
<activity <activity
android:name=".features.attachments.preview.AttachmentsPreviewActivity" android:name=".features.attachments.preview.AttachmentsPreviewActivity"
android:theme="@style/AppTheme.AttachmentsPreview" /> android:theme="@style/AppTheme.AttachmentsPreview" />
<activity android:name=".features.call.VectorCallActivity" <activity
android:name=".features.call.VectorCallActivity"
android:excludeFromRecents="true" /> android:excludeFromRecents="true" />
<!-- PIP Support https://developer.android.com/guide/topics/ui/picture-in-picture -->
<activity <activity
android:name=".features.call.conference.VectorJitsiActivity" android:name=".features.call.conference.VectorJitsiActivity"
android:configChanges="orientation|screenSize" /> android:configChanges="orientation|smallestScreenSize|screenLayout|screenSize"
android:launchMode="singleTask"
android:supportsPictureInPicture="true" />
<activity android:name=".features.terms.ReviewTermsActivity" /> <activity android:name=".features.terms.ReviewTermsActivity" />
<activity android:name=".features.widgets.WidgetActivity" /> <activity android:name=".features.widgets.WidgetActivity" />
@ -247,9 +250,10 @@
<activity android:name=".features.call.transfer.CallTransferActivity" /> <activity android:name=".features.call.transfer.CallTransferActivity" />
<!-- Single instance is very important for the custom scheme callback--> <!-- Single instance is very important for the custom scheme callback-->
<activity android:name=".features.auth.ReAuthActivity" <activity
android:launchMode="singleInstance" android:name=".features.auth.ReAuthActivity"
android:exported="false"> android:exported="false"
android:launchMode="singleInstance">
<!-- XXX: UIA SSO has only web fallback, i.e no url redirect, so for now we comment this out <!-- XXX: UIA SSO has only web fallback, i.e no url redirect, so for now we comment this out
hopefully, we would use it when finally available hopefully, we would use it when finally available

View File

@ -58,6 +58,7 @@ import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.version.VersionProvider import im.vector.app.features.version.VersionProvider
import im.vector.app.push.fcm.FcmHelper import im.vector.app.push.fcm.FcmHelper
import org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler
import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.AuthenticationService
@ -117,6 +118,11 @@ class VectorApplication :
vectorUncaughtExceptionHandler.activate(this) vectorUncaughtExceptionHandler.activate(this)
rxConfig.setupRxPlugin() rxConfig.setupRxPlugin()
// Remove Log handler statically added by Jitsi
Timber.forest()
.filterIsInstance(JitsiMeetDefaultLogHandler::class.java)
.forEach { Timber.uproot(it) }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }

View File

@ -46,7 +46,7 @@ class ActiveConferenceView @JvmOverloads constructor(
} }
var callback: Callback? = null var callback: Callback? = null
var jitsiWidget: Widget? = null private var jitsiWidget: Widget? = null
private lateinit var views: ViewActiveConferenceViewBinding private lateinit var views: ViewActiveConferenceViewBinding
@ -95,18 +95,12 @@ class ActiveConferenceView @JvmOverloads constructor(
val summary = state.asyncRoomSummary() val summary = state.asyncRoomSummary()
if (summary?.membership == Membership.JOIN) { if (summary?.membership == Membership.JOIN) {
// We only display banner for 'live' widgets // We only display banner for 'live' widgets
val activeConf = jitsiWidget = state.activeRoomWidgets()?.firstOrNull {
state.activeRoomWidgets()?.firstOrNull {
// for now only jitsi? // for now only jitsi?
it.type == WidgetType.Jitsi it.type == WidgetType.Jitsi
} }
if (activeConf == null) { isVisible = jitsiWidget != null
isVisible = false
} else {
isVisible = true
jitsiWidget = activeConf
}
// if sent by me or if i can moderate? // if sent by me or if i can moderate?
views.deleteWidgetButton.isVisible = state.isAllowedToManageWidgets views.deleteWidgetButton.isVisible = state.isAllowedToManageWidgets
} else { } else {

View File

@ -18,4 +18,12 @@ package im.vector.app.features.call.conference
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
sealed class JitsiCallViewActions : VectorViewModelAction sealed class JitsiCallViewActions : VectorViewModelAction {
data class SwitchTo(val args: VectorJitsiActivity.Args,
val withConfirmation: Boolean) : JitsiCallViewActions()
/**
* The ViewModel will either ask the View to finish, or to join another conf.
*/
object OnConferenceLeft: JitsiCallViewActions()
}

View File

@ -17,5 +17,21 @@
package im.vector.app.features.call.conference package im.vector.app.features.call.conference
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
import org.jitsi.meet.sdk.JitsiMeetUserInfo
sealed class JitsiCallViewEvents : VectorViewEvents sealed class JitsiCallViewEvents : VectorViewEvents {
data class StartConference(
val enableVideo: Boolean,
val jitsiUrl: String,
val subject: String,
val confId: String,
val userInfo: JitsiMeetUserInfo
) : JitsiCallViewEvents()
data class ConfirmSwitchingConference(
val args: VectorJitsiActivity.Args
) : JitsiCallViewEvents()
object LeaveConference : JitsiCallViewEvents()
object Finish : JitsiCallViewEvents()
}

View File

@ -16,18 +16,25 @@
package im.vector.app.features.call.conference package im.vector.app.features.call.conference
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.themes.ThemeProvider import im.vector.app.features.themes.ThemeProvider
import io.reactivex.disposables.Disposable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jitsi.meet.sdk.JitsiMeetUserInfo import org.jitsi.meet.sdk.JitsiMeetUserInfo
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.asObservable
@ -35,7 +42,6 @@ import java.net.URL
class JitsiCallViewModel @AssistedInject constructor( class JitsiCallViewModel @AssistedInject constructor(
@Assisted initialState: JitsiCallViewState, @Assisted initialState: JitsiCallViewState,
@Assisted val args: VectorJitsiActivity.Args,
private val session: Session, private val session: Session,
private val jitsiMeetPropertiesFactory: JitsiWidgetPropertiesFactory, private val jitsiMeetPropertiesFactory: JitsiWidgetPropertiesFactory,
private val themeProvider: ThemeProvider private val themeProvider: ThemeProvider
@ -43,38 +49,35 @@ class JitsiCallViewModel @AssistedInject constructor(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialState: JitsiCallViewState, args: VectorJitsiActivity.Args): JitsiCallViewModel fun create(initialState: JitsiCallViewState): JitsiCallViewModel
} }
private var currentWidgetObserver: Disposable? = null
private val widgetService = session.widgetService() private val widgetService = session.widgetService()
private var confIsStarted = false
private var pendingArgs: VectorJitsiActivity.Args? = null
init { init {
val me = session.getRoomMember(session.myUserId, args.roomId)?.toMatrixItem() observeWidget(initialState.roomId, initialState.widgetId)
val userInfo = JitsiMeetUserInfo().apply {
displayName = me?.getBestName()
avatar = me?.avatarUrl?.let { session.contentUrlResolver().resolveFullSize(it) }?.let { URL(it) }
}
val roomName = session.getRoomSummary(args.roomId)?.displayName
setState {
copy(userInfo = userInfo)
} }
widgetService.getRoomWidgetsLive(args.roomId, QueryStringValue.Equals(args.widgetId), WidgetType.Jitsi.values()) private fun observeWidget(roomId: String, widgetId: String) {
confIsStarted = false
currentWidgetObserver?.dispose()
currentWidgetObserver = widgetService.getRoomWidgetsLive(roomId, QueryStringValue.Equals(widgetId), WidgetType.Jitsi.values())
.asObservable() .asObservable()
.distinctUntilChanged() .distinctUntilChanged()
.subscribe { .subscribe {
val jitsiWidget = it.firstOrNull() val jitsiWidget = it.firstOrNull()
if (jitsiWidget != null) { if (jitsiWidget != null) {
val ppt = widgetService.getWidgetComputedUrl(jitsiWidget, themeProvider.isLightTheme())
?.let { url -> jitsiMeetPropertiesFactory.create(url) }
setState { setState {
copy( copy(widget = Success(jitsiWidget))
widget = Success(jitsiWidget), }
jitsiUrl = "https://${ppt?.domain}",
confId = ppt?.confId ?: "", if (!confIsStarted) {
subject = roomName ?: "" confIsStarted = true
) startConference(jitsiWidget)
} }
} else { } else {
setState { setState {
@ -87,7 +90,69 @@ class JitsiCallViewModel @AssistedInject constructor(
.disposeOnClear() .disposeOnClear()
} }
private fun startConference(jitsiWidget: Widget) = withState { state ->
val me = session.getRoomMember(session.myUserId, state.roomId)?.toMatrixItem()
val userInfo = JitsiMeetUserInfo().apply {
displayName = me?.getBestName()
avatar = me?.avatarUrl?.let { session.contentUrlResolver().resolveFullSize(it) }?.let { URL(it) }
}
val roomName = session.getRoomSummary(state.roomId)?.displayName
val ppt = widgetService.getWidgetComputedUrl(jitsiWidget, themeProvider.isLightTheme())
?.let { url -> jitsiMeetPropertiesFactory.create(url) }
_viewEvents.post(JitsiCallViewEvents.StartConference(
enableVideo = state.enableVideo,
jitsiUrl = "https://${ppt?.domain}",
subject = roomName ?: "",
confId = ppt?.confId ?: "",
userInfo = userInfo
))
}
override fun handle(action: JitsiCallViewActions) { override fun handle(action: JitsiCallViewActions) {
when (action) {
is JitsiCallViewActions.SwitchTo -> handleSwitchTo(action)
JitsiCallViewActions.OnConferenceLeft -> handleOnConferenceLeft()
}.exhaustive
}
private fun handleSwitchTo(action: JitsiCallViewActions.SwitchTo) = withState { state ->
// Check if it is the same conf
if (action.args.roomId != state.roomId
|| action.args.widgetId != state.widgetId) {
if (action.withConfirmation) {
// Ask confirmation to switch, but wait a bit for the Activity to quit the PiP mode
viewModelScope.launch {
delay(500)
_viewEvents.post(JitsiCallViewEvents.ConfirmSwitchingConference(action.args))
}
} else {
// Ask the view to leave the conf, then the view will tell us when it's done, to join the new conf
pendingArgs = action.args
_viewEvents.post(JitsiCallViewEvents.LeaveConference)
}
}
}
private fun handleOnConferenceLeft() {
val safePendingArgs = pendingArgs
pendingArgs = null
if (safePendingArgs == null) {
// Quit
_viewEvents.post(JitsiCallViewEvents.Finish)
} else {
setState {
copy(
roomId = safePendingArgs.roomId,
widgetId = safePendingArgs.widgetId,
enableVideo = safePendingArgs.enableVideo,
widget = Uninitialized
)
}
observeWidget(safePendingArgs.roomId, safePendingArgs.widgetId)
}
} }
companion object : MvRxViewModelFactory<JitsiCallViewModel, JitsiCallViewState> { companion object : MvRxViewModelFactory<JitsiCallViewModel, JitsiCallViewState> {
@ -97,8 +162,7 @@ class JitsiCallViewModel @AssistedInject constructor(
@JvmStatic @JvmStatic
override fun create(viewModelContext: ViewModelContext, state: JitsiCallViewState): JitsiCallViewModel? { override fun create(viewModelContext: ViewModelContext, state: JitsiCallViewState): JitsiCallViewModel? {
val callActivity: VectorJitsiActivity = viewModelContext.activity() val callActivity: VectorJitsiActivity = viewModelContext.activity()
val callArgs: VectorJitsiActivity.Args = viewModelContext.args() return callActivity.viewModelFactory.create(state)
return callActivity.viewModelFactory.create(state, callArgs)
} }
override fun initialState(viewModelContext: ViewModelContext): JitsiCallViewState? { override fun initialState(viewModelContext: ViewModelContext): JitsiCallViewState? {

View File

@ -19,16 +19,11 @@ package im.vector.app.features.call.conference
import com.airbnb.mvrx.Async import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import org.jitsi.meet.sdk.JitsiMeetUserInfo
import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.Widget
data class JitsiCallViewState( data class JitsiCallViewState(
val roomId: String = "", val roomId: String = "",
val widgetId: String = "", val widgetId: String = "",
val enableVideo: Boolean = true, val enableVideo: Boolean = false,
val jitsiUrl: String = "",
val subject: String = "",
val confId: String = "",
val userInfo: JitsiMeetUserInfo = JitsiMeetUserInfo(),
val widget: Async<Widget> = Uninitialized val widget: Async<Widget> = Uninitialized
) : MvRxState ) : MvRxState

View File

@ -20,9 +20,12 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
@ -30,7 +33,9 @@ import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel import com.airbnb.mvrx.viewModel
import com.facebook.react.modules.core.PermissionListener import com.facebook.react.modules.core.PermissionListener
import im.vector.app.R
import im.vector.app.core.di.ScreenComponent import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityJitsiBinding import im.vector.app.databinding.ActivityJitsiBinding
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -80,9 +85,39 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
renderState(it) renderState(it)
} }
jitsiViewModel.observeViewEvents {
when (it) {
is JitsiCallViewEvents.StartConference -> configureJitsiView(it)
is JitsiCallViewEvents.ConfirmSwitchingConference -> handleConfirmSwitching(it)
JitsiCallViewEvents.Finish -> finish()
JitsiCallViewEvents.LeaveConference -> handleLeaveConference()
}.exhaustive
}
registerForBroadcastMessages() registerForBroadcastMessages()
} }
private fun handleLeaveConference() {
jitsiMeetView?.leave()
}
private fun handleConfirmSwitching(action: JitsiCallViewEvents.ConfirmSwitchingConference) {
AlertDialog.Builder(this)
.setTitle(R.string.dialog_title_warning)
.setMessage(R.string.jitsi_leave_conf_to_join_another_one_content)
.setPositiveButton(R.string.action_switch) { _, _ ->
jitsiViewModel.handle(JitsiCallViewActions.SwitchTo(action.args, false))
}
.setNegativeButton(R.string.cancel, null)
.show()
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,
newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
Timber.w("onPictureInPictureModeChanged($isInPictureInPictureMode)")
}
override fun initUiAndData() { override fun initUiAndData() {
super.initUiAndData() super.initUiAndData()
jitsiMeetView = JitsiMeetView(this) jitsiMeetView = JitsiMeetView(this)
@ -96,7 +131,6 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
is Success -> { is Success -> {
views.jitsiProgressLayout.isVisible = false views.jitsiProgressLayout.isVisible = false
jitsiMeetView?.isVisible = true jitsiMeetView?.isVisible = true
configureJitsiView(viewState)
} }
else -> { else -> {
jitsiMeetView?.isVisible = false jitsiMeetView?.isVisible = false
@ -105,12 +139,12 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
} }
} }
private fun configureJitsiView(viewState: JitsiCallViewState) { private fun configureJitsiView(startConference: JitsiCallViewEvents.StartConference) {
val jitsiMeetConferenceOptions = JitsiMeetConferenceOptions.Builder() val jitsiMeetConferenceOptions = JitsiMeetConferenceOptions.Builder()
.setVideoMuted(!viewState.enableVideo) .setVideoMuted(!startConference.enableVideo)
.setUserInfo(viewState.userInfo) .setUserInfo(startConference.userInfo)
.apply { .apply {
tryOrNull { URL(viewState.jitsiUrl) }?.let { tryOrNull { URL(startConference.jitsiUrl) }?.let {
setServerURL(it) setServerURL(it)
} }
} }
@ -120,15 +154,15 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
.setFeatureFlag("add-people.enabled", false) .setFeatureFlag("add-people.enabled", false)
.setFeatureFlag("video-share.enabled", false) .setFeatureFlag("video-share.enabled", false)
.setFeatureFlag("call-integration.enabled", false) .setFeatureFlag("call-integration.enabled", false)
.setRoom(viewState.confId) .setRoom(startConference.confId)
.setSubject(viewState.subject) .setSubject(startConference.subject)
.build() .build()
jitsiMeetView?.join(jitsiMeetConferenceOptions) jitsiMeetView?.join(jitsiMeetConferenceOptions)
} }
override fun onPause() { override fun onStop() {
JitsiMeetActivityDelegate.onHostPause(this) JitsiMeetActivityDelegate.onHostPause(this)
super.onPause() super.onStop()
} }
override fun onResume() { override fun onResume() {
@ -147,13 +181,23 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
super.onDestroy() super.onDestroy()
} }
// override fun onUserLeaveHint() { override fun onUserLeaveHint() {
// super.onUserLeaveHint() super.onUserLeaveHint()
// jitsiMeetView?.enterPictureInPicture() if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
// } jitsiMeetView?.enterPictureInPicture()
}
}
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
JitsiMeetActivityDelegate.onNewIntent(intent) JitsiMeetActivityDelegate.onNewIntent(intent)
// Is it a switch to another conf?
intent?.takeIf { it.hasExtra(MvRx.KEY_ARG) }
?.let { intent.getParcelableExtra<Args>(MvRx.KEY_ARG) }
?.let {
jitsiViewModel.handle(JitsiCallViewActions.SwitchTo(it, true))
}
super.onNewIntent(intent) super.onNewIntent(intent)
} }
@ -195,7 +239,7 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
Timber.v("JitsiMeetViewListener.onConferenceTerminated()") Timber.v("JitsiMeetViewListener.onConferenceTerminated()")
// Do not finish if there is an error // Do not finish if there is an error
if (data["error"] == null) { if (data["error"] == null) {
finish() jitsiViewModel.handle(JitsiCallViewActions.OnConferenceLeft)
} }
} }
@ -203,7 +247,6 @@ class VectorJitsiActivity : VectorBaseActivity<ActivityJitsiBinding>(), JitsiMee
fun newIntent(context: Context, roomId: String, widgetId: String, enableVideo: Boolean): Intent { fun newIntent(context: Context, roomId: String, widgetId: String, enableVideo: Boolean): Intent {
return Intent(context, VectorJitsiActivity::class.java).apply { return Intent(context, VectorJitsiActivity::class.java).apply {
putExtra(MvRx.KEY_ARG, Args(roomId, widgetId, enableVideo)) putExtra(MvRx.KEY_ARG, Args(roomId, widgetId, enableVideo))
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
} }
} }
} }

View File

@ -133,6 +133,7 @@
<string name="action_close">Close</string> <string name="action_close">Close</string>
<string name="action_copy">Copy</string> <string name="action_copy">Copy</string>
<string name="action_add">Add</string> <string name="action_add">Add</string>
<string name="action_switch">Switch</string>
<string name="action_unpublish">Unpublish</string> <string name="action_unpublish">Unpublish</string>
<string name="copied_to_clipboard">Copied to clipboard</string> <string name="copied_to_clipboard">Copied to clipboard</string>
<string name="disable">Disable</string> <string name="disable">Disable</string>
@ -1318,6 +1319,8 @@
<string name="error_jitsi_not_supported_on_old_device">Sorry, conference calls with Jitsi are not supported on old devices (devices with Android OS below 6.0)</string> <string name="error_jitsi_not_supported_on_old_device">Sorry, conference calls with Jitsi are not supported on old devices (devices with Android OS below 6.0)</string>
<string name="jitsi_leave_conf_to_join_another_one_content">Leave the current conference and switch to the other one?</string>
<string name="room_widget_resource_permission_title">This widget wants to use the following resources:</string> <string name="room_widget_resource_permission_title">This widget wants to use the following resources:</string>
<string name="room_widget_resource_grant_permission">Allow</string> <string name="room_widget_resource_grant_permission">Allow</string>
<string name="room_widget_resource_decline_permission">Block All</string> <string name="room_widget_resource_decline_permission">Block All</string>