incoming/outgoing call uitests

This commit is contained in:
Quentin Monnier 2022-12-01 13:19:02 -05:00 committed by Sylvain Berfini
parent e0cc864a85
commit 7795bdd434
29 changed files with 1214 additions and 5 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "screport"]
path = screport
url = https://gitlab.linphone.org/BC/public/screport.git

View file

@ -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 = "<a href=\"#tab0\">Tests</a>\n</li>\n</ul>\n<div id=\"tab0\" class=\"tab\">"
def tab0Index = failedTestJunitReportContent.indexOf(tab0)
if (tab0Index != -1) {
def newtab0 = "<a href=\"#tab0\">Failed tests</a>\n</li>\n<li>\n<a href=\"#tab1\">Tests</a>\n</li>\n</ul>\n<div id=\"tab0\" class=\"tab\">\n<h2>Failed tests</h2>\n</div>\n<div id=\"tab1\" class=\"tab\">"
failedTestJunitReportContent = failedTestJunitReportContent.replace(tab0,newtab0)
}
def failedTest = "<h3 class=\"failures\">${failedTestName}</h3>"
def failedTestIndex = failedTestJunitReportContent.indexOf(failedTest)
def screenshotReport = ""
if (failedTestIndex == -1) {
def newtestDiv = "<div class=\"test\">\n<a name=\"${failedTestName}\"></a>\n${failedTest}\n<span class=\"code\">\n<pre>\n</pre>\n</span>\n</div>"
def endDiv = "</div>\n<div id=\"tab1\" class=\"tab\">"
failedTestJunitReportContent = failedTestJunitReportContent.replace(endDiv,newtestDiv+endDiv)
failedTestJunitReportContent = failedTestJunitReportContent.replace("<td>${failedTestName}</td>\n<td class=\"success\">passed","<td>${failedTestName}</td>\n<td class=\"failures\">failed")
def totalCounterStr = "id=\"tests\">\n<div class=\"counter\">"
def totalCounterStart = failedTestJunitReportContent.indexOf(totalCounterStr) + totalCounterStr.length()
def totalCounterEnd = failedTestJunitReportContent.indexOf("</div>",totalCounterStart)
def failureCounterStr = "id=\"failures\">\n<div class=\"counter\">"
def failureCounterStart = failedTestJunitReportContent.indexOf(failureCounterStr,totalCounterEnd) + failureCounterStr.length()
def failureCounterEnd = failedTestJunitReportContent.indexOf("</div>",failureCounterStart)
def percentInfoStr = "id=\"successRate\">\n<div class=\"percent\">"
def percentInfoStart = failedTestJunitReportContent.indexOf(percentInfoStr,failureCounterEnd) + percentInfoStr.length()
def percentInfoEnd = failedTestJunitReportContent.indexOf("%</div>",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 += "<div style=\"display: table\">\n"
screenshotReport += "<img src=\"${imagesrc}/reference.png\" width =\"220\" style=\"padding: 5px\"/>\n"
screenshotReport += "<img src=\"${imagesrc}/capture.png\" width =\"220\" style=\"padding: 5px\"/>\n"
screenshotReport += "<img src=\"${imagesrc}/difference.png\" width =\"220\" style=\"padding: 5px\"/>\n"
screenshotReport += "</div>"
def insertIndex = failedTestJunitReportContent.indexOf("</pre>",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
}
}

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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" }
}
}

View file

@ -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<UITestsRegisteredLinphoneCore>()
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" }
}
}

View file

@ -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
}

View file

@ -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<MainActivity>? = 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<Int> {
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<View>, timeout: Double) {
return waitForView(matcher, timeout, true)
}
fun waitForNonExistence(matcher: Matcher<View>, timeout: Double) {
return waitForView(matcher, timeout, false)
}
private fun waitForView(matcher: Matcher<View>, 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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -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

View file

@ -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<Int>()
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<Boolean>()
val dnsServerAddressListener = object : SettingListenerStub() {
override fun onTextValueChanged(newValue: String) {
LinphoneApplication.corePreferences.dnsServerAddress = newValue
}
}
val dnsServerAddress = MutableLiveData<String>()
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) {

View file

@ -216,7 +216,7 @@ class SingleCallFragment : GenericVideoPreviewFragment<VoipSingleCallFragmentBin
private fun updateHingeRelatedConstraints(feature: FoldingFeature) {
Log.i("[Single Call] Updating constraint layout hinges: $feature")
val constraintLayout = binding.constraintLayout
val constraintLayout = binding.singleCallLayout
val set = ConstraintSet()
set.clone(constraintLayout)

View file

@ -429,6 +429,11 @@ class CoreContext(
core.config.setInt("misc", "conference_layout", 1)
}
// Dns Server init
if (core.config.getBool("app", "use_custom_dns_server", false)) {
core.setDnsServersApp(arrayOf(core.config.getString("app", "custom_dns_address", "") ?: ""))
}
// Now LIME server URL is set on accounts
val limeServerUrl = core.limeX3DhServerUrl.orEmpty()
if (limeServerUrl.isNotEmpty()) {

View file

@ -132,6 +132,18 @@ class CorePreferences constructor(private val context: Context) {
config.setBool("app", "read_and_agree_terms_and_privacy", value)
}
var useDnsServer: Boolean
get() = config.getBool("app", "use_custom_dns_server", false)
set(value) {
config.setBool("app", "use_custom_dns_server", value)
}
var dnsServerAddress: String
get() = config.getString("app", "custom_dns_address", "") ?: ""
set(value) {
config.setString("app", "custom_dns_address", value)
}
/* UI */
var forcePortrait: Boolean
@ -643,6 +655,9 @@ class CorePreferences constructor(private val context: Context) {
val configPath: String
get() = context.filesDir.absolutePath + "/.linphonerc"
val uiTestsConfigPath: String
get() = context.filesDir.absolutePath + "/.linphonerc_uitests"
val factoryConfigPath: String
get() = context.filesDir.absolutePath + "/linphonerc"

1
screport Submodule

@ -0,0 +1 @@
Subproject commit 58525a25b6a9bcbe79e451dced5e9424b21424cc