diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 951b4799d..5e3d11f6b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,10 +98,6 @@ tasks.register("linphoneSdkSource") { } project.tasks.preBuild.dependsOn("linphoneSdkSource") -configurations { - implementation { isCanBeResolved = true } -} - android { namespace = "org.linphone" compileSdk = 36 @@ -254,6 +250,8 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso) + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.cucumber.android) androidTestImplementation(libs.cucumber.junit) diff --git a/app/src/androidTest/assets/features/assistant_activity.feature b/app/src/androidTest/assets/features/assistant_activity.feature new file mode 100644 index 000000000..a8c62906b --- /dev/null +++ b/app/src/androidTest/assets/features/assistant_activity.feature @@ -0,0 +1,103 @@ +# language: fr +Fonctionnalité: Création de compte + + Background: + Etant donné que l’utilisateur vient de lancer l'app pour la première fois + + Scénario: Accorder les permissions + Etant donné que toutes les permissions ne sont pas accordées + Quand l’utilisateur clique sur le bouton "OK" + Alors l'utilisateur est redirigé vers la page d'accueil + + Scénario: Créer un compte + Etant donné que l’utilisateur est sur la vue de login + Quand l’utilisateur clique sur le bouton "Créer un compte" + Alors l'utilisateur est redirigé vers le formulaire d'inscription. + + #Tel# + + Scénario: Bouton "Créer" inactif sans champs remplis + Etant donné que l’utilisateur clique sur le bouton "S'inscrire" + Quand l’utilisateur clique sur "Créer" sans remplir les champs + Alors le bouton "Créer" est grisé et non cliquable + + Plan du Scénario: Validation des champs lors de la création de compte par téléphone + Etant donné que l’utilisateur clique sur le bouton "S'inscrire" + Quand l’utilisateur saisit "" dans le champ Nom utilisateur + Et saisit "" dans le champ Numéro de téléphone + Et saisit "" dans le champ Numéro de téléphone + Et saisit "" dans le champ mot de passe + Et clique sur "Créer" + Alors "" + + Exemples: + | Nom utilisateur | Numéro de téléphone | Indicatif international | Password | expectedResult | + | TestAccount666 | 0600000000 | +33 | azertyiop123 | No uppercase letters are allowed. (and 1 more error) | + | testaccoun/t | 0600000000 | +33 | azertyiop123 | The pseudo should be a valid SIP username | + | testaccount | 0600000000 | +33 | azertyiop123 | Le nom d'utilisateur est déjà pris | + | unused_account | 0600000000 | +33 | azertyiop123 | L'utilisateur est redirigé vers la validation par SMS | + + Scénario: Validation du numéro de téléphone avec code + Etant donné que l’utilisateur est sur la vue de validation du phone number + Quand l’utilisateur saisit "1111" comme code + Alors l'utilisateur n'est pas redirigé vers l'application. + + Scénario: Création de compte validée + Etant donné que l’utilisateur est sur la vue de validation du phone number + Quand l’utilisateur saisit le code reçu par SMS + Alors l'utilisateur est redirigé vers l'application sur la vue Appels + + #MAIL# + Scénario: Formulaire inscription par email + Etant donné que l’utilisateur est sur la vue de login + Quand l’utilisateur clique sur le bouton "Créer un compte" + Quand l’utilisateur clique sur le bouton "subscribe.linphone.org" + Alors l'utilisateur et redirigé sur son navigateur. + + #SIP tiers + Scénario: Connexion via un compte SIP tiers + Etant donné que l’utilisateur est sur la vue de login + Quand Je clique sur le bouton "J'ai un compte SIP tiers" depuis la vue login + Alors Une vue m’informe que certaines fonctionnalités ne seront pas disponibles + + Quand Je clique sur "je préfère créer un compte" + Alors Je suis redirigé vers la création de compte + + Quand Je clique sur la flèche en haut à gauche + Et Je clique de nouveau sur "J'ai un compte SIP tiers" + Et Je clique sur "J’ai compris" + Alors La vue de connexion avec un compte SIP tiers est affichée + Et Le bouton connexion est désactivé tant que tous les champs obligatoires ne sont pas remplis + + Quand Je saisis un username "dummy_account" + Et Je saisis un mot de passe "invalid" + Et Je saisis un nom de domaine "sip.linphone.org" + Et Je clique sur connexion + Alors un dialogue apparait "opération en cours, merci de patienter..." + Et Un message indique "Erreur durant la connexion" + + Quand Je saisis un username "dummy_account" + Et Je saisis un mot de passe "3V3ee@dummy!" + Et Je saisis un nom de domaine "sip.linphone.org" + Et Je clique sur connexion + Alors On sort de l'assistant + + #QRcode + Scénario: Connexion via QR code + Quand Je clique sur "Scanner un QR code" + Alors La vue de scan de QR code s’ouvre + + #compte linphone + Scénario: Connexion via formulaire classique + Quand Je clique sur "Connexion" sans saisir de username ni mot de passe + Alors Le bouton "connexion" est grisé. + + Quand Je saisis une adresse SIP "dummy_account_2" + Et Je saisis un mot de passe "invalid" + Et Je clique sur "Connexion" + Alors Un toast indique "Mauvais nom d'utilisateur ou mot de passe" + + Quand Je saisis une adresse SIP "dummy_account_2" + Et Je saisis un mot de passe "3V3ee@dummy!" + Et Je clique sur "Connexion" + Alors On sort de l'assistant diff --git a/app/src/androidTest/assets/features/welcome.feature b/app/src/androidTest/assets/features/welcome.feature deleted file mode 100644 index 75e56533d..000000000 --- a/app/src/androidTest/assets/features/welcome.feature +++ /dev/null @@ -1,7 +0,0 @@ -Feature:Welcome - When app starts the first time, it must display the welcome screens - - Scenario Outline:Welcome screens displayed at first start - Given I have a welcome Activity - When I press skip - Then I should be sent to the assistant Activity diff --git a/app/src/androidTest/java/org/linphone/test/AssistantActivityTest.kt b/app/src/androidTest/java/org/linphone/test/AssistantActivityTest.kt new file mode 100644 index 000000000..ca96e0307 --- /dev/null +++ b/app/src/androidTest/java/org/linphone/test/AssistantActivityTest.kt @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2010-2025 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.test + +import android.content.Intent +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.espresso.intent.matcher.UriMatchers.hasHost +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.ActivityTestRule +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import io.cucumber.java.After +import io.cucumber.java.Before +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import junit.framework.TestCase.assertTrue +import org.hamcrest.Matchers.* +import org.junit.Rule +import org.junit.runner.RunWith +import org.linphone.R +import org.linphone.ui.assistant.AssistantActivity +import kotlin.random.Random +import kotlin.random.nextInt + +@LargeTest +@RunWith(AndroidJUnit4::class) +class AssistantActivityTest { + @JvmField + @Rule + var activityTestRule: ActivityTestRule = + ActivityTestRule(AssistantActivity::class.java, false, false) + + @Before + fun setup() { + Intents.init() + activityTestRule.launchActivity(Intent()) + } + + @After + fun finish() { + Intents.release() + activityTestRule.finishActivity() + } + + @Given("^toutes les permissions ne sont pas accordées$") + fun grant_all_permissions() { + onView(withId(R.id.grant_permissions_title)).check(matches(isDisplayed())) + } + + @When("^l’utilisateur clique sur le bouton \"OK\"$") + fun user_clicks_on_grant_permissions() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + try { + // Check if we are on the permissions fragment + onView(withId(R.id.grant_all_permissions)).check(matches(isDisplayed())) + + // Click the grant all button + onView(withId(R.id.grant_all_permissions)).perform(click()) + + // Handle system permission dialogs if they appear + var allowButton = + device.findObject(UiSelector().textMatches("(?i)ALLOW|AUTORISER|ACCEPTER|WHILE USING THE APP|LORSQUE VOUS UTILISEZ L'APPLI")) + var tries = 0 + var clicks = 0 + while (allowButton.exists() && tries < 10) { + allowButton.click() + clicks += 1 + tries++ + // Wait a bit for the next one + Thread.sleep(1000) + allowButton = + device.findObject(UiSelector().textMatches("(?i)ALLOW|AUTORISER|ACCEPTER|WHILE USING THE APP|LORSQUE VOUS UTILISEZ L'APPLI")) + } + assertTrue(clicks == 4) + } catch (e: Exception) { + // If the view is not found, permissions might already be granted + } + } + + @Then("^l'utilisateur est redirigé vers la page d'accueil$") + fun user_is_redirected_to_landing_page() { + onView(withId(R.id.login_title)).check(matches(isDisplayed())) + } + + @Given("^l’utilisateur est sur la vue de login$") + fun user_is_on_login_page() { + onView(withId(R.id.login_title)).check(matches(isDisplayed())) + } + + @When("^l’utilisateur clique sur le bouton \"Créer un compte\"$") + fun user_clicks_on_create_account() { + onView(withId(R.id.register)).perform(click()) + + // Handle app accept conditions & terms dialog if they appear + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + var allowButton = device.findObject(UiSelector().textMatches("(?i)Accept|Accepter")) + var count = 0 + while (allowButton.exists() && count < 10) { + allowButton.click() + count++ + // Wait a bit for the next one + Thread.sleep(1000) + allowButton = device.findObject(UiSelector().textMatches("(?i)Accept|Accepter")) + } + } + + @Then("^l'utilisateur est redirigé vers le formulaire d'inscription.$") + fun user_is_redirected_to_registration_form() { + onView(withId(R.id.register_title)).check(matches(isDisplayed())) + } + + @Given("^l’utilisateur clique sur le bouton \"S'inscrire\"$") + fun user_clicks_on_signup_button() { + // Dans LandingFragment, "Créer un compte" correspond au bouton d'inscription + onView(withId(R.id.register)).perform(click()) + } + + @When("^l’utilisateur clique sur \"Créer\" sans remplir les champs$") + fun user_clicks_create_without_filling_fields() { + // On s'assure juste que le bouton est présent + onView(withId(R.id.create)).check(matches(isDisplayed())) + } + + @Then("^le bouton \"Créer\" est grisé et non cliquable$") + fun create_button_is_disabled() { + onView(withId(R.id.create)).check(matches(not(isEnabled()))) + } + + @When("^l’utilisateur saisit \"([^\"]*)\" dans le champ Nom utilisateur$") + fun user_enters_username(username: String) { + val usernameToSet = if (username == "unused_account") { + val randInt = Random.nextInt(0..1000000) + "usernameToSet-$randInt" + } else { + username + } + onView(withId(R.id.username)).perform(replaceText(usernameToSet)) + } + + @When("^saisit \"([^\"]*)\" dans le champ Numéro de téléphone$") + fun user_enters_phone_info(value: String) { + if (value.startsWith("+")) { + // C'est l'indicatif (Spinner) + onView(withId(R.id.prefix)).perform(click()) + onData( + allOf( + `is`(instanceOf(String::class.java)), + containsString(value) + ) + ).perform(click()) + } else { + // C'est le numéro + onView(withId(R.id.phone_number)).perform(replaceText(value)) + } + } + + @When("^saisit \"([^\"]*)\" dans le champ mot de passe$") + fun user_enters_password(password: String) { + onView(withId(R.id.password)).perform(replaceText(password)) + } + + @When("^clique sur \"Créer\"$") + fun user_clicks_create() { + onView(withId(R.id.create)).perform(click()) + + // Une popup de confirmation s'affiche d'abord + onView(withText(R.string.assistant_dialog_confirm_phone_number_title)).check( + matches( + isDisplayed() + ) + ) + onView(withId(R.id.confirm)).perform(click()) + } + + @Then("^\"([^\"]*)\"$") + fun check_expected_result(expectedResult: String) { + Thread.sleep(1000) + when (expectedResult) { + "no upper case letters are allowed", + "Special characters are not allowed" -> { + // On vérifie que le message d'erreur contient le texte attendu + onView(withId(R.id.username_error)).check( + matches( + withText( + containsString( + expectedResult + ) + ) + ) + ) + } + + "L'utilisateur est redirigé vers la validation par SMS" -> { + // On arrive sur la vue du code + onView(withId(R.id.register_code_confirmation_title)).check(matches(isDisplayed())) + } + } + } + + @Given("^l’utilisateur est sur la vue de validation du phone number$") + fun user_is_on_sms_validation_view() { + user_is_on_login_page() + onView(withId(R.id.register)).perform(click()) + user_enters_username("testaccount") + user_enters_phone_info("+33") + user_enters_phone_info("0600000000") + user_enters_password("azertyiop123") + onView(withId(R.id.create)).perform(click()) + onView(withId(R.id.confirm)).perform(click()) + onView(withId(R.id.register_code_confirmation_title)).check(matches(isDisplayed())) + } + + @When("^l’utilisateur saisit \"([^\"]*)\" comme code$") + fun user_enters_code(code: String) { + if (code.length == 4) { + onView(withId(R.id.code_first_digit)).perform(replaceText(code[0].toString())) + onView(withId(R.id.code_second_digit)).perform(replaceText(code[1].toString())) + onView(withId(R.id.code_third_digit)).perform(replaceText(code[2].toString())) + onView(withId(R.id.code_last_digit)).perform(replaceText(code[3].toString())) + } + } + + @Then("^l'utilisateur n'est pas redirigé vers l'application.$") + fun user_is_not_redirected() { + onView(withId(R.id.register_code_confirmation_title)).check(matches(isDisplayed())) + } + + @When("^l’utilisateur saisit le code reçu par SMS$") + fun user_enters_correct_sms_code() { + // Simulation de saisie d'un code (dépend de votre environnement de test pour le code réel) + user_enters_code("0000") + } + + @Then("^l'utilisateur est redirigé vers l'application sur la vue Appels$") + fun user_is_redirected_to_calls_view() { + // Une fois validé, l'assistant se ferme et on arrive sur l'historique des appels + onView(withId(R.id.history_list)).check(matches(isDisplayed())) + } + + @When("^l’utilisateur clique sur le bouton \"subscribe.linphone.org\"$") + fun user_clicks_on_subscribe_link() { + onView(withId(R.id.create_email_account)).perform(click()) + } + + @Then("^l'utilisateur et redirigé sur son navigateur.$") + fun user_is_redirected_to_browser() { + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(hasHost("subscribe.linphone.org")) + ) + ) + } + + @When("^Je clique sur le bouton \"J'ai un compte SIP tiers\" depuis la vue login$") + fun click_third_party_sip_account() { + onView(withId(R.id.third_party_sip_account)).perform(click()) + } + + @Then("^Une vue m’informe que certaines fonctionnalités ne seront pas disponibles$") + fun check_third_party_warning_view() { + onView(withId(R.id.third_party_warning_title)).check(matches(isDisplayed())) + } + + @When("^Je clique sur \"je préfère créer un compte\"$") + fun click_prefer_create_account() { + onView(withId(R.id.create_linphone_account)).perform(click()) + } + + @Then("^Je suis redirigé vers la création de compte$") + fun check_redirected_to_creation() { + onView(withId(R.id.register_title)).check(matches(isDisplayed())) + } + + @When("^Je clique sur la flèche en haut à gauche$") + fun click_back_arrow() { + onView(withId(R.id.back)).perform(click()) + } + + @When("^Je clique de nouveau sur \"J'ai un compte SIP tiers\"$") + fun click_third_party_sip_account_again() { + onView(withId(R.id.third_party_sip_account)).perform(click()) + } + + @When("^Je clique sur \"J’ai compris\"$") + fun click_understood() { + onView(withId(R.id.continue_third_party_account_login)).perform(click()) + } + + @Then("^La vue de connexion avec un compte SIP tiers est affichée$") + fun check_third_party_login_view() { + onView(withId(R.id.third_party_login_title)).check(matches(isDisplayed())) + } + + @Then("^Le bouton connexion est désactivé tant que tous les champs obligatoires ne sont pas remplis$") + fun check_login_button_disabled() { + onView(withId(R.id.login)).check(matches(not(isEnabled()))) + } + + @When("^Je saisis un username \"([^\"]*)\"$") + fun enter_valid_username(user: String) { + onView(withId(R.id.username)).perform(replaceText(user)) + } + + @When("^Je saisis un nom de domaine \"([^\"]*)\"$") + fun enter_domain(user: String) { + onView(withId(R.id.domain)).perform(replaceText(user)) + } + + @When("^Je clique sur connexion$") + fun click_login() { + onView(withId(R.id.login)).perform(click()) + } + + @Then("^un dialogue apparait \"opération en cours, merci de patienter...\"$") + fun check_operation_in_progress() { + onView(withText(R.string.operation_in_progress_overlay)).check(matches(isDisplayed())) + } + + @Then("^Un message indique \"Erreur durant la connexion\"$") + fun check_login_error_message() { + // TODO + Thread.sleep(3000) + } + + @Then("^On sort de l'assistant$") + fun check_call_view() { + // This is a workaround since the MainActivity wasn't started before the Assistant one... + // Can't check for Paused activity, have to wait for destroyed lifecycle state hence the long sleep + Thread.sleep(3000) + assertTrue(activityTestRule.getActivity().isDestroyed) + } + + @When("^Je clique sur \"Scanner un QR code\"$") + fun click_scan_qr_code() { + onView(withId(R.id.scan_qr_code)).perform(click()) + } + + @Then("^La vue de scan de QR code s’ouvre$") + fun check_qr_scanner_view() { + onView(withId(R.id.scan_qr_code_title)).check(matches(isDisplayed())) + } + + @When("^Je clique sur \"Connexion\" sans saisir de username ni mot de passe$") + fun click_login_empty() { + onView(withId(R.id.sip_identity)).perform(replaceText("")) + onView(withId(R.id.password)).perform(replaceText(""), closeSoftKeyboard()) + } + + @Then("^Le bouton \"connexion\" est grisé.$") + fun check_login_button_grayed() { + onView(withId(R.id.login)).check(matches(not(isEnabled()))) + } + + @When("^Je saisis un mot de passe \"([^\"]*)\"$") + fun enter_password(pass: String) { + onView(withId(R.id.password)).perform(replaceText(pass)) + } + + @When("^Je clique sur \"Connexion\"$") + fun click_login_linphone() { + onView(withId(R.id.login)).perform(click()) + } + + @Then("^Un toast indique \"Mauvais nom d'utilisateur ou mot de passe\"$") + fun check_wrong_credentials_popup() { + // TODO + Thread.sleep(3000) + } + + @When("^Je saisis une adresse SIP \"([^\"]*)\"$") + fun enter_sip_address(user: String) { + onView(withId(R.id.sip_identity)).perform(replaceText(user)) + } +} diff --git a/app/src/androidTest/java/org/linphone/test/WelcomeActivityTest.kt b/app/src/androidTest/java/org/linphone/test/WelcomeActivityTest.kt deleted file mode 100644 index c31ddc41d..000000000 --- a/app/src/androidTest/java/org/linphone/test/WelcomeActivityTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) 2010-2025 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.test - -import android.content.Intent -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.SmallTest -import androidx.test.rule.ActivityTestRule -import io.cucumber.java.After -import io.cucumber.java.Before -import io.cucumber.java.en.Given -import io.cucumber.java.en.Then -import io.cucumber.java.en.When -import junit.framework.TestCase.assertNotNull -import org.junit.Rule -import org.junit.runner.RunWith -import org.linphone.ui.welcome.WelcomeActivity - -@SmallTest -@RunWith(AndroidJUnit4::class) -class WelcomeActivityTest { - @JvmField - @Rule - var activityTestRule: ActivityTestRule = ActivityTestRule(WelcomeActivity::class.java) - - @Rule - lateinit var activity: WelcomeActivity - - @Before() - fun setup() { - activityTestRule.launchActivity(Intent()) - activity = activityTestRule.activity - } - - @After() - fun finish() { - activityTestRule.finishActivity() - } - - @Given("^I have a welcome Activity") - fun I_have_a_login_activity() { - assertNotNull(activity) - } - - @When("^I press skip") - fun I_press_skip() { - - } - - @Then("^I should be sent to the assistant Activity") - fun I_should_be_sent_to_the_assistant_activity() { - - } -} diff --git a/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt b/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt index 7f1275c1b..9038492b7 100644 --- a/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt +++ b/app/src/main/java/org/linphone/ui/assistant/AssistantActivity.kt @@ -55,6 +55,10 @@ class AssistantActivity : GenericActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) + while (!coreContext.isReady()) { + Thread.sleep(50) + } + binding = DataBindingUtil.setContentView(this, R.layout.assistant_activity) binding.lifecycleOwner = this setUpToastsArea(binding.toastsArea) diff --git a/app/src/main/java/org/linphone/ui/main/sso/AppAuthConnectionBuilder.kt b/app/src/main/java/org/linphone/ui/main/sso/AppAuthConnectionBuilder.kt new file mode 100644 index 000000000..42111ddd1 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/sso/AppAuthConnectionBuilder.kt @@ -0,0 +1,56 @@ +/* + * 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 + +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 554fb2f7f..b4b0fa768 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.AppAuthConnectionBuilder import org.linphone.utils.Event import org.linphone.utils.FileUtils import org.linphone.utils.TimestampUtils -import androidx.core.net.toUri class SingleSignOnViewModel @UiThread @@ -51,19 +53,7 @@ class SingleSignOnViewModel private const val TAG = "[Single Sign On ViewModel]" } - val operationInProgress = MutableLiveData() - - val singleSignOnProcessCompletedEvent: MutableLiveData> by lazy { - MutableLiveData() - } - - val startAuthIntentEvent: MutableLiveData> by lazy { - MutableLiveData() - } - - val onErrorEvent: MutableLiveData> by lazy { - MutableLiveData() - } + val singleSignOnProcessCompletedEvent = MutableLiveData>() private var clientId: String private val redirectUri: String @@ -72,6 +62,16 @@ class SingleSignOnViewModel private var username: String = "" + val operationInProgress = MutableLiveData() + + val startAuthIntentEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val onErrorEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + private lateinit var authState: AuthState private lateinit var authService: AuthorizationService @@ -133,64 +133,72 @@ class SingleSignOnViewModel @UiThread private fun singleSignOn() { Log.i("$TAG Fetch from issuer [$singleSignOnUrl]") - operationInProgress.postValue(true) + val connectionBuilder = AppAuthConnectionBuilder(true) + val callback = AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> + if (ex != null) { + Log.e( + "$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}" + ) + onErrorEvent.postValue( + Event("Failed to fetch configuration from issuer $singleSignOnUrl") + ) + operationInProgress.postValue(false) + return@RetrieveConfigurationCallback + } + + if (serviceConfiguration == null) { + Log.e("$TAG Service configuration is null!") + onErrorEvent.postValue(Event("Service configuration is null")) + operationInProgress.postValue(false) + return@RetrieveConfigurationCallback + } + + if (!::authState.isInitialized) { + Log.i("$TAG Initializing AuthState object") + authState = AuthState(serviceConfiguration) + storeAuthStateAsJsonFile() + } + + val authRequestBuilder = AuthorizationRequest.Builder( + serviceConfiguration, // the authorization service configuration + clientId, // the client ID, typically pre-registered and static + ResponseTypeValues.CODE, // the response_type value: we want a code + redirectUri.toUri() // the redirect URI to which the auth response is sent + ) + + // Needed for SDK to be able to refresh the token, otherwise it will return + // an invalid grant error with description "Session not active" + authRequestBuilder.setScopes("offline_access") + + if (username.isNotEmpty() && corePreferences.useUsernameAsSingleSignOnLoginHint) { + Log.i("$TAG Using username [$username] as login hint") + authRequestBuilder.setLoginHint(username) + } + + val authRequest = authRequestBuilder.build() + 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(), - AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex -> - if (ex != null) { - Log.e( - "$TAG Failed to fetch configuration from issuer [$singleSignOnUrl]: ${ex.errorDescription}" - ) - onErrorEvent.postValue( - Event("Failed to fetch configuration from issuer $singleSignOnUrl") - ) - operationInProgress.postValue(false) - return@RetrieveConfigurationCallback - } - - if (serviceConfiguration == null) { - Log.e("$TAG Service configuration is null!") - onErrorEvent.postValue(Event("Service configuration is null")) - operationInProgress.postValue(false) - return@RetrieveConfigurationCallback - } - - if (!::authState.isInitialized) { - Log.i("$TAG Initializing AuthState object") - authState = AuthState(serviceConfiguration) - storeAuthStateAsJsonFile() - } - - val authRequestBuilder = AuthorizationRequest.Builder( - serviceConfiguration, // the authorization service configuration - clientId, // the client ID, typically pre-registered and static - ResponseTypeValues.CODE, // the response_type value: we want a code - redirectUri.toUri() // the redirect URI to which the auth response is sent - ) - - // Needed for SDK to be able to refresh the token, otherwise it will return - // an invalid grant error with description "Session not active" - authRequestBuilder.setScopes("offline_access") - - if (username.isNotEmpty() && corePreferences.useUsernameAsSingleSignOnLoginHint) { - Log.i("$TAG Using username [$username] as login hint") - authRequestBuilder.setLoginHint(username) - } - - val authRequest = authRequestBuilder.build() - authService = AuthorizationService(coreContext.context) - val authIntent = authService.getAuthorizationRequestIntent(authRequest) - startAuthIntentEvent.postValue(Event(authIntent)) - } + callback, + connectionBuilder ) } @UiThread private fun performRefreshToken() { - operationInProgress.postValue(true) 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/layout-sw600dp/assistant_landing_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml index 8e5c02417..fe65a2e11 100644 --- a/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_landing_fragment.xml @@ -68,13 +68,13 @@ android:background="@drawable/circle_transparent_button_background" android:visibility="@{viewModel.showBackButton ? View.VISIBLE : View.INVISIBLE, default=invisible}" app:tint="?attr/color_main2_500" - app:layout_constraintTop_toTopOf="@id/title" + app:layout_constraintTop_toTopOf="@id/login_title" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/title"/> + app:layout_constraintEnd_toStartOf="@id/login_title"/> @@ -166,8 +166,8 @@ android:inputType="text|textNoSuggestions" android:hint="@string/username" app:layout_constraintWidth_max="@dimen/text_input_max_width" - app:layout_constraintStart_toStartOf="@id/title" - app:layout_constraintEnd_toEndOf="@id/title" + app:layout_constraintStart_toStartOf="@id/login_title" + app:layout_constraintEnd_toEndOf="@id/login_title" app:layout_constraintTop_toBottomOf="@id/sip_identity_label" app:layout_constraintBottom_toTopOf="@id/password_label"/> @@ -201,8 +201,8 @@ android:hint="@string/password" passwordInputType="@{viewModel.showPassword ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD, default=textPassword}" app:layout_constraintWidth_max="@dimen/text_input_max_width" - app:layout_constraintStart_toStartOf="@id/title" - app:layout_constraintEnd_toEndOf="@id/title" + app:layout_constraintStart_toStartOf="@id/login_title" + app:layout_constraintEnd_toEndOf="@id/login_title" app:layout_constraintTop_toBottomOf="@id/password_label" app:layout_constraintBottom_toTopOf="@id/login"/> @@ -233,7 +233,7 @@ android:text="@string/assistant_account_login" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/password" - app:layout_constraintStart_toStartOf="@id/title" + app:layout_constraintStart_toStartOf="@id/login_title" app:layout_constraintBottom_toTopOf="@id/scan_qr_code"/> @@ -285,8 +285,8 @@ android:layout_marginEnd="16dp" android:text="@string/assistant_login_third_party_sip_account" android:visibility="@{viewModel.hideThirdPartyAccount ? View.GONE : View.VISIBLE}" - app:layout_constraintStart_toStartOf="@id/title" - app:layout_constraintEnd_toEndOf="@id/title" + app:layout_constraintStart_toStartOf="@id/login_title" + app:layout_constraintEnd_toEndOf="@id/login_title" app:layout_constraintTop_toBottomOf="@id/scan_qr_code" app:layout_constraintBottom_toTopOf="@id/mountains"/> diff --git a/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml index 159807eaa..9df4cd745 100644 --- a/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_permissions_fragment.xml @@ -49,13 +49,13 @@ android:contentDescription="@string/content_description_go_back_icon" android:background="@drawable/circle_transparent_button_background" app:tint="?attr/color_main2_500" - app:layout_constraintTop_toTopOf="@id/title" + app:layout_constraintTop_toTopOf="@id/grant_permissions_title" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/title"/> + app:layout_constraintEnd_toStartOf="@id/grant_permissions_title"/> + app:layout_constraintTop_toBottomOf="@id/register_code_confirmation_title" /> + app:layout_constraintEnd_toStartOf="@id/register_code_confirmation_title"/> + app:layout_constraintStart_toStartOf="@id/register_code_confirmation_title" + app:layout_constraintEnd_toEndOf="@id/register_code_confirmation_title"/> + app:layout_constraintEnd_toEndOf="@id/register_code_confirmation_title" /> diff --git a/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml b/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml index 2bbc5fae8..8d5310755 100644 --- a/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml +++ b/app/src/main/res/layout-sw600dp/assistant_register_fragment.xml @@ -57,7 +57,7 @@ android:scaleType="centerCrop" android:contentDescription="@null" android:src="@drawable/assistant_logo" - app:layout_constraintStart_toEndOf="@id/title" + app:layout_constraintStart_toEndOf="@id/register_title" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toTopOf="@id/mountains" app:layout_constraintTop_toBottomOf="@id/login" /> @@ -73,13 +73,13 @@ android:contentDescription="@string/content_description_go_back_icon" android:background="@drawable/circle_transparent_button_background" app:tint="?attr/color_main2_500" - app:layout_constraintTop_toTopOf="@id/title" + app:layout_constraintTop_toTopOf="@id/register_title" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintEnd_toStartOf="@id/title"/> + app:layout_constraintEnd_toStartOf="@id/register_title"/> + app:layout_constraintStart_toStartOf="@id/register_title" + app:layout_constraintEnd_toEndOf="@id/register_title"/> @@ -156,7 +156,7 @@ android:labelFor="@id/username" android:text="@{@string/username + `*`}" app:layout_constraintVertical_chainStyle="packed" - app:layout_constraintTop_toBottomOf="@id/title" + app:layout_constraintTop_toBottomOf="@id/register_title" app:layout_constraintBottom_toTopOf="@id/username" app:layout_constraintStart_toStartOf="@id/username"/> @@ -179,8 +179,8 @@ app:layout_constraintWidth_max="@dimen/text_input_max_width" app:layout_constraintTop_toBottomOf="@id/username_label" app:layout_constraintBottom_toTopOf="@id/username_error" - app:layout_constraintStart_toStartOf="@id/title" - app:layout_constraintEnd_toEndOf="@id/title"/> + app:layout_constraintStart_toStartOf="@id/register_title" + app:layout_constraintEnd_toEndOf="@id/register_title"/> + app:layout_constraintStart_toStartOf="@id/register_title" + app:layout_constraintEnd_toEndOf="@id/register_title"/> + app:layout_constraintStart_toStartOf="@id/register_title" + app:layout_constraintEnd_toEndOf="@id/register_title" /> + app:layout_constraintTop_toTopOf="@id/register_title" + app:layout_constraintBottom_toBottomOf="@id/register_title"/> + app:layout_constraintEnd_toStartOf="@id/third_party_login_title"/> @@ -108,8 +108,8 @@ app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/username_label" app:layout_constraintBottom_toTopOf="@id/password_label" - app:layout_constraintStart_toStartOf="@id/title" - app:layout_constraintEnd_toEndOf="@id/title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" + app:layout_constraintEnd_toEndOf="@id/third_party_login_title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" + app:layout_constraintEnd_toEndOf="@id/third_party_login_title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" + app:layout_constraintEnd_toEndOf="@id/third_party_login_title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" + app:layout_constraintEnd_toEndOf="@id/third_party_login_title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" + app:layout_constraintEnd_toEndOf="@id/third_party_login_title"/> + app:layout_constraintStart_toStartOf="@id/third_party_login_title" /> + app:layout_constraintTop_toBottomOf="@id/third_party_login_title" /> + app:layout_constraintEnd_toStartOf="@id/third_party_warning_title"/> @@ -151,7 +151,7 @@ android:text="@string/assistant_third_party_sip_account_create_linphone_account" app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintVertical_bias="1" - app:layout_constraintStart_toStartOf="@id/title" + app:layout_constraintStart_toStartOf="@id/third_party_warning_title" app:layout_constraintEnd_toEndOf="@id/message" app:layout_constraintTop_toBottomOf="@id/contact" app:layout_constraintBottom_toTopOf="@id/continue_third_party_account_login" /> @@ -169,7 +169,7 @@ app:layout_constraintWidth_max="@dimen/button_max_width" app:layout_constraintTop_toBottomOf="@id/create_linphone_account" app:layout_constraintBottom_toTopOf="@id/mountains" - app:layout_constraintStart_toStartOf="@id/title" + app:layout_constraintStart_toStartOf="@id/third_party_warning_title" app:layout_constraintEnd_toEndOf="@id/message" /> + app:layout_constraintTop_toTopOf="@id/login_title" + app:layout_constraintBottom_toBottomOf="@id/login_title"/> diff --git a/app/src/main/res/layout/assistant_register_fragment.xml b/app/src/main/res/layout/assistant_register_fragment.xml index b5f893275..407eba284 100644 --- a/app/src/main/res/layout/assistant_register_fragment.xml +++ b/app/src/main/res/layout/assistant_register_fragment.xml @@ -70,7 +70,7 @@ + app:layout_constraintTop_toBottomOf="@id/register_title"/> + app:layout_constraintTop_toBottomOf="@id/third_party_warning_title"/> + app:layout_constraintTop_toBottomOf="@id/third_party_warning_title"/>