Properly integrated SSO in app

This commit is contained in:
Sylvain Berfini 2024-02-19 14:38:50 +01:00
parent 9c3102392b
commit 59ece2f8a8
9 changed files with 338 additions and 178 deletions

View file

@ -173,6 +173,7 @@ dependencies {
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.google.firebase:firebase-crashlytics-ndk'
// https://github.com/openid/AppAuth-Android/blob/master/LICENSE Apache v2.0
implementation 'net.openid:appauth:0.11.1'
android.defaultConfig.manifestPlaceholders = [appAuthRedirectScheme: 'org.linphone']

View file

@ -111,11 +111,6 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.sso.OpenIdActivity"
android:launchMode="singleTask"
android:resizeableActivity="true"/>
<activity
android:name=".ui.call.CallActivity"
android:theme="@style/Theme.LinphoneInCall"

View file

@ -36,7 +36,6 @@ import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantLandingFragmentBinding
import org.linphone.ui.assistant.model.AcceptConditionsAndPolicyDialogModel
import org.linphone.ui.assistant.viewmodel.LandingViewModel
import org.linphone.ui.sso.OpenIdActivity
import org.linphone.utils.DialogUtils
@UiThread
@ -99,19 +98,23 @@ class LandingFragment : Fragment() {
viewModel.redirectToSingleSignOnEvent.observe(viewLifecycleOwner) {
it.consume { address ->
goToSingleSignOnActivity(address)
goToSingleSignOnFragment(address)
}
}
}
private fun goToLoginFragment(identity: String) {
Log.i(
"$TAG Going to Linphone credentials based authentication fragment for SIP account [$identity]"
)
val action = LandingFragmentDirections.actionLandingFragmentToLoginFragment(identity)
findNavController().navigate(action)
}
private fun goToSingleSignOnActivity(identity: String) {
startActivity(Intent(requireContext(), OpenIdActivity::class.java))
requireActivity().finish()
private fun goToSingleSignOnFragment(identity: String) {
Log.i("$TAG Going to Single Sign On fragment for SIP account [$identity]")
val action = LandingFragmentDirections.actionLandingFragmentToSingleSignOnFragment(identity)
findNavController().navigate(action)
}
private fun goToRegisterFragment() {

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* 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/>.
*/
package org.linphone.ui.assistant.fragment
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.UiThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.AssistantSingleSignOnFragmentBinding
import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.assistant.viewmodel.SingleSignOnViewModel
@UiThread
class SingleSignOnFragment : Fragment() {
companion object {
private const val TAG = "[Single Sign On Fragment]"
private const val ACTIVITY_RESULT_ID = 666
}
private lateinit var binding: AssistantSingleSignOnFragmentBinding
private lateinit var viewModel: SingleSignOnViewModel
private val args: SingleSignOnFragmentArgs by navArgs()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = AssistantSingleSignOnFragmentBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.lifecycleOwner = viewLifecycleOwner
viewModel = ViewModelProvider(this)[SingleSignOnViewModel::class.java]
binding.viewModel = viewModel
val identity = args.sipIdentity
Log.i("$TAG SIP Identity found in arguments is [$identity]")
viewModel.preFilledUser = identity
viewModel.singleSignOnProcessCompletedEvent.observe(viewLifecycleOwner) {
it.consume {
Log.i("$TAG Process complete, leaving assistant")
requireActivity().finish()
}
}
viewModel.startAuthIntentEvent.observe(viewLifecycleOwner) {
it.consume { intent ->
Log.i("$TAG Starting auth intent activity")
startActivityForResult(intent, ACTIVITY_RESULT_ID)
}
}
viewModel.onErrorEvent.observe(viewLifecycleOwner) {
it.consume { errorMessage ->
(requireActivity() as AssistantActivity).showRedToast(errorMessage, R.drawable.x)
findNavController().popBackStack()
}
}
viewModel.setUp()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == ACTIVITY_RESULT_ID && data != null) {
val resp = AuthorizationResponse.fromIntent(data)
val ex = AuthorizationException.fromIntent(data)
viewModel.processAuthIntentResponse(resp, ex)
}
super.onActivityResult(requestCode, resultCode, data)
}
}

View file

@ -17,107 +17,93 @@
* 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.sso
package org.linphone.ui.assistant.viewmodel
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.annotation.UiThread
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import kotlinx.coroutines.launch
import net.openid.appauth.AuthState
import net.openid.appauth.AuthState.AuthStateAction
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback
import net.openid.appauth.ResponseTypeValues
import org.linphone.R
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.tools.Log
import org.linphone.databinding.SingleSignOnOpenIdActivityBinding
import org.linphone.ui.GenericActivity
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils
@UiThread
class OpenIdActivity : GenericActivity() {
class SingleSignOnViewModel : ViewModel() {
companion object {
private const val TAG = "[Open ID Activity]"
private const val TAG = "[Single Sign On ViewModel]"
private const val WELL_KNOWN = "https://sso.onhexagone.com//realms/ONHEXAGONE/.well-known/openid-configuration"
private const val CLIENT_ID = "account"
private const val SCOPE = "openid email profile"
private const val REDIRECT_URI = "org.linphone:/openidcallback"
private const val ACTIVITY_RESULT_ID = 666
}
private lateinit var binding: SingleSignOnOpenIdActivityBinding
val singleSignOnProcessCompletedEvent = MutableLiveData<Event<Boolean>>()
val startAuthIntentEvent: MutableLiveData<Event<Intent>> by lazy {
MutableLiveData<Event<Intent>>()
}
val onErrorEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<String>>()
}
var preFilledUser: String = ""
private lateinit var authState: AuthState
private lateinit var authService: AuthorizationService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.single_sign_on_open_id_activity)
binding.lifecycleOwner = this
lifecycleScope.launch {
@UiThread
fun setUp() {
viewModelScope.launch {
Log.i("$TAG Setting up SSO environment, redirect URI is [$REDIRECT_URI]")
authState = getAuthState()
updateTokenInfo()
}
}
binding.setSingleSignOnClickListener {
lifecycleScope.launch {
singleSignOn()
}
@UiThread
fun processAuthIntentResponse(resp: AuthorizationResponse?, ex: AuthorizationException?) {
if (::authState.isInitialized) {
Log.i("$TAG Updating AuthState object after authorization response")
authState.update(resp, ex)
}
binding.setRefreshTokenClickListener {
lifecycleScope.launch {
performRefreshToken()
}
if (resp != null) {
Log.i("$TAG Response isn't null, performing request token")
performRequestToken(resp)
} else {
Log.e("$TAG Can't perform request token [$ex]")
onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty()))
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == ACTIVITY_RESULT_ID && data != null) {
val resp = AuthorizationResponse.fromIntent(data)
val ex = AuthorizationException.fromIntent(data)
if (::authState.isInitialized) {
Log.i("$TAG Updating AuthState object after authorization response")
authState.update(resp, ex)
}
if (resp != null) {
Log.i("$TAG Response isn't null, performing request token")
performRequestToken(resp)
} else {
Log.e("$TAG Can't perform request token [$ex]")
}
}
super.onActivityResult(requestCode, resultCode, data)
}
@UiThread
private fun singleSignOn() {
Log.i("$TAG Fetch from issuer")
AuthorizationServiceConfiguration.fetchFromUrl(
Uri.parse(WELL_KNOWN),
RetrieveConfigurationCallback { serviceConfiguration, ex ->
AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex ->
if (ex != null) {
Log.e("$TAG Failed to fetch configuration")
onErrorEvent.postValue(Event("Failed to fetch configuration"))
return@RetrieveConfigurationCallback
}
if (serviceConfiguration == null) {
Log.e("$TAG Service configuration is null!")
onErrorEvent.postValue(Event("Service configuration is null"))
return@RetrieveConfigurationCallback
}
@ -134,43 +120,25 @@ class OpenIdActivity : GenericActivity() {
Uri.parse(REDIRECT_URI) // the redirect URI to which the auth response is sent
)
if (preFilledUser.isNotEmpty()) {
authRequestBuilder.setLoginHint(preFilledUser)
}
val authRequest = authRequestBuilder
.setScope(SCOPE)
.build()
authService = AuthorizationService(this)
authService = AuthorizationService(coreContext.context)
val authIntent = authService.getAuthorizationRequestIntent(authRequest)
startActivityForResult(authIntent, ACTIVITY_RESULT_ID)
startAuthIntentEvent.postValue(Event(authIntent))
}
)
}
private fun performRequestToken(response: AuthorizationResponse) {
if (::authService.isInitialized) {
Log.i("$TAG Starting perform token request")
authService.performTokenRequest(
response.createTokenExchangeRequest()
) { resp, ex ->
if (resp != null) {
Log.i("$TAG Token exchange succeeded!")
if (::authState.isInitialized) {
Log.i("$TAG Updating AuthState object after token response")
authState.update(resp, ex)
storeAuthStateAsJsonFile()
}
useToken()
} else {
Log.e("$TAG Failed to perform token request [$ex]")
}
}
}
}
@UiThread
private fun performRefreshToken() {
if (::authState.isInitialized) {
if (!::authService.isInitialized) {
authService = AuthorizationService(this)
authService = AuthorizationService(coreContext.context)
}
Log.i("$TAG Starting refresh token request")
@ -190,9 +158,14 @@ class OpenIdActivity : GenericActivity() {
Log.e(
"$TAG Failed to perform token refresh [$ex], destroying auth_state.json file"
)
val file = File(applicationContext.filesDir.absolutePath, "auth_state.json")
lifecycleScope.launch {
onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty()))
val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json")
viewModelScope.launch {
FileUtils.deleteFile(file.absolutePath)
Log.w(
"$TAG Previous auth_state.json file deleted, starting single sign on process from scratch"
)
singleSignOn()
}
}
@ -200,6 +173,32 @@ class OpenIdActivity : GenericActivity() {
}
}
@UiThread
private fun performRequestToken(response: AuthorizationResponse) {
if (::authService.isInitialized) {
Log.i("$TAG Starting perform token request")
authService.performTokenRequest(
response.createTokenExchangeRequest()
) { resp, ex ->
if (resp != null) {
Log.i("$TAG Token exchange succeeded!")
if (::authState.isInitialized) {
Log.i("$TAG Updating AuthState object after token response")
authState.update(resp, ex)
storeAuthStateAsJsonFile()
}
useToken()
} else {
Log.e("$TAG Failed to perform token request [$ex]")
onErrorEvent.postValue(Event(ex?.errorDescription.orEmpty()))
}
}
}
}
@UiThread
private fun useToken() {
if (::authState.isInitialized && ::authService.isInitialized) {
if (authState.needsTokenRefresh && authState.refreshToken.isNullOrEmpty()) {
@ -207,10 +206,11 @@ class OpenIdActivity : GenericActivity() {
return
}
Log.i("$TAG Performing action with fresh token")
singleSignOnProcessCompletedEvent.postValue(Event(true))
/*Log.i("$TAG Performing action with fresh token")
authState.performActionWithFreshTokens(
authService,
AuthStateAction { accessToken, idToken, ex ->
AuthState.AuthStateAction { accessToken, idToken, ex ->
if (ex != null) {
Log.e("$TAG Failed to use token [$ex]")
return@AuthStateAction
@ -221,13 +221,15 @@ class OpenIdActivity : GenericActivity() {
storeAuthStateAsJsonFile()
}
)
)*/
}
}
@UiThread
private suspend fun getAuthState(): AuthState {
val file = File(applicationContext.filesDir.absolutePath, "auth_state.json")
val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json")
if (file.exists()) {
Log.i("$TAG Auth state file found, trying to read it")
val content = FileUtils.readFile(file)
if (content.isNotEmpty()) {
Log.i("$TAG Initializing AuthState from local JSON file")
@ -236,19 +238,23 @@ class OpenIdActivity : GenericActivity() {
return AuthState.jsonDeserialize(content)
} catch (exception: Exception) {
Log.e("$TAG Failed to use serialized AuthState [$exception]")
onErrorEvent.postValue(Event("Failed to read stored AuthState"))
}
}
} else {
Log.i("$TAG Auth state file not found yet...")
}
return AuthState()
}
@UiThread
private fun storeAuthStateAsJsonFile() {
Log.i("$TAG Trying to save serialized authState as JSON file")
val data = authState.jsonSerializeString()
Log.d("$TAG Date to save is [$data]")
val file = File(applicationContext.filesDir.absolutePath, "auth_state.json")
lifecycleScope.launch {
val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json")
viewModelScope.launch {
if (FileUtils.dumpStringToFile(data, file)) {
Log.i("$TAG Service configuration saved as JSON as [${file.absolutePath}]")
} else {
@ -259,20 +265,19 @@ class OpenIdActivity : GenericActivity() {
}
}
@UiThread
private fun updateTokenInfo() {
Log.i("$TAG Updating token info")
if (::authState.isInitialized) {
if (authState.isAuthorized) {
Log.i("$TAG User is already authenticated!")
binding.sso.visibility = View.GONE
binding.tokenRefresh.visibility = View.GONE
binding.tokenExpires.visibility = View.VISIBLE
val expiration = authState.accessTokenExpirationTime
if (expiration != null) {
if (expiration < System.currentTimeMillis()) {
Log.w("$TAG Access token is expired")
binding.tokenExpires.text = "Token expired!"
binding.tokenRefresh.visibility = View.VISIBLE
performRefreshToken()
} else {
val date = if (TimestampUtils.isToday(expiration, timestampInSecs = false)) {
"today"
@ -285,18 +290,23 @@ class OpenIdActivity : GenericActivity() {
}
val time = TimestampUtils.toString(expiration, timestampInSecs = false)
Log.i("$TAG Access token expires [$date] [$time]")
binding.tokenExpires.text = "Token expires $date at $time"
singleSignOnProcessCompletedEvent.postValue(Event(true))
}
} else {
Log.w("$TAG Access token expiration info not available")
binding.tokenExpires.text = "Can't access token expiration!"
val file = File(coreContext.context.filesDir.absolutePath, "auth_state.json")
viewModelScope.launch {
FileUtils.deleteFile(file.absolutePath)
singleSignOn()
}
}
} else {
Log.w("$TAG User isn't authenticated yet!")
binding.sso.visibility = View.VISIBLE
binding.tokenRefresh.visibility = View.GONE
binding.tokenExpires.visibility = View.GONE
Log.w("$TAG User isn't authenticated yet")
singleSignOn()
}
} else {
Log.i("$TAG Auth state hasn't been created yet")
singleSignOn()
}
}
}

View file

@ -0,0 +1,92 @@
<?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.assistant.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"/>
<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_single_sign_on_title"
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>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="singleSignOnClickListener"
type="View.OnClickListener" />
<variable
name="refreshTokenClickListener"
type="View.OnClickListener" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.sso.OpenIdActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatTextView
style="@style/primary_button_label_style"
android:id="@+id/sso"
android:onClick="@{singleSignOnClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="Single sign on"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_800"
android:id="@+id/token_expires"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/primary_button_label_style"
android:id="@+id/token_refresh"
android:onClick="@{refreshTokenClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="Refresh token"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/token_expires"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -172,6 +172,25 @@
app:popExitAnim="@anim/slide_out_right"
app:launchSingleTop="true" />
<action
android:id="@+id/action_landingFragment_to_singleSignOnFragment"
app:destination="@id/singleSignOnFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:launchSingleTop="true" />
</fragment>
<fragment
android:id="@+id/singleSignOnFragment"
android:name="org.linphone.ui.assistant.fragment.SingleSignOnFragment"
android:label="SingleSignOnFragment"
tools:layout="@layout/assistant_single_sign_on_fragment">
<argument
android:name="sipIdentity"
app:argType="string" />
</fragment>
</navigation>

View file

@ -218,6 +218,7 @@
<string name="assistant_permissions_title">Grant permissions</string>
<string name="assistant_permissions_grant_all_of_them">OK</string>
<string name="assistant_permissions_skip_permissions">Do it later</string>
<string name="assistant_single_sign_on_title">Single Sign On</string>
<string name="assistant_permissions_subtitle">To fully enjoy &appName; we need you to grant us the following permissions :</string>
<string name="assistant_permissions_read_contacts_title"><b>Read contacts:</b> To display your contacts and find whom is using &appName;.</string>