diff --git a/app/build.gradle b/app/build.gradle
index e25d223cb..9833f9ac5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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']
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 74a60260f..74a8e2dd2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -111,11 +111,6 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
-
-
- 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() {
diff --git a/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt b/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt
new file mode 100644
index 000000000..9b893e0c2
--- /dev/null
+++ b/app/src/main/java/org/linphone/ui/assistant/fragment/SingleSignOnFragment.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt b/app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt
similarity index 68%
rename from app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt
rename to app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt
index dec1ac7f7..49e4b2ec1 100644
--- a/app/src/main/java/org/linphone/ui/sso/OpenIdActivity.kt
+++ b/app/src/main/java/org/linphone/ui/assistant/viewmodel/SingleSignOnViewModel.kt
@@ -17,107 +17,93 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-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>()
+
+ val startAuthIntentEvent: MutableLiveData> by lazy {
+ MutableLiveData>()
+ }
+
+ val onErrorEvent: MutableLiveData> by lazy {
+ MutableLiveData>()
+ }
+
+ 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()
}
}
}
diff --git a/app/src/main/res/layout/assistant_single_sign_on_fragment.xml b/app/src/main/res/layout/assistant_single_sign_on_fragment.xml
new file mode 100644
index 000000000..70518d18b
--- /dev/null
+++ b/app/src/main/res/layout/assistant_single_sign_on_fragment.xml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/single_sign_on_open_id_activity.xml b/app/src/main/res/layout/single_sign_on_open_id_activity.xml
deleted file mode 100644
index 7f06df1db..000000000
--- a/app/src/main/res/layout/single_sign_on_open_id_activity.xml
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/navigation/assistant_nav_graph.xml b/app/src/main/res/navigation/assistant_nav_graph.xml
index f217e088b..6d25cbb9d 100644
--- a/app/src/main/res/navigation/assistant_nav_graph.xml
+++ b/app/src/main/res/navigation/assistant_nav_graph.xml
@@ -172,6 +172,25 @@
app:popExitAnim="@anim/slide_out_right"
app:launchSingleTop="true" />
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6e0fccf67..b6fdd19b8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -218,6 +218,7 @@
Grant permissions
OK
Do it later
+ Single Sign On
To fully enjoy &appName; we need you to grant us the following permissions :
Read contacts: To display your contacts and find whom is using &appName;.