Reworked auth requested callback & SSO activity to handle Bearer authentication requests

This commit is contained in:
Sylvain Berfini 2024-04-30 11:24:11 +02:00
parent ebf9fa9145
commit 507fb8a3ce
11 changed files with 324 additions and 124 deletions

View file

@ -114,21 +114,6 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.assistant.SingleSignOnActivity"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="linphone-sso" />
</intent-filter>
</activity>
<activity
android:name=".ui.call.CallActivity"
android:theme="@style/Theme.LinphoneInCall"

View file

@ -72,6 +72,17 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
private val mainThread = Handler(Looper.getMainLooper())
var bearerAuthInfoPendingPasswordUpdate: AuthInfo? = null
var digestAuthInfoPendingPasswordUpdate: AuthInfo? = null
val bearerAuthenticationRequestedEvent: MutableLiveData<Event<Pair<String, String?>>> by lazy {
MutableLiveData<Event<Pair<String, String?>>>()
}
val digestAuthenticationRequestedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
val greenToastToShowEvent: MutableLiveData<Event<Pair<String, Int>>> by lazy {
MutableLiveData<Event<Pair<String, Int>>>()
}
@ -210,6 +221,57 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
}
}
}
@WorkerThread
override fun onAuthenticationRequested(core: Core, authInfo: AuthInfo, method: AuthMethod) {
if (authInfo.username == null || authInfo.domain == null || authInfo.realm == null) {
Log.e(
"$TAG Authentication request but either username [${authInfo.username}], domain [${authInfo.domain}] or realm [${authInfo.realm}] is null!"
)
return
}
when (method) {
AuthMethod.Bearer -> {
val serverUrl = authInfo.authorizationServer
val username = authInfo.username
if (!serverUrl.isNullOrEmpty()) {
Log.i(
"$TAG Authentication requested method is Bearer, starting Single Sign On activity with server URL [$serverUrl] and username [$username]"
)
bearerAuthInfoPendingPasswordUpdate = authInfo
bearerAuthenticationRequestedEvent.postValue(
Event(Pair(serverUrl, username))
)
} else {
Log.e(
"$TAG Authentication requested method is Bearer but no authorization server was found in auth info!"
)
}
}
AuthMethod.HttpDigest -> {
val accountFound = core.accountList.find {
it.params.identityAddress?.username == authInfo.username && it.params.identityAddress?.domain == authInfo.domain
}
if (accountFound == null) {
Log.w(
"$TAG Failed to find account matching auth info, aborting auth dialog"
)
return
}
val identity = "${authInfo.username}@${authInfo.domain}"
Log.i(
"$TAG Authentication requested method is HttpDigest, showing dialog asking user for password for identity [$identity]"
)
digestAuthInfoPendingPasswordUpdate = authInfo
digestAuthenticationRequestedEvent.postValue(Event(identity))
}
AuthMethod.Tls -> {
Log.w("$TAG Authentication requested method is TLS, not doing anything...")
}
}
}
}
private val loggingServiceListener = object : LoggingServiceListenerStub() {
@ -392,6 +454,22 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C
}
}
@WorkerThread
fun updateAuthInfo(password: String) {
val authInfo = digestAuthInfoPendingPasswordUpdate
if (authInfo != null) {
Log.i(
"$TAG Updating password for username [${authInfo.username}] using auth info [$authInfo]"
)
authInfo.password = password
core.addAuthInfo(authInfo)
digestAuthInfoPendingPasswordUpdate = null
core.refreshRegisters()
} else {
Log.e("$TAG No pending auth info for digest authentication!")
}
}
@WorkerThread
fun isAddressMyself(address: Address): Boolean {
val found = core.accountList.find {

View file

@ -53,6 +53,7 @@ import org.linphone.ui.GenericActivity
import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.main.chat.fragment.ConversationsListFragmentDirections
import org.linphone.ui.main.fragment.AuthRequestedDialogModel
import org.linphone.ui.main.sso.fragment.SingleSignOnFragmentDirections
import org.linphone.ui.main.viewmodel.MainViewModel
import org.linphone.ui.main.viewmodel.SharedMainViewModel
import org.linphone.ui.welcome.WelcomeActivity
@ -164,12 +165,6 @@ class MainActivity : GenericActivity() {
}
}
viewModel.authenticationRequestedEvent.observe(this) {
it.consume { identity ->
showAuthenticationRequestedDialog(identity)
}
}
binding.root.doOnAttach {
Log.i("$TAG Report UI has been fully drawn (TTFD)")
try {
@ -179,6 +174,28 @@ class MainActivity : GenericActivity() {
}
}
coreContext.bearerAuthenticationRequestedEvent.observe(this) {
it.consume { pair ->
val serverUrl = pair.first
val username = pair.second
Log.i(
"$TAG Navigating to Single Sign On Fragment with server URL [$serverUrl] and username [$username]"
)
val action = SingleSignOnFragmentDirections.actionGlobalSingleSignOnFragment(
serverUrl,
username
)
findNavController().navigate(action)
}
}
coreContext.digestAuthenticationRequestedEvent.observe(this) {
it.consume { identity ->
showAuthenticationRequestedDialog(identity)
}
}
coreContext.greenToastToShowEvent.observe(this) {
it.consume { pair ->
val message = pair.first
@ -586,7 +603,9 @@ class MainActivity : GenericActivity() {
model.confirmEvent.observe(this) {
it.consume { password ->
viewModel.updateAuthInfo(password)
coreContext.postOnCoreThread {
coreContext.updateAuthInfo(password)
}
dialog.dismiss()
}
}

View file

@ -30,6 +30,7 @@ import org.linphone.ui.main.fragment.GenericFragment
@UiThread
class RecordingsFragment : GenericFragment() {
private lateinit var binding: RecordingsFragmentBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,

View file

@ -17,83 +17,80 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.assistant
package org.linphone.ui.main.sso.fragment
import android.content.Intent
import android.os.Bundle
import androidx.annotation.UiThread
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantSingleSignOnActivityBinding
import org.linphone.databinding.SingleSignOnFragmentBinding
import org.linphone.ui.GenericActivity
import org.linphone.ui.assistant.viewmodel.SingleSignOnViewModel
import org.linphone.ui.main.fragment.GenericFragment
import org.linphone.ui.main.sso.viewmodel.SingleSignOnViewModel
@UiThread
class SingleSignOnActivity : GenericActivity() {
class SingleSignOnFragment : GenericFragment() {
companion object {
private const val TAG = "[Single Sign On Activity]"
private const val TAG = "[Single Sign On Fragment]"
private const val ACTIVITY_RESULT_ID = 666
}
private lateinit var binding: AssistantSingleSignOnActivityBinding
private lateinit var binding: SingleSignOnFragmentBinding
private lateinit var viewModel: SingleSignOnViewModel
private val viewModel: SingleSignOnViewModel by navGraphViewModels(
R.id.main_nav_graph
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
private val args: SingleSignOnFragmentArgs by navArgs()
binding = DataBindingUtil.setContentView(this, R.layout.assistant_single_sign_on_activity)
binding.lifecycleOwner = this
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = SingleSignOnFragmentBinding.inflate(layoutInflater)
return binding.root
}
viewModel = ViewModelProvider(this)[SingleSignOnViewModel::class.java]
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel
setUpToastsArea(binding.toastsArea)
if (intent != null) {
Log.i(
"$TAG Handling intent action [${intent.action}], type [${intent.type}] and data [${intent.data}]"
)
val uri = intent.data?.toString() ?: ""
if (uri.startsWith("linphone-sso:")) {
val ssoUrl = uri.replace("linphone-sso:", "https:")
Log.i("$TAG Setting SSO URL [$ssoUrl]")
viewModel.singleSignOnUrl.value = ssoUrl
}
}
viewModel.singleSignOnUrl.observe(this) { url ->
Log.i("$TAG SSO URL found [$url], setting it up")
viewModel.setUp()
}
viewModel.singleSignOnProcessCompletedEvent.observe(this) {
viewModel.singleSignOnProcessCompletedEvent.observe(viewLifecycleOwner) {
it.consume {
Log.i("$TAG Process complete, leaving assistant")
finish()
Log.i("$TAG Process complete, going back")
goBack()
}
}
viewModel.startAuthIntentEvent.observe(this) {
viewModel.startAuthIntentEvent.observe(viewLifecycleOwner) {
it.consume { intent ->
Log.i("$TAG Starting auth intent activity")
startActivityForResult(intent, ACTIVITY_RESULT_ID)
}
}
viewModel.onErrorEvent.observe(this) {
viewModel.onErrorEvent.observe(viewLifecycleOwner) {
it.consume { errorMessage ->
showRedToast(
(requireActivity() as GenericActivity).showRedToast(
errorMessage,
R.drawable.warning_circle
)
}
}
val serverUrl = args.serverUrl
val username = args.username
Log.i("$TAG Found server URL [$serverUrl] and username [$username] in args")
viewModel.setUp(serverUrl, username.orEmpty())
}
@Deprecated("Deprecated in Java")

View file

@ -17,7 +17,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.ui.assistant.viewmodel
package org.linphone.ui.main.sso.viewmodel
import android.content.Intent
import android.net.Uri
@ -35,6 +35,7 @@ import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Factory
import org.linphone.core.tools.Log
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
@ -50,7 +51,9 @@ class SingleSignOnViewModel : ViewModel() {
val singleSignOnProcessCompletedEvent = MutableLiveData<Event<Boolean>>()
val singleSignOnUrl = MutableLiveData<String>()
private var singleSignOnUrl = ""
private var username: String = ""
val startAuthIntentEvent: MutableLiveData<Event<Intent>> by lazy {
MutableLiveData<Event<Intent>>()
@ -60,15 +63,18 @@ class SingleSignOnViewModel : ViewModel() {
MutableLiveData<Event<String>>()
}
private var preFilledUser: String = ""
private lateinit var authState: AuthState
private lateinit var authService: AuthorizationService
@UiThread
fun setUp() {
fun setUp(ssoUrl: String, user: String = "") {
viewModelScope.launch {
Log.i("$TAG Setting up SSO environment, redirect URI is [$REDIRECT_URI]")
singleSignOnUrl = ssoUrl
username = user
Log.i(
"$TAG Setting up SSO environment for username [$username] and URL [$singleSignOnUrl], redirect URI is [$REDIRECT_URI]"
)
authState = getAuthState()
updateTokenInfo()
}
@ -94,7 +100,7 @@ class SingleSignOnViewModel : ViewModel() {
private fun singleSignOn() {
Log.i("$TAG Fetch from issuer")
AuthorizationServiceConfiguration.fetchFromUrl(
Uri.parse(singleSignOnUrl.value),
Uri.parse(singleSignOnUrl),
AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex ->
if (ex != null) {
Log.e("$TAG Failed to fetch configuration")
@ -120,8 +126,8 @@ class SingleSignOnViewModel : ViewModel() {
Uri.parse(REDIRECT_URI) // the redirect URI to which the auth response is sent
)
if (preFilledUser.isNotEmpty()) {
authRequestBuilder.setLoginHint(preFilledUser)
if (username.isNotEmpty()) {
authRequestBuilder.setLoginHint(username)
}
val authRequest = authRequestBuilder.build()
@ -187,7 +193,7 @@ class SingleSignOnViewModel : ViewModel() {
storeAuthStateAsJsonFile()
}
useToken()
storeTokensInAuthInfo()
} else {
Log.e("$TAG Failed to perform token request [$ex]")
onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty()))
@ -214,9 +220,7 @@ class SingleSignOnViewModel : ViewModel() {
return@AuthStateAction
}
Log.i("$$TAG Access & id tokens are now available")
Log.d("$TAG Access token [$accessToken], id token [$idToken]")
Log.i("$TAG Access & id tokens are now available")
storeAuthStateAsJsonFile()
}
)*/
@ -288,7 +292,7 @@ class SingleSignOnViewModel : ViewModel() {
}
val time = TimestampUtils.toString(expiration, timestampInSecs = false)
Log.i("$TAG Access token expires [$date] [$time]")
singleSignOnProcessCompletedEvent.postValue(Event(true))
storeTokensInAuthInfo()
}
} else {
Log.w("$TAG Access token expiration info not available")
@ -307,4 +311,35 @@ class SingleSignOnViewModel : ViewModel() {
singleSignOn()
}
}
@UiThread
private fun storeTokensInAuthInfo() {
coreContext.postOnCoreThread { core ->
val expire = authState.accessTokenExpirationTime
if (expire == null) {
Log.e("$TAG Access token expiration time is null!")
onErrorEvent.postValue(Event("Invalid access token expiration time"))
} else {
val accessToken =
Factory.instance().createBearerToken(authState.accessToken, expire)
val refreshToken =
Factory.instance().createBearerToken(authState.refreshToken, expire)
val authInfo = coreContext.bearerAuthInfoPendingPasswordUpdate
if (authInfo == null) {
Log.e("$TAG No pending auth info in CoreContext!")
return@postOnCoreThread
}
authInfo.accessToken = accessToken
authInfo.refreshToken = refreshToken
core.addAuthInfo(authInfo)
Log.i(
"$TAG Auth info for username [$username] filled with access token [${authState.accessToken}], refresh token [${authState.refreshToken}] and expire [$expire], refreshing REGISTERs"
)
core.refreshRegisters()
singleSignOnProcessCompletedEvent.postValue(Event(true))
}
}
}
}

View file

@ -34,8 +34,6 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.LinphoneApplication.Companion.corePreferences
import org.linphone.R
import org.linphone.core.Account
import org.linphone.core.AuthInfo
import org.linphone.core.AuthMethod
import org.linphone.core.Call
import org.linphone.core.Core
import org.linphone.core.CoreListenerStub
@ -93,10 +91,6 @@ class MainViewModel @UiThread constructor() : ViewModel() {
MutableLiveData<Event<Boolean>>()
}
val authenticationRequestedEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
var accountsFound = -1
var mainIntentHandled = false
@ -109,8 +103,6 @@ class MainViewModel @UiThread constructor() : ViewModel() {
private var firstAccountRegistered: Boolean = false
private var authInfoPendingPasswordUpdate: AuthInfo? = null
private val coreListener = object : CoreListenerStub() {
@WorkerThread
override fun onLastCallEnded(core: Core) {
@ -237,27 +229,6 @@ class MainViewModel @UiThread constructor() : ViewModel() {
core.defaultAccount = core.accountList.firstOrNull()
}
}
@WorkerThread
override fun onAuthenticationRequested(core: Core, authInfo: AuthInfo, method: AuthMethod) {
if (authInfo.username == null || authInfo.domain == null || authInfo.realm == null) {
return
}
Log.w(
"$TAG Authentication requested for account [${authInfo.username}@${authInfo.domain}] with realm [${authInfo.realm}] using method [$method]"
)
val accountFound = core.accountList.find {
it.params.identityAddress?.username == authInfo.username && it.params.identityAddress?.domain == authInfo.domain
}
if (accountFound == null) {
Log.w("$TAG Failed to find account matching auth info, aborting auth dialog")
return
}
val identity = "${authInfo.username}@${authInfo.domain}"
authInfoPendingPasswordUpdate = authInfo
authenticationRequestedEvent.postValue(Event(identity))
}
}
init {
@ -340,22 +311,6 @@ class MainViewModel @UiThread constructor() : ViewModel() {
}
}
@UiThread
fun updateAuthInfo(password: String) {
coreContext.postOnCoreThread { core ->
val authInfo = authInfoPendingPasswordUpdate
if (authInfo != null) {
Log.i(
"$TAG Updating password for username [${authInfo.username}] using auth info [$authInfo]"
)
authInfo.password = password
core.addAuthInfo(authInfo)
authInfoPendingPasswordUpdate = null
core.refreshRegisters()
}
}
}
@WorkerThread
private fun updateCallAlert() {
val core = coreContext.core

View file

@ -250,7 +250,7 @@
android:textColor="@color/gray_main2_600"
android:maxLines="1"
android:background="@drawable/edit_text_background"
android:inputType="text|textPersonName"
android:inputType="text|textPersonName|textCapSentences"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintWidth_max="@dimen/text_input_max_width"
app:layout_constraintTop_toBottomOf="@id/display_name_label"
@ -403,6 +403,7 @@
android:layout_marginEnd="16dp"
android:contentDescription="@null"
android:src="@drawable/shape_squircle_white_background"
android:visibility="@{viewModel.showModeSelection ? View.VISIBLE : View.GONE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/connection_background"
@ -416,6 +417,7 @@
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:text="@{viewModel.isCurrentlySelectedModeSecure ? @string/manage_account_e2e_encrypted_mode_default_title : @string/manage_account_e2e_encrypted_mode_interoperable_title, default=@string/manage_account_e2e_encrypted_mode_default_title}"
android:visibility="@{viewModel.showModeSelection ? View.VISIBLE : View.GONE}"
app:layout_constraintTop_toTopOf="@id/mode_background"
app:layout_constraintStart_toStartOf="@id/mode_background"
app:layout_constraintBottom_toBottomOf="@id/mode_background"/>
@ -436,6 +438,7 @@
android:text="@string/manage_account_change_mode"
android:maxLines="1"
android:ellipsize="end"
android:visibility="@{viewModel.showModeSelection ? View.VISIBLE : View.GONE}"
app:layout_constraintEnd_toEndOf="@id/mode_background"
app:layout_constraintTop_toTopOf="@id/mode_background"
app:layout_constraintBottom_toBottomOf="@id/mode_background"/>

View file

@ -6,7 +6,7 @@
<import type="android.view.View" />
<variable
name="viewModel"
type="org.linphone.ui.assistant.viewmodel.SingleSignOnViewModel" />
type="org.linphone.ui.main.sso.viewmodel.SingleSignOnViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="viewModel"
type="org.linphone.ui.main.sso.viewmodel.SingleSignOnViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/color_main2_000">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/back"
android:layout_width="@dimen/top_bar_height"
android:layout_height="@dimen/top_bar_height"
android:padding="15dp"
android:src="@drawable/caret_left"
android:visibility="invisible"
android:contentDescription="@string/content_description_go_back_icon"
app:tint="?attr/color_main2_500"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/header"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="19dp"
android:src="@drawable/mountains"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:contentDescription="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/back"
app:layout_constraintBottom_toBottomOf="@id/title"
app:tint="@color/main1_500"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/assistant_page_title_style"
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:paddingBottom="27dp"
android:text="@string/assistant_login_using_single_sign_on"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
app:indicatorColor="?attr/color_main1_500"
android:indeterminate="true"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@id/message"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_800"
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/operation_in_progress_overlay"
android:textColor="?attr/color_main1_500"
android:textSize="18sp"
android:layout_below="@id/progress"
android:layout_centerHorizontal="true"
app:layout_constraintTop_toBottomOf="@id/progress"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/toasts_area"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="@dimen/toast_top_margin"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
app:layout_constraintWidth_max="@dimen/toast_max_width"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -437,4 +437,25 @@
android:label="SettingsAdvancedFragment"
tools:layout="@layout/settings_advanced_fragment"/>
<fragment
android:id="@+id/singleSignOnFragment"
android:name="org.linphone.ui.main.sso.fragment.SingleSignOnFragment"
android:label="SingleSignOnFragment"
tools:layout="@layout/single_sign_on_fragment">
<argument
android:name="serverUrl"
app:argType="string" />
<argument
android:name="username"
app:argType="string"
app:nullable="true"
android:defaultValue="@null" />
</fragment>
<action android:id="@+id/action_global_singleSignOnFragment"
app:destination="@id/singleSignOnFragment"
app:launchSingleTop="true"
app:enterAnim="@anim/slide_in"
app:popExitAnim="@anim/slide_out" />
</navigation>