Trying AI generated UI tests

This commit is contained in:
Sylvain Berfini 2026-03-24 16:34:49 +01:00
parent 721e379b50
commit 0aca829ba7
7 changed files with 197 additions and 9 deletions

View file

@ -267,6 +267,12 @@ dependencies {
implementation(libs.openid.appauth) implementation(libs.openid.appauth)
implementation(libs.linphone) 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<org.jlleitschuh.gradle.ktlint.KtlintExtension> { configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> {

View file

@ -55,6 +55,7 @@
android:theme="@style/Theme.Linphone" android:theme="@style/Theme.Linphone"
android:appCategory="social" android:appCategory="social"
android:largeHeap="true" android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="35"> tools:targetApi="35">
<!-- Required for chat message & call notifications to be displayed in Android auto --> <!-- Required for chat message & call notifications to be displayed in Android auto -->

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}), java.security.SecureRandom())
context
}
}
}

View file

@ -21,10 +21,12 @@ package org.linphone.ui.main.sso.viewmodel
import android.content.Intent import android.content.Intent
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.io.File import java.io.File
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.openid.appauth.AppAuthConfiguration
import net.openid.appauth.AuthState import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationRequest
@ -39,10 +41,10 @@ import org.linphone.R
import org.linphone.core.Factory import org.linphone.core.Factory
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
import org.linphone.ui.main.sso.utils.AppAuthConnectionBuilder
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.TimestampUtils import org.linphone.utils.TimestampUtils
import androidx.core.net.toUri
class SingleSignOnViewModel class SingleSignOnViewModel
@UiThread @UiThread
@ -127,9 +129,12 @@ class SingleSignOnViewModel
@UiThread @UiThread
private fun singleSignOn() { private fun singleSignOn() {
Log.i("$TAG Fetch from issuer [$singleSignOnUrl]") Log.i("$TAG Fetch from issuer [$singleSignOnUrl]")
AuthorizationServiceConfiguration.fetchFromIssuer( val connectionBuilder = AppAuthConnectionBuilder(true)
singleSignOnUrl.toUri(), val callback = object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback {
AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> override fun onFetchConfigurationCompleted(
serviceConfiguration: AuthorizationServiceConfiguration?,
ex: AuthorizationException?
) {
if (ex != null) { if (ex != null) {
Log.e( Log.e(
"$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}" "$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}"
@ -137,13 +142,13 @@ class SingleSignOnViewModel
onErrorEvent.postValue( onErrorEvent.postValue(
Event("Failed to fetch configuration from issuer $singleSignOnUrl") Event("Failed to fetch configuration from issuer $singleSignOnUrl")
) )
return@RetrieveConfigurationCallback return
} }
if (serviceConfiguration == null) { if (serviceConfiguration == null) {
Log.e("$TAG Service configuration is null!") Log.e("$TAG Service configuration is null!")
onErrorEvent.postValue(Event("Service configuration is null")) onErrorEvent.postValue(Event("Service configuration is null"))
return@RetrieveConfigurationCallback return
} }
if (!::authState.isInitialized) { if (!::authState.isInitialized) {
@ -169,10 +174,19 @@ class SingleSignOnViewModel
} }
val authRequest = authRequestBuilder.build() 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) val authIntent = authService.getAuthorizationRequestIntent(authRequest)
startAuthIntentEvent.postValue(Event(authIntent)) startAuthIntentEvent.postValue(Event(authIntent))
} }
}
AuthorizationServiceConfiguration.fetchFromIssuer(
singleSignOnUrl.toUri(),
callback,
connectionBuilder
) )
} }
@ -180,7 +194,10 @@ class SingleSignOnViewModel
private fun performRefreshToken() { private fun performRefreshToken() {
if (::authState.isInitialized) { if (::authState.isInitialized) {
if (!::authService.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) val authStateJsonFile = File(corePreferences.ssoCacheFile)

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -32,6 +32,11 @@ dotsIndicator = "5.1.0"
photoview = "2.3.0" photoview = "2.3.0"
openidAppauth = "0.11.1" openidAppauth = "0.11.1"
linphone = "5.5.+" linphone = "5.5.+"
junit = "4.13.2"
androidxTestCore = "1.6.1"
androidxArchCore = "2.2.0"
kotlinxCoroutinesTest = "1.10.1"
mockk = "1.13.16"
[libraries] [libraries]
androidx-annotations = { group = "androidx.annotation", name = "annotation", version.ref = "annotations" } 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" } 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] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }
jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }