diff --git a/.gitlab-ci-files/job-android.yml b/.gitlab-ci-files/job-android.yml index 3bb32bbf5..39ea2614d 100644 --- a/.gitlab-ci-files/job-android.yml +++ b/.gitlab-ci-files/job-android.yml @@ -20,13 +20,18 @@ job-android: - ./gradlew app:dependencies | grep org.linphone - ./gradlew assembleDebug - ./gradlew assembleRelease + +after_script: + - ln -s ./app/build/outputs/apk/debug/linphone-android-debug-*.apk ./apk/debug + - ln -s ./app/build/outputs/apk/release/linphone-android-release-*.apk ./apk/release artifacts: - paths: - - ./app/build/outputs/apk/debug/linphone-android-debug-*.apk - - ./app/build/outputs/apk/release/linphone-android-release-*.apk + paths: + - ./app/build + - ./apk/debug + - ./apk/release when: always - expire_in: 1 week + expire_in: 2 hour .scheduled-job-android: diff --git a/.gitlab-ci-files/job-uitests.yml b/.gitlab-ci-files/job-uitests.yml new file mode 100644 index 000000000..2f9e3a0d8 --- /dev/null +++ b/.gitlab-ci-files/job-uitests.yml @@ -0,0 +1,34 @@ +#dependencies: + #install 'Android SDK Command-line Tools' from Android Studio > Tools > SDK Manager > SDK Tools > Android SDK Command-line Tools + + +variables: + android_api: 33 #android 13 + emulator_type: apis #atd for api < 30 more efficient + system_architecture: arm64-v8a #x86_64 or x86 for Intels + android_system_image: system-images;android-$android_api;google_$emulator_type;$system_architecture + emulator_device = pixel_7 + emulator_name: $emulator_device-api_$android_api-google_$emulator_type-arch_$system_architecture + +job-android-uitests: + + stage: uitests + tags: [ "deploy" ] + + dependencies: + - job-android + + before_script: + - sdkmanager --install $android_system_image > emulatorSystemImageInstallation.log + - echo no | ${AVDMANAGER_EXE} --verbose create avd --force --name $emulator_name --package $android_system_image --tag google_$emulator_type --abi $system_architecture --device emulator_device > emulatorCreation.log + - abd start-server + - emulator -avd $emulator_name """-no-window -no-audio"""& + - ${WAIT_FOR_EMULATOR_EXE} + - adb shell input keyevent 82 + + script: + - ./gradlew -Pandroid.testInstrumentationRunnerArguments.class=org.linphone.call.OutgoingCallUITests -PscreportAutoClose=true connectedAndroidTest + + after_script: + - adb -s emulator-5554 emu kill + - adb kill-server diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ca9839e75..6d856c455 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,3 +19,38 @@ stages: - build - uitests - deploy + + +variables: + PATH: /Users/quentin/Library/Android/sdk/emulator/:/Users/quentin/Library/Android/sdk/cmdline-tools/latest/bin/ + android_api: "33" #android 13 + emulator_type: apis #atd for api < 30 more efficient + system_architecture: arm64-v8a #x86_64 or x86 for Intels + android_system_image: system-images;android-$android_api;google_$emulator_type;$system_architecture + emulator_device: pixel_7 + emulator_name: $emulator_device-api_$android_api-google_$emulator_type-arch_$system_architecture + + +job-android-uitests: + + stage: uitests + tags: [ "android" ] + + dependencies: + - job-android + + before_script: + - echo $PATH + - ${PATH}sdkmanager --install $android_system_image > emulatorSystemImageInstallation.log + - echo no | ${PATH}avdmanager --verbose create avd --force --name $emulator_name --package $android_system_image --tag google_$emulator_type --abi $system_architecture --device emulator_device > emulatorCreation.log + - abd start-server + - ${PATH}emulator -avd $emulator_name & + - wait-for-android-emulator + - adb shell input keyevent 82 + + script: + - ./gradlew -Pandroid.testInstrumentationRunnerArguments.class=org.linphone.call.OutgoingCallUITests -PscreportAutoClose=true connectedAndroidTest + + after_script: + - adb -s emulator-5554 emu kill + - adb kill-server diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..9f70c564c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "screport"] + path = screport + url = https://gitlab.linphone.org/BC/public/screport.git diff --git a/app/build.gradle b/app/build.gradle index 61fc0d523..eb4b67147 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'kotlin-android' + id 'kotlin-android-extensions' id 'kotlin-kapt' id 'org.jlleitschuh.gradle.ktlint' id 'org.jetbrains.kotlin.android' @@ -86,6 +87,12 @@ android { versionCode appVersionCode versionName "${project.version}" applicationId getPackageName() + + //testInstrumentationRunnerArguments clearPackageData: 'true' + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testOptions { + animationsDisabled = true + } } applicationVariants.all { variant -> @@ -188,7 +195,21 @@ android { dataBinding = true } + compileOptions { + //sourceCompatibility JavaVersion.VERSION_1_8 + //targetCompatibility JavaVersion.VERSION_1_8 + } + + adbOptions { + installOptions '-g', '-r' + } + + kotlinOptions { + //jvmTarget = "1.8" + } + namespace 'org.linphone' + testBuildType 'debug' } dependencies { @@ -202,6 +223,7 @@ dependencies { implementation "androidx.security:security-crypto-ktx:1.1.0-alpha05" implementation "androidx.window:window:1.0.0" implementation 'androidx.core:core-ktx:1.9.0' + implementation 'androidx.test:rules:1.4.0' def nav_version = "2.5.3" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" @@ -244,6 +266,15 @@ dependencies { // Only enable leak canary prior to release //debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.4' + + // UITests dependencies + debugImplementation 'androidx.test:runner:1.1.3' + debugImplementation 'androidx.test:core:1.1.3' + debugImplementation 'androidx.test.ext:junit-ktx:1.1.3' + + debugImplementation 'androidx.test.espresso:espresso-core:3.4.0' + debugImplementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' + debugImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' } task generateContactsXml(type: Copy) { @@ -272,3 +303,164 @@ if (crashlyticsAvailable) { packageReleaseWithCrashlytics.finalizedBy(uploadCrashlyticsSymbolFileReleaseWithCrashlytics) } } + +// screenshots ui tests + +def reportsDirectory = "$buildDir/reports/androidTests/connected" +def screenshotDirectory = "$reportsDirectory/screenshots" + +def embedScreenshotsTask = task('embedScreenshots', group: 'reporting') { + doFirst { + + def failureScreenshotsDirectory = new File(reportsDirectory, 'failures') + + if (!failureScreenshotsDirectory.exists()) { + println 'Could not find screenshot failures. Skipping...' + return + } + + failureScreenshotsDirectory.eachFile { failedTestClassDirectory -> + def failedTestClassName = failedTestClassDirectory.name + + failedTestClassDirectory.eachFile { failedTestFile -> + def failedTestName = failedTestFile.name + + failedTestFile.eachFile { failedTestCase -> + def failedTestCaseDescription = failedTestCase.name + def pt1 = failedTestCaseDescription.indexOf(".") + def line = failedTestCaseDescription.substring(0,pt1) + def pt2 = failedTestCaseDescription.indexOf(".",pt1) + def name = failedTestCaseDescription.substring(pt1,pt2) + def pt3 = failedTestCaseDescription.indexOf(".",pt2) + if (pt3 != -1) name += " (${failedTestCaseDescription.substring(pt3)})" + + def failedTestClassJunitReportFile = new File(reportsDirectory, "${failedTestClassName}.html") + + if (!failedTestClassJunitReportFile.exists()) { + println "Could not find JUnit report file for test class '${failedTestClassJunitReportFile}'" + return + } + + def failedTestJunitReportContent = failedTestClassJunitReportFile.text + + def tab0 = "Tests\n\n\n
" + def tab0Index = failedTestJunitReportContent.indexOf(tab0) + + if (tab0Index != -1) { + def newtab0 = "Failed tests\n\n
  • \nTests\n
  • \n\n
    \n

    Failed tests

    \n
    \n
    " + failedTestJunitReportContent = failedTestJunitReportContent.replace(tab0,newtab0) + } + + def failedTest = "

    ${failedTestName}

    " + def failedTestIndex = failedTestJunitReportContent.indexOf(failedTest) + def screenshotReport = "" + + if (failedTestIndex == -1) { + def newtestDiv = "
    \n\n${failedTest}\n\n
    \n
    \n
    \n
    " + def endDiv = "
    \n
    " + failedTestJunitReportContent = failedTestJunitReportContent.replace(endDiv,newtestDiv+endDiv) + failedTestJunitReportContent = failedTestJunitReportContent.replace("${failedTestName}\npassed","${failedTestName}\nfailed") + + def totalCounterStr = "id=\"tests\">\n
    " + def totalCounterStart = failedTestJunitReportContent.indexOf(totalCounterStr) + totalCounterStr.length() + def totalCounterEnd = failedTestJunitReportContent.indexOf("
    ",totalCounterStart) + + def failureCounterStr = "id=\"failures\">\n
    " + def failureCounterStart = failedTestJunitReportContent.indexOf(failureCounterStr,totalCounterEnd) + failureCounterStr.length() + def failureCounterEnd = failedTestJunitReportContent.indexOf("
    ",failureCounterStart) + + def percentInfoStr = "id=\"successRate\">\n
    " + def percentInfoStart = failedTestJunitReportContent.indexOf(percentInfoStr,failureCounterEnd) + percentInfoStr.length() + def percentInfoEnd = failedTestJunitReportContent.indexOf("%
    ",percentInfoStart) + + def total = failedTestJunitReportContent.substring(totalCounterStart,totalCounterEnd).toInteger() + def failure = failedTestJunitReportContent.substring(failureCounterStart,failureCounterEnd).toInteger()+1 + def percent = ((1 - failure/total)*100).toInteger() + + failedTestJunitReportContent = failedTestJunitReportContent.substring(0,failureCounterStart) + "$failure" + failedTestJunitReportContent.substring(failureCounterEnd) + failedTestJunitReportContent = failedTestJunitReportContent.substring(0,percentInfoStart) + "$percent" + failedTestJunitReportContent.substring(percentInfoEnd) + + if (failedTestJunitReportContent.indexOf("\"infoBox success\" id=\"successRate\"") != -1) { + failedTestJunitReportContent = failedTestJunitReportContent.replace("\"infoBox success\" id=\"successRate\"", "\"infoBox failures\" id=\"successRate\"") + } + + failedTestIndex = failedTestJunitReportContent.indexOf(failedTest) + } else screenshotReport = "\n\n" + + def imagesrc = "failures/${failedTestClassName}/${failedTestName}/${failedTestCase.name}" + + screenshotReport += "display conficts detected with \"${name}\" line ${line}: (reference | capture | difference)\n" + screenshotReport += "
    \n" + screenshotReport += "\n" + screenshotReport += "\n" + screenshotReport += "\n" + screenshotReport += "
    " + + def insertIndex = failedTestJunitReportContent.indexOf("",failedTestIndex) + def firstPart = failedTestJunitReportContent.substring(0,insertIndex) + def secondPart = failedTestJunitReportContent.substring(insertIndex) + failedTestJunitReportContent = firstPart + screenshotReport + secondPart + + failedTestClassJunitReportFile.write(failedTestJunitReportContent) + } + } + } + } +} + +def clearScreenshotsTask = task('clearScreenshots', type: Exec) { + executable "${android.getAdbExe().toString()}" + args 'shell', 'rm', '-r', '/sdcard/Pictures/linphone_uitests' +} + +def fetchScreenshotsTask = task('fetchScreenshots', type: Exec, group: 'reporting') { + executable "${android.getAdbExe().toString()}" + args 'pull', '/sdcard/Pictures/linphone_uitests/.', screenshotDirectory + + doFirst { + new File(screenshotDirectory).mkdirs() + } + + finalizedBy { + clearScreenshotsTask + } +} + +def launchScreenshotsComparisonTask = task('launchScreenshotsComparison', type: Exec, group: 'reporting') { + workingDir "$rootDir/screport/" + def autoClose = "false" + if (project.hasProperty("screportAutoClose") ) { + autoClose = screportAutoClose + } + commandLine "python3", "launch.py", "-rp", "$projectDir/src/androidTest/java/org/linphone/screenshots", "-sp", "$projectDir/build/reports/androidTests/connected/screenshots", "-ac", "$autoClose" + + dependsOn() { + fetchScreenshotsTask + } + + finalizedBy { + embedScreenshotsTask + } +} + +def isScreenshotComparisonNeededTask = task('isScreenshotComparisonNeeded', type: Exec, group: 'reporting') { + commandLine "${android.getAdbExe().toString()}", 'shell', 'cd', '/sdcard/Pictures/linphone_uitests', '||', 'echo false' + standardOutput = new ByteArrayOutputStream() + + doLast { + launchScreenshotsComparisonTask.onlyIf {"$standardOutput" == ""} + fetchScreenshotsTask.onlyIf {"$standardOutput" == ""} + clearScreenshotsTask.onlyIf {"$standardOutput" == ""} + embedScreenshotsTask.onlyIf {"$standardOutput" == ""} + } + + finalizedBy { + launchScreenshotsComparisonTask + } +} + +gradle.projectsEvaluated { + connectedDebugAndroidTest.finalizedBy { + isScreenshotComparisonNeededTask + } +} diff --git a/app/src/androidTest/java/org/linphone/call/IncomingCallUITests.kt b/app/src/androidTest/java/org/linphone/call/IncomingCallUITests.kt new file mode 100644 index 000000000..a4fb9e28d --- /dev/null +++ b/app/src/androidTest/java/org/linphone/call/IncomingCallUITests.kt @@ -0,0 +1,77 @@ +package org.linphone.call + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.linphone.methods.CallViewUITestsMethods +import org.linphone.methods.UITestsUtils + +@RunWith(AndroidJUnit4::class) +class IncomingCallUITests { + + lateinit var methods: CallViewUITestsMethods + + @Before + fun setUp() { + UITestsUtils.testAppSetup() + methods = CallViewUITestsMethods() + } + + // notification tests + @Test + fun testDisplayCallPush() { + methods.startIncomingCall() + methods.endCall() + } + + @Test + fun testNoAnswerCallPush() { + methods.startIncomingCall() + methods.noAnswerCallFromPush() + } + + @Test + fun testDeclineCallPush() { + methods.startIncomingCall() + methods.declineCallFromPush() + } + + @Test + fun testAnswerCallPush() { + methods.startIncomingCall() + methods.answerCallFromPush() + methods.endCall() + } + + // incoming call view tests + @Test + fun testOpenIncomingCallView() { + methods.startIncomingCall() + methods.openIncomingCallViewFromPush() + methods.endCall() + } + + @Test + fun testNoAnswerIncomingCallView() { + methods.startIncomingCall() + methods.openIncomingCallViewFromPush() + methods.noAnswerCallFromIncomingCall() + } + + @Test + fun testDeclineIncomingCallView() { + methods.startIncomingCall() + methods.openIncomingCallViewFromPush() + methods.declineCallFromIncomingCallView() + methods.endCall() + } + + @Test + fun testAcceptIncomingCallView() { + methods.startIncomingCall() + methods.openIncomingCallViewFromPush() + methods.answerCallFromIncomingCallView() + methods.endCall() + } +} diff --git a/app/src/androidTest/java/org/linphone/call/OutgoingCallUITests.kt b/app/src/androidTest/java/org/linphone/call/OutgoingCallUITests.kt new file mode 100644 index 000000000..26970ddef --- /dev/null +++ b/app/src/androidTest/java/org/linphone/call/OutgoingCallUITests.kt @@ -0,0 +1,77 @@ +package org.linphone.call + +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.linphone.R +import org.linphone.methods.* +import org.linphone.methods.UITestsScreenshots.takeScreenshot + +@RunWith(AndroidJUnit4::class) +@LargeTest +class OutgoingCallUITests { + + lateinit var methods: CallViewUITestsMethods + + @get:Rule + val screenshotsRule = ScreenshotsRule(true) + + @Before + fun setUp() { + UITestsUtils.testAppSetup() + methods = CallViewUITestsMethods() + takeScreenshot("dialer_view") + methods.startOutgoingCall() + takeScreenshot("outgoing_call_view") + } + + @After + fun tearDown() { + methods.endCall() + } + + @Test + fun testViewDisplay() { + methods.endCall() + takeScreenshot("dialer_view", "declined") + } + + @Test + fun testNoAnswer() { + methods.noAnswerCallFromOutgoingCall() + takeScreenshot("dialer_view", "no_answer") + } + + @Test + fun testToggleMute() { + Espresso.onView(withId(R.id.microphone)).perform(ViewActions.click()) + takeScreenshot("outgoing_call_view", "mute") + Espresso.onView(withId(R.id.microphone)).perform(ViewActions.click()) + takeScreenshot("outgoing_call_view") + methods.endCall() + takeScreenshot("dialer_view", "declined") + } + + @Test + fun testToggleSpeaker() { + Espresso.onView(withId(R.id.speaker)).perform(ViewActions.click()) + takeScreenshot("outgoing_call_view", "speaker") + Espresso.onView(withId(R.id.speaker)).perform(ViewActions.click()) + takeScreenshot("outgoing_call_view") + methods.endCall() + takeScreenshot("dialer_view", "declined") + } + + @Test + fun testCancel() { + methods.cancelCallFromOutgoingCallView() + takeScreenshot("dialer_view") + } +} diff --git a/app/src/androidTest/java/org/linphone/methods/CallViewUITestsMethods.kt b/app/src/androidTest/java/org/linphone/methods/CallViewUITestsMethods.kt new file mode 100644 index 000000000..0f9680a10 --- /dev/null +++ b/app/src/androidTest/java/org/linphone/methods/CallViewUITestsMethods.kt @@ -0,0 +1,153 @@ +package org.linphone.methods + +import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.* +import kotlinx.coroutines.* +import org.linphone.R +import org.linphone.core.AuthInfo +import org.linphone.core.Call +import org.linphone.methods.UITestsUtils.activityScenario +import org.linphone.methods.UITestsUtils.checkWithTimeout +import org.linphone.utils.AppUtils.Companion.getString + +class CallViewUITestsMethods { + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val manager = UITestsCoreManager.instance + val appAccountAuthInfo: AuthInfo = UITestsCoreManager.instance.appAccountAuthInfo + val ghostAccount: UITestsRegisteredLinphoneCore = UITestsCoreManager.instance.ghostAccounts[0] + + fun startIncomingCall() { + if (ghostAccount.callState != Call.State.Released) { ghostAccount.terminateCall() } + + ghostAccount.startCall(manager.createAddress(appAccountAuthInfo)) + ghostAccount.waitForCallState(Call.State.OutgoingRinging, 5.0) + + waitForCallNotification(true, 5.0) + } + + fun startOutgoingCall() { + if (ghostAccount.callState != Call.State.Released) { ghostAccount.terminateCall() } + + onView(withId(R.id.sip_uri_input)).perform(typeText(ghostAccount.mAuthInfo.username)) + onView(withContentDescription(R.string.content_description_start_call)).perform(click()) + + onView(withId(R.id.outgoing_call_layout)).checkWithTimeout(matches(isDisplayed()), 5.0) + } + + fun endCall() { + if (ghostAccount.callState == Call.State.Released) { return } + + ghostAccount.terminateCall() + ghostAccount.waitForCallState(Call.State.Released, 5.0) + onView(withId(R.id.outgoing_call_layout)).checkWithTimeout(doesNotExist(), 5.0) + // onView(withId(com.google.android.material.R.id.snackbar_text)).checkWithTimeout(doesNotExist(), 5.0) + } + + fun noAnswerCallFromPush() { + waitForCallNotification(false, 30.0) + } + + fun declineCallFromPush() { + val declineLabel = getString(R.string.incoming_call_notification_answer_action_label) + + try { + val decline = device.findObject(By.textContains(declineLabel)) + decline.click() + } catch (e: java.lang.NullPointerException) { + throw AssertionError("[UITests] Enable to find the \"$declineLabel\" button in the incoming call notification") + } + } + + fun answerCallFromPush() { + val answerLabel = getString(R.string.incoming_call_notification_answer_action_label) + try { + val answer = device.findObject(By.textContains(answerLabel)) + answer.click() + } catch (e: java.lang.NullPointerException) { + throw AssertionError("[UITests] Enable to find the \"$answerLabel\" button in the incoming call notification") + } + onView(withId(R.id.single_call_layout)).checkWithTimeout(matches(isDisplayed()), 5.0) + } + + fun openIncomingCallViewFromPush() { + try { + val notif = device.findObject(By.textContains(getString(R.string.incoming_call_notification_title))) + notif.click() + } catch (e: java.lang.NullPointerException) { + throw AssertionError("[UITests] Enable to find the incoming call notification") + } + onView(withId(R.id.incoming_call_layout)).checkWithTimeout(matches(isDisplayed()), 5.0) + } + + fun declineCallFromIncomingCallView() { + onView(withId(R.id.hangup)).checkWithTimeout(matches(isDisplayed()), 5.0) + onView(withId(R.id.hangup)).perform(click()) + onView(withId(R.id.incoming_call_layout)).checkWithTimeout(doesNotExist(), 5.0) + } + + fun answerCallFromIncomingCallView() { + onView(withId(R.id.answer)).checkWithTimeout(matches(isDisplayed()), 5.0) + onView(withId(R.id.answer)).perform(click()) + onView(withId(R.id.single_call_layout)).checkWithTimeout(matches(isDisplayed()), 5.0) + } + + fun cancelCallFromOutgoingCallView() { + onView(withId(R.id.hangup)).checkWithTimeout(matches(isDisplayed()), 5.0) + onView(withId(R.id.hangup)).perform(click()) + onView(withId(R.id.outgoing_call_layout)).checkWithTimeout(doesNotExist(), 5.0) + } + + fun noAnswerCallFromIncomingCall() { + onView(withId(R.id.incoming_call_layout)).checkWithTimeout(doesNotExist(), 30.0) + } + + fun noAnswerCallFromOutgoingCall() { + onView(withId(R.id.outgoing_call_layout)).checkWithTimeout(doesNotExist(), 30.0) + /* + val snackbar = onView(withId(com.google.android.material.R.id.snackbar_text)) + snackbar.checkWithTimeout( + matches( + allOf( + withText(containsString("Error")), + withText(containsString("Request Timeout")) + ) + ), + 5.0 + ) + snackbar.checkWithTimeout(doesNotExist(), 5.0) + */ + } + + private fun waitForCallNotification(exist: Boolean, timeout: Double) = runBlocking { + var result = false + val wait = launch(Dispatchers.Default) { + lateinit var activity: Activity + activityScenario!!.onActivity { act -> activity = act } + val manager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + repeat(timeout.toInt() * 10) { + for (notif in manager.activeNotifications) { + if (notif.notification.channelId == getString(R.string.notification_channel_incoming_call_id)) { + result = true + break + } + result = false + } + if (result == exist) { cancel() } + delay(100) + } + } + wait.join() + delay(500) + assert(result == exist) { "[UITests] Incoming call Notification still ${if (exist) "not " else ""}displayed after $timeout seconds" } + } +} diff --git a/app/src/androidTest/java/org/linphone/methods/UITestsCoreManager.kt b/app/src/androidTest/java/org/linphone/methods/UITestsCoreManager.kt new file mode 100644 index 000000000..87f8f0590 --- /dev/null +++ b/app/src/androidTest/java/org/linphone/methods/UITestsCoreManager.kt @@ -0,0 +1,354 @@ +package org.linphone.methods + +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import java.util.* +import kotlinx.coroutines.* +import org.linphone.LinphoneApplication +import org.linphone.core.* +import org.linphone.core.tools.Log +import org.linphone.utils.LinphoneUtils + +class UITestsCoreManager { + + var core: Core + var accountCreator: AccountCreator + private val factory = Factory.instance() + + var appAccountAuthInfo: AuthInfo + var ghostAccounts: UITestsGhostAccounts + val dnsServer = arrayOf("51.255.123.121") + + companion object { + private var mInstance: UITestsCoreManager? = null + + val instance: UITestsCoreManager + get() { + if (mInstance == null) mInstance = UITestsCoreManager() + return mInstance!! + } + } + + init { + factory.loggingService.setLogLevel(LogLevel.Debug) + + // Config account creator for flexiapi + val config = factory.createConfig(LinphoneApplication.corePreferences.uiTestsConfigPath) + config.setInt("account_creator", "backend", AccountCreatorBackend.FlexiAPI.ordinal) + config.setString("account_creator", "url", "http://subscribe.example.org/flexiapi/api/") + core = factory.createCoreWithConfig(config, getApplicationContext()) + core.setDnsServersApp(dnsServer) + accountCreator = core.createAccountCreator(null) + core.start() + ghostAccounts = UITestsGhostAccounts(::newAccountAuthInfo) + appAccountAuthInfo = newAccountAuthInfo() + ghostAccounts.indexOffset++ + } + + private fun newAccountAuthInfo(): AuthInfo { + val authInfo: AuthInfo + if (core.authInfoList.count() > ghostAccounts.count) { + authInfo = core.authInfoList[ghostAccounts.count] + Log.i("[UITests] Account retrieved (n°${ghostAccounts.count}) {usr: ${authInfo.username}, pwd: ${authInfo.password}, dmn: ${authInfo.domain}}") + } else { + authInfo = createAccount() + } + return authInfo + } + + fun createAccount(): AuthInfo { + accountCreator.username = "uitester_" + (Date().time * 1000).toUInt().toString().subSequence(0, 5) + accountCreator.password = (1..15).map { accountCreator.username!!.random() }.joinToString("") + accountCreator.domain = "sip.example.org" + accountCreator.email = accountCreator.username + "@" + accountCreator.domain + accountCreator.transport = TransportType.Tcp + accountCreator.createAccount() + waitForAccountCreationStatus(AccountCreator.Status.AccountCreated, 5.0) + + val authInfo = factory.createAuthInfo(accountCreator.username!!, "", accountCreator.password, "", "", accountCreator.domain) + core.addAuthInfo(authInfo) + Log.i("[UITests] New Account created (n°${core.authInfoList.count()}) {usr: ${authInfo.username}, pwd: ${authInfo.password}, dmn: ${authInfo.domain}}") + + return authInfo + } + + fun accountsReset() { + core.clearAllAuthInfo() + ghostAccounts.reset() + appAccountAuthInfo = createAccount() + } + + fun createAddress(authInfo: AuthInfo): Address { + return factory.createAddress("sip:" + authInfo.username + "@" + authInfo.domain)!! + } + + fun waitForAccountCreationStatus(wStatus: AccountCreator.Status, timeout: Double) = runBlocking { + var result = false + val wait = launch { delay(timeout.toLong() * 1000) } + val listener = object : AccountCreatorListenerStub() { + override fun onCreateAccount( + creator: AccountCreator, + status: AccountCreator.Status?, + response: String? + ) { + super.onCreateAccount(creator, status, response) + if (wStatus == status) { + Log.d("dsqdfs", status.ordinal) + result = true + wait.cancel() + } + } + } + accountCreator.addListener(listener) + wait.join() + accountCreator.removeListener(listener) + assert(result) { "[UITests] $wStatus account status still not verified after $timeout seconds" } + } +} + +class UITestsGhostAccounts(authInfoCreationFunction: () -> AuthInfo) { + private var mCores = mutableListOf() + var indexOffset = 0 + val count: Int + get() = mCores.count() + indexOffset + + private var newCore: (() -> AuthInfo) + + init { + newCore = authInfoCreationFunction + } + + fun reset() { + mCores.clear() + } + + operator fun get(index: Int): UITestsRegisteredLinphoneCore { + while (index >= mCores.count()) { + mCores.add(UITestsRegisteredLinphoneCore(newCore())) + } + return mCores[index] + } +} + +class UITestsRegisteredLinphoneCore(authInfo: AuthInfo) { + var mCore: Core + private val factory = Factory.instance() + var description: String + + private val manager = UITestsCoreManager.instance + + var mCoreListener: CoreListener private set + lateinit var mAccount: Account private set + var mAuthInfo: AuthInfo private set + + var callState = Call.State.Released + private set + var registrationState = RegistrationState.Cleared + private set + + init { + description = "Ghost Account (" + authInfo.username + ")" + factory.loggingService.setLogLevel(LogLevel.Debug) + + mCore = factory.createCore("", "", getApplicationContext()) + mCore.setDnsServersApp(manager.dnsServer) + + mCore.isVideoCaptureEnabled = true + mCore.isVideoDisplayEnabled = true + mCore.isRecordAwareEnabled = true + mCore.videoActivationPolicy.automaticallyAccept = true + + mCoreListener = object : CoreListenerStub() { + override fun onCallStateChanged( + core: Core, + call: Call, + state: Call.State?, + message: String + ) { + callState = state ?: Call.State.Released + Log.i("[UITests] ${authInfo.username} current call state is $callState") + } + + override fun onAccountRegistrationStateChanged( + core: Core, + account: Account, + state: RegistrationState?, + message: String + ) { + registrationState = state ?: RegistrationState.Cleared + Log.i("[UITests] New registration state \"$state\" for user ${account.params.identityAddress?.username}") + } + } + mCore.addListener(mCoreListener) + + mCore.playFile = "sounds/hello8000.wav" + mCore.useFiles = true + + mCore.start() + + mAuthInfo = authInfo + login(TransportType.Tcp) + } + + fun login(transport: TransportType) { + val accountParams = mCore.createAccountParams() + val identity = manager.createAddress(mAuthInfo) + accountParams.setIdentityAddress(identity) + val address = factory.createAddress("sip:" + mAuthInfo.domain)!! + address.transport = transport + accountParams.serverAddress = address + accountParams.isRegisterEnabled = true + val account = mCore.createAccount(accountParams) + mCore.addAuthInfo(mAuthInfo) + mCore.addAccount(account) + mAccount = account + mCore.defaultAccount = mAccount + waitForRegistrationState(RegistrationState.Ok, 5.0) + } + + fun startCall(address: Address) { + val params = mCore.createCallParams(null)!! + params.mediaEncryption = MediaEncryption.None + params.recordFile = LinphoneUtils.getRecordingFilePathForAddress(address) + mCore.inviteAddressWithParams(address, params) + } + + fun terminateCall() { + if (mCore.callsNb == 0) { return } + val call = if (mCore.currentCall != null) mCore.currentCall else mCore.calls[0] + call ?: return + call.terminate() + } + + fun acceptCall() { + mCore.currentCall?.accept() + } + + fun toggleMicrophone() { + mCore.isMicEnabled = !mCore.isMicEnabled + } + + fun toggleSpeaker() { + val currentAudioDevice = mCore.currentCall?.outputAudioDevice + val speakerEnabled = currentAudioDevice?.type == AudioDevice.Type.Speaker + + for (audioDevice in mCore.audioDevices) { + if (speakerEnabled && audioDevice.type == AudioDevice.Type.Earpiece) { + mCore.currentCall?.outputAudioDevice = audioDevice + return + } else if (!speakerEnabled && audioDevice.type == AudioDevice.Type.Speaker) { + mCore.currentCall?.outputAudioDevice = audioDevice + return + } /* If we wanted to route the audio to a bluetooth headset + else if (audioDevice.type == AudioDevice.Type.Bluetooth) { + core.currentCall?.outputAudioDevice = audioDevice + }*/ + } + } + + fun toggleVideo() { + if (mCore.callsNb == 0) return + val call = if (mCore.currentCall != null) mCore.currentCall else mCore.calls[0] + call ?: return + + val params = mCore.createCallParams(call) + params?.isVideoEnabled = !call.currentParams.isVideoEnabled + call.update(params) + } + + fun toggleCamera() { + val currentDevice = mCore.videoDevice + for (camera in mCore.videoDevicesList) { + if (camera != currentDevice && camera != "StaticImage: Static picture") { + mCore.videoDevice = camera + break + } + } + } + + fun pauseCall() { + if (mCore.callsNb == 0) return + val call = if (mCore.currentCall != null) mCore.currentCall else mCore.calls[0] + call ?: return + call.pause() + } + + fun resumeCall() { + if (mCore.callsNb == 0) return + val call = if (mCore.currentCall != null) mCore.currentCall else mCore.calls[0] + call ?: return + call.resume() + } + + fun startRecording() { + mCore.currentCall?.startRecording() + } + + fun stopRecording() { + mCore.currentCall?.stopRecording() + } + + fun waitForRegistrationState(registrationState: RegistrationState, timeout: Double) = runBlocking { + var result = false + val wait = launch { delay(timeout.toLong() * 1000) } + val listener = object : AccountListenerStub() { + override fun onRegistrationStateChanged( + account: Account, + state: RegistrationState?, + message: String + ) { + super.onRegistrationStateChanged(account, state, message) + if (registrationState == state) { + result = true + wait.cancel() + } + } + } + mCore.defaultAccount!!.addListener(listener) + wait.join() + mCore.defaultAccount!!.removeListener(listener) + assert(result) { "[UITests] $registrationState registration state still not verified after $timeout seconds" } + } + + fun waitForCallState(callState: Call.State, timeout: Double) = runBlocking { + var result = false + val wait = launch { delay(timeout.toLong() * 1000) } + val listener = object : CoreListenerStub() { + override fun onCallStateChanged( + core: Core, + call: Call, + state: Call.State?, + message: String + ) { + super.onCallStateChanged(core, call, state, message) + if (callState == state) { + result = true + wait.cancel() + } + } + } + mCore.addListener(listener) + wait.join() + mCore.removeListener(listener) + assert(result) { "[UITests] $callState call state still not verified after $timeout seconds" } + } + + fun waitForRecordingState(recording: Boolean, onRemote: Boolean = false, timeout: Double) = runBlocking { + var result = false + val wait = launch(Dispatchers.Default) { + repeat(timeout.toInt() * 10) { i -> + if (!onRemote && recording == mCore.currentCall?.params?.isRecording) { + result = true + cancel() + } + if (onRemote && recording == mCore.currentCall?.remoteParams?.isRecording) { + result = true + cancel() + } + delay(100) + } + } + val remoteText = if (onRemote) "remote" else "" + wait.join() + assert(result) { "[UITests] $remoteText call state still not $recording after ${timeout.toInt()} seconds" } + } +} diff --git a/app/src/androidTest/java/org/linphone/methods/UITestsScreenshots.kt b/app/src/androidTest/java/org/linphone/methods/UITestsScreenshots.kt new file mode 100644 index 000000000..0da71a2ec --- /dev/null +++ b/app/src/androidTest/java/org/linphone/methods/UITestsScreenshots.kt @@ -0,0 +1,61 @@ +package org.linphone.methods + +import android.os.Environment.DIRECTORY_PICTURES +import android.os.Environment.getExternalStoragePublicDirectory +import androidx.test.runner.screenshot.BasicScreenCaptureProcessor +import androidx.test.runner.screenshot.Screenshot +import java.io.File +import java.io.IOException +import org.linphone.core.tools.Log + +object UITestsScreenshots { + + var defaultPath = File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES), "") + var screenshotComparison = true + + fun definePath(testClass: String, testFunction: String, startTime: String) { + defaultPath = File( + File( + getExternalStoragePublicDirectory(DIRECTORY_PICTURES), + "linphone_uitests" + ).absolutePath, + "$testClass/$testFunction/$startTime" + ) + } + + private fun screenshot(screenShotName: String) { + Log.i("[UITests] Taking screenshot of '$screenShotName'") + val screenCapture = Screenshot.capture() + val processors = setOf(MyScreenCaptureProcessor()) + try { + screenCapture.apply { + name = screenShotName + process(processors) + } + Log.i("[UITests] Screenshot taken") + } catch (ex: IOException) { + Log.e("[UITests] Could not take a screenshot", ex) + } + } + + fun takeScreenshot( + name: String, + variant: String? = null, + line: Int = Throwable().stackTrace[1].lineNumber + ) { + if (!screenshotComparison) return + if (name.contains(".") || variant?.contains(".") == true) { + throw Exception("[UITests] \".\" character is forbidden for screencheck methods arguments name and variant") + } + screenshot(line.toString() + ".$name" + if (variant != null) ".$variant" else "") + } +} + +class MyScreenCaptureProcessor : BasicScreenCaptureProcessor() { + + init { + this.mDefaultScreenshotPath = UITestsScreenshots.defaultPath + } + + override fun getFilename(prefix: String): String = prefix +} diff --git a/app/src/androidTest/java/org/linphone/methods/UITestsUtils.kt b/app/src/androidTest/java/org/linphone/methods/UITestsUtils.kt new file mode 100644 index 000000000..a5a772978 --- /dev/null +++ b/app/src/androidTest/java/org/linphone/methods/UITestsUtils.kt @@ -0,0 +1,167 @@ +package org.linphone.methods + +import android.content.Intent +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Observer +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.espresso.* +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import java.util.* +import kotlinx.coroutines.* +import org.hamcrest.Matcher +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.linphone.LinphoneApplication +import org.linphone.R +import org.linphone.activities.main.MainActivity +import org.linphone.activities.main.settings.viewmodels.AccountSettingsViewModel +import org.linphone.activities.main.viewmodels.StatusViewModel +import org.linphone.core.Factory +import org.linphone.core.TransportType +import org.linphone.core.tools.Log + +class ScreenshotsRule(active: Boolean) : TestWatcher() { + + val screenshotComparison = active + + override fun starting(description: Description) { + super.starting(description) + UITestsScreenshots.screenshotComparison = screenshotComparison + UITestsScreenshots.definePath(description.className, description.methodName, Date().time.toString()) + if (screenshotComparison && !UITestsScreenshots.defaultPath.isDirectory) { + UITestsScreenshots.defaultPath.mkdirs() + } + } +} + +object UITestsUtils { + + private var mainActivityIntent = Intent(getApplicationContext(), MainActivity::class.java) + var activityScenario: ActivityScenario? = null + + fun testAppSetup() { + // launch app + Log.i("[UITests] Launch Linphone app") + if (!isAppLaunch()) { launchApp() } + if (!rightAccountConnected() || !accountIsConnected()) { + removeAllAccounts() + connectAccount() + assert(accountIsConnected()) { "registration state on the Status Bar is still not : Connected after 10 seconds" } + } + onView(withId(R.id.dialer_layout)).checkWithTimeout(matches(isDisplayed()), 5.0) + } + + fun launchApp() { + if (isAppLaunch()) activityScenario?.close() + activityScenario = ActivityScenario.launch(mainActivityIntent) + } + + fun isAppLaunch(): Boolean { + if (activityScenario != null) return activityScenario!!.state != Lifecycle.State.DESTROYED + return false + } + + fun accountIsConnected(): Boolean { + var result = false + runBlocking { + val wait = launch { delay(5000) } + val observer = Observer { + if (it == R.string.status_connected) { + result = true + wait.cancel() + } + } + lateinit var viewModel: StatusViewModel + getInstrumentation().runOnMainSync { + viewModel = StatusViewModel() + viewModel.registrationStatusText.observeForever(observer) + } + wait.join() + getInstrumentation().runOnMainSync { viewModel.registrationStatusText.removeObserver(observer) } + } + return result + } + + fun rightAccountConnected(): Boolean { + val realAccount = LinphoneApplication.coreContext.core.defaultAccount?.findAuthInfo()?.username + val expectedAccount = UITestsCoreManager.instance.appAccountAuthInfo.username + return realAccount == expectedAccount + } + + fun connectAccount() { + val manager = UITestsCoreManager.instance + manager.accountsReset() + Log.i("[UITests] Connect ${manager.appAccountAuthInfo.username} user to Linphone app") + val core = LinphoneApplication.coreContext.core + LinphoneApplication.corePreferences.useDnsServer = true + LinphoneApplication.corePreferences.dnsServerAddress = manager.dnsServer.first() + val accountParams = core.createAccountParams() + val identity = manager.createAddress(manager.appAccountAuthInfo) + accountParams.identityAddress = identity + val address = Factory.instance().createAddress("sip:" + manager.appAccountAuthInfo.domain)!! + address.transport = TransportType.Tcp + accountParams.serverAddress = address + accountParams.isRegisterEnabled = true + val account = core.createAccount(accountParams) + core.addAuthInfo(manager.appAccountAuthInfo) + core.addAccount(account) + core.defaultAccount = account + } + + fun removeAllAccounts() { + Log.i("[UITests] Remove all accounts from the Linphone app core") + for (account in LinphoneApplication.coreContext.core.accountList) { + getInstrumentation().runOnMainSync { + val viewModel = AccountSettingsViewModel(account) + viewModel.deleteListener.onClicked() + } + } + } + + fun waitForExistence(matcher: Matcher, timeout: Double) { + return waitForView(matcher, timeout, true) + } + + fun waitForNonExistence(matcher: Matcher, timeout: Double) { + return waitForView(matcher, timeout, false) + } + + private fun waitForView(matcher: Matcher, timeout: Double, exist: Boolean) = runBlocking { + var result = false + val wait = launch(Dispatchers.Default) { + repeat(timeout.toInt() * 10) { + try { + onView(matcher).check(matches(isDisplayed())) + result = true + cancel() + } catch (_: Exception) { + // do nothing to retry until timeout + } + delay(100) + } + } + wait.join() + assert(result) { "[UITests] $matcher still ${if (exist) "not " else ""}displayed after $timeout seconds" } + } + + fun ViewInteraction.checkWithTimeout(viewAssert: ViewAssertion, timeout: Double): ViewInteraction = runBlocking { + val wait = launch(Dispatchers.Default) { + repeat(timeout.toInt() * 10) { + try { + check(viewAssert) + cancel() + } catch (e: Throwable) { + // do nothing to retry until timeout + } + delay(100) + } + } + wait.join() + check(viewAssert) + } +} diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.declined.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.declined.pkl new file mode 100644 index 000000000..8f1794636 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.declined.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.no_answer.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.no_answer.pkl new file mode 100644 index 000000000..43406eebf Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.no_answer.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.pkl new file mode 100644 index 000000000..d706fa5f0 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/dialer_view.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.mute.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.mute.pkl new file mode 100644 index 000000000..eb9293484 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.mute.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.pkl new file mode 100644 index 000000000..fa48ccad6 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.speaker.pkl b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.speaker.pkl new file mode 100644 index 000000000..3bfae90bc Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/data/outgoing_call_view.speaker.pkl differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.declined.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.declined.png new file mode 100644 index 000000000..bff08267a Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.declined.png differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.no_answer.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.no_answer.png new file mode 100644 index 000000000..7d09bfa18 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.no_answer.png differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.png new file mode 100644 index 000000000..0c5e43094 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/dialer_view.png differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.mute.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.mute.png new file mode 100644 index 000000000..0e60066af Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.mute.png differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.png new file mode 100644 index 000000000..2bd23f15f Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.png differ diff --git a/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.speaker.png b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.speaker.png new file mode 100644 index 000000000..f17b678c9 Binary files /dev/null and b/app/src/androidTest/java/org/linphone/screenshots/1080x2220/outgoing_call_view.speaker.png differ diff --git a/app/src/androidTest/java/org/linphone/testsuites/CallTestSuite.kt b/app/src/androidTest/java/org/linphone/testsuites/CallTestSuite.kt new file mode 100644 index 000000000..807b978e4 --- /dev/null +++ b/app/src/androidTest/java/org/linphone/testsuites/CallTestSuite.kt @@ -0,0 +1,11 @@ +package org.linphone.testsuites + +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.linphone.call.OutgoingCallUITests + +@RunWith(Suite::class) +@Suite.SuiteClasses( + OutgoingCallUITests::class +) +class CallTestSuite diff --git a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt index c282b006e..17c178ab4 100644 --- a/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt +++ b/app/src/main/java/org/linphone/activities/main/settings/viewmodels/NetworkSettingsViewModel.kt @@ -21,6 +21,7 @@ package org.linphone.activities.main.settings.viewmodels import androidx.lifecycle.MutableLiveData import java.lang.NumberFormatException +import org.linphone.LinphoneApplication import org.linphone.activities.main.settings.SettingListenerStub class NetworkSettingsViewModel : GenericSettingsViewModel() { @@ -58,11 +59,29 @@ class NetworkSettingsViewModel : GenericSettingsViewModel() { } val sipPort = MutableLiveData() + val useDnsServerListener = object : SettingListenerStub() { + override fun onBoolValueChanged(newValue: Boolean) { + if (newValue) core.setDnsServersApp(arrayOf(dnsServerAddress.value)) + else core.setDnsServersApp(arrayOf()) + LinphoneApplication.corePreferences.useDnsServer = newValue + } + } + val useDnsServer = MutableLiveData() + + val dnsServerAddressListener = object : SettingListenerStub() { + override fun onTextValueChanged(newValue: String) { + LinphoneApplication.corePreferences.dnsServerAddress = newValue + } + } + val dnsServerAddress = MutableLiveData() + init { wifiOnly.value = core.isWifiOnlyEnabled allowIpv6.value = core.isIpv6Enabled randomPorts.value = getTransportPort() == -1 sipPort.value = getTransportPort() + useDnsServer.value = LinphoneApplication.corePreferences.useDnsServer + dnsServerAddress.value = LinphoneApplication.corePreferences.dnsServerAddress } private fun setTransportPort(port: Int) { diff --git a/app/src/main/java/org/linphone/activities/voip/fragments/SingleCallFragment.kt b/app/src/main/java/org/linphone/activities/voip/fragments/SingleCallFragment.kt index bdbf92075..eee5aaa6f 100644 --- a/app/src/main/java/org/linphone/activities/voip/fragments/SingleCallFragment.kt +++ b/app/src/main/java/org/linphone/activities/voip/fragments/SingleCallFragment.kt @@ -216,7 +216,7 @@ class SingleCallFragment : GenericVideoPreviewFragment