diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c83276f45..b653d23ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -267,6 +267,12 @@ dependencies { implementation(libs.openid.appauth) implementation(libs.linphone) + + testImplementation(libs.junit) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) } configure { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bd5a3d7af..47007d956 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,6 +55,7 @@ android:theme="@style/Theme.Linphone" android:appCategory="social" android:largeHeap="true" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="35"> diff --git a/app/src/main/java/org/linphone/ui/main/sso/utils/AppAuthConnectionBuilder.kt b/app/src/main/java/org/linphone/ui/main/sso/utils/AppAuthConnectionBuilder.kt new file mode 100644 index 000000000..c0c02b6cd --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/sso/utils/AppAuthConnectionBuilder.kt @@ -0,0 +1,50 @@ +/* + * 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.main.sso.utils + +import android.net.Uri +import net.openid.appauth.connectivity.ConnectionBuilder +import java.net.HttpURLConnection +import java.net.URL +import java.security.cert.X509Certificate +import javax.net.ssl.* + +class AppAuthConnectionBuilder(private val trustAll: Boolean) : ConnectionBuilder { + override fun openConnection(uri: Uri): HttpURLConnection { + val conn = URL(uri.toString()).openConnection() as HttpURLConnection + if (trustAll && conn is HttpsURLConnection) { + conn.sslSocketFactory = TRUSTING_CONTEXT.socketFactory + conn.hostnameVerifier = HostnameVerifier { _, _ -> true } + } + return conn + } + + companion object { + private val TRUSTING_CONTEXT: SSLContext by lazy { + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }), java.security.SecureRandom()) + context + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt b/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt index 80e468920..dba844189 100644 --- a/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/sso/viewmodel/SingleSignOnViewModel.kt @@ -21,10 +21,12 @@ package org.linphone.ui.main.sso.viewmodel import android.content.Intent import androidx.annotation.UiThread +import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import java.io.File import kotlinx.coroutines.launch +import net.openid.appauth.AppAuthConfiguration import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationRequest @@ -39,10 +41,10 @@ import org.linphone.R import org.linphone.core.Factory import org.linphone.core.tools.Log import org.linphone.ui.GenericViewModel +import org.linphone.ui.main.sso.utils.AppAuthConnectionBuilder import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.TimestampUtils -import androidx.core.net.toUri class SingleSignOnViewModel @UiThread @@ -127,9 +129,12 @@ class SingleSignOnViewModel @UiThread private fun singleSignOn() { Log.i("$TAG Fetch from issuer [$singleSignOnUrl]") - AuthorizationServiceConfiguration.fetchFromIssuer( - singleSignOnUrl.toUri(), - AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> + val connectionBuilder = AppAuthConnectionBuilder(true) + val callback = object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback { + override fun onFetchConfigurationCompleted( + serviceConfiguration: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { if (ex != null) { Log.e( "$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}" @@ -137,13 +142,13 @@ class SingleSignOnViewModel onErrorEvent.postValue( Event("Failed to fetch configuration from issuer $singleSignOnUrl") ) - return@RetrieveConfigurationCallback + return } if (serviceConfiguration == null) { Log.e("$TAG Service configuration is null!") onErrorEvent.postValue(Event("Service configuration is null")) - return@RetrieveConfigurationCallback + return } if (!::authState.isInitialized) { @@ -169,10 +174,19 @@ class SingleSignOnViewModel } val authRequest = authRequestBuilder.build() - authService = AuthorizationService(coreContext.context) + val authConfig = AppAuthConfiguration.Builder() + .setConnectionBuilder(connectionBuilder) + .build() + authService = AuthorizationService(coreContext.context, authConfig) val authIntent = authService.getAuthorizationRequestIntent(authRequest) startAuthIntentEvent.postValue(Event(authIntent)) } + } + + AuthorizationServiceConfiguration.fetchFromIssuer( + singleSignOnUrl.toUri(), + callback, + connectionBuilder ) } @@ -180,7 +194,10 @@ class SingleSignOnViewModel private fun performRefreshToken() { if (::authState.isInitialized) { if (!::authService.isInitialized) { - authService = AuthorizationService(coreContext.context) + val authConfig = AppAuthConfiguration.Builder() + .setConnectionBuilder(AppAuthConnectionBuilder(true)) + .build() + authService = AuthorizationService(coreContext.context, authConfig) } val authStateJsonFile = File(corePreferences.ssoCacheFile) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..d20fb8331 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/test/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModelTest.kt b/app/src/test/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModelTest.kt new file mode 100644 index 000000000..761416d9f --- /dev/null +++ b/app/src/test/java/org/linphone/ui/assistant/viewmodel/AccountLoginViewModelTest.kt @@ -0,0 +1,94 @@ +/* + * 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.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.linphone.LinphoneApplication +import org.linphone.core.CoreContext +import org.linphone.core.CorePreferences + +class AccountLoginViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: AccountLoginViewModel + private val coreContext: CoreContext = mockk(relaxed = true) + private val corePreferences: CorePreferences = mockk(relaxed = true) + + @Before + fun setUp() { + mockkObject(LinphoneApplication.Companion) + every { LinphoneApplication.coreContext } returns coreContext + every { LinphoneApplication.corePreferences } returns corePreferences + + // Mock postOnCoreThread to execute the lambda immediately + every { coreContext.postOnCoreThread(any()) } answers { + val lambda = invocation.args[0] as (org.linphone.core.Core) -> Unit + lambda(mockk(relaxed = true)) + } + + viewModel = AccountLoginViewModel() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun testLoginButtonEnabledOnlyWhenBothFieldsAreFilled() { + // MediatorLiveData needs an observer to be active and trigger its logic + viewModel.loginEnabled.observeForever { } + + // Initial state: both empty + viewModel.sipIdentity.value = "" + viewModel.password.value = "" + assertEquals("Login button should be disabled when both fields are empty", false, viewModel.loginEnabled.value) + + // Only username filled + viewModel.sipIdentity.value = "testuser" + viewModel.password.value = "" + assertEquals("Login button should be disabled when password is empty", false, viewModel.loginEnabled.value) + + // Only password filled + viewModel.sipIdentity.value = "" + viewModel.password.value = "testpassword" + assertEquals("Login button should be disabled when username is empty", false, viewModel.loginEnabled.value) + + // Both filled + viewModel.sipIdentity.value = "testuser" + viewModel.password.value = "testpassword" + assertEquals("Login button should be enabled when both fields are filled", true, viewModel.loginEnabled.value) + + // Username with only spaces + viewModel.sipIdentity.value = " " + viewModel.password.value = "testpassword" + assertEquals("Login button should be disabled when username is only whitespace", false, viewModel.loginEnabled.value) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f911837a..c7e833e1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,11 @@ dotsIndicator = "5.1.0" photoview = "2.3.0" openidAppauth = "0.11.1" linphone = "5.5.+" +junit = "4.13.2" +androidxTestCore = "1.6.1" +androidxArchCore = "2.2.0" +kotlinxCoroutinesTest = "1.10.1" +mockk = "1.13.16" [libraries] androidx-annotations = { group = "androidx.annotation", name = "annotation", version.ref = "annotations" } @@ -69,6 +74,12 @@ openid-appauth = { group = "net.openid", name = "appauth", version.ref = "openid linphone = { group = "org.linphone", name = "linphone-sdk-android", version.ref = "linphone" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArchCore" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -76,4 +87,4 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } navigation = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" } crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" } googleGmsServices = { id = "com.google.gms.google-services", version.ref = "gmsGoogleServices" } -ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } \ No newline at end of file +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }