Skip to content

Commit

Permalink
feat: Add a possibility to set app locale (#580)
Browse files Browse the repository at this point in the history
  • Loading branch information
mykola-mokhnach authored May 24, 2020
1 parent 879453b commit 74910bf
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 57 deletions.
Empty file removed emulator.log
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ class CreateSession : RequestHandler<SessionParams, Session> {
val shouldLaunchApp = (parsedCaps["autoLaunch"] ?: true) as Boolean
if (shouldLaunchApp) {
@Suppress("UNCHECKED_CAST")
startActivity(
StartActivityParams(
parsedCaps["appPackage"] as? String,
parsedCaps["appActivity"] as? String,
parsedCaps["intentOptions"] as? Map<String, Any?>,
parsedCaps["activityOptions"] as? Map<String, Any?>)
)
startActivity(StartActivityParams(
parsedCaps["appPackage"] as? String,
parsedCaps["appActivity"] as? String,
parsedCaps["appLocale"] as? Map<String, Any?>,
parsedCaps["intentOptions"] as? Map<String, Any?>,
parsedCaps["activityOptions"] as? Map<String, Any?>
))
}
} catch (e: Exception) {
throw SessionNotCreatedException(e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,83 +17,107 @@
package io.appium.espressoserver.lib.helpers

import android.app.Activity
import android.app.Instrumentation
import android.util.ArrayMap
import android.content.Context
import android.os.Build

import android.util.ArrayMap
import androidx.test.platform.app.InstrumentationRegistry

import io.appium.espressoserver.lib.handlers.exceptions.AppiumException
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import io.appium.espressoserver.lib.model.StartActivityParams
import io.appium.espressoserver.lib.model.mapToLocaleParams

object ActivityHelpers {
// https://androidreclib.wordpress.com/2014/11/22/getting-the-current-activity/
val currentActivity: Activity
@Throws(AppiumException::class)
get() {
try {
val activityThreadClass = Class.forName("android.app.ActivityThread")
val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null)
val activitiesField = activityThreadClass.getDeclaredField("mActivities")
activitiesField.isAccessible = true
val activities = activitiesField.get(activityThread) as ArrayMap<*, *>
for (activityRecord in activities.values) {
val activityRecordClass = activityRecord.javaClass
val pausedField = activityRecordClass.getDeclaredField("paused")
pausedField.isAccessible = true
if (!pausedField.getBoolean(activityRecord)) {
val activityField = activityRecordClass.getDeclaredField("activity")
activityField.isAccessible = true
return activityField.get(activityRecord) as Activity
val method1 = fun(): Activity? {
// https://stackoverflow.com/questions/38737127/espresso-how-to-get-current-activity-to-test-fragments/58684943#58684943
var result: Activity? = null
InstrumentationRegistry.getInstrumentation()
.runOnMainSync {
run {
result = ActivityLifecycleMonitorRegistry.getInstance()
.getActivitiesInStage(Stage.RESUMED)
.elementAtOrNull(0)
}
}
return result
}
val method2 = fun(): Activity? {
// https://androidreclib.wordpress.com/2014/11/22/getting-the-current-activity/
try {
val activityThreadClass = Class.forName("android.app.ActivityThread")
val activityThread = activityThreadClass.getMethod("currentActivityThread")
.invoke(null)
val activitiesField = activityThreadClass.getDeclaredField("mActivities")
activitiesField.isAccessible = true
val activities = activitiesField.get(activityThread) as ArrayMap<*, *>
for (activityRecord in activities.values) {
val activityRecordClass = activityRecord.javaClass
val pausedField = activityRecordClass.getDeclaredField("paused")
pausedField.isAccessible = true
if (!pausedField.getBoolean(activityRecord)) {
val activityField = activityRecordClass.getDeclaredField("activity")
activityField.isAccessible = true
return activityField.get(activityRecord) as Activity
}
}
} catch (e: Exception) {
// ignore
}
return null
}
for (method in listOf(method1, method2)) {
val result = method()
if (result != null) {
return result
}
} catch (e: Exception) {
throw AppiumException(e)
}

throw AppiumException("Failed to get current Activity")
throw IllegalStateException("Cannot retrieve the current activity")
}

/**
* https://android.googlesource.com/platform/frameworks/base/+/master/tools/aapt/Resource.cpp#755
*
* @param instrumentation Instrumentation instance
* @param context Target context instance
* @param pkg app package name
* @param activity activity name shortcut to be qualified
* @return The qualified activity name
*/
private fun getFullyQualifiedActivityName(instrumentation: Instrumentation,
pkg: String?, activity: String): String {
val appPackage = pkg ?: instrumentation.targetContext.packageName
private fun getFullyQualifiedActivityName(context: Context, pkg: String?, activity: String): String {
val appPackage = pkg ?: context.packageName
val dotPos = activity.indexOf(".")
return (if (dotPos > 0) activity else "$appPackage${(if (dotPos == 0) "" else ".")}$activity")
}



fun startActivity(params: StartActivityParams) {
if (params.appActivity == null && params.optionalIntentArguments == null) {
throw IllegalArgumentException("Either activity name or intent options must be set")
}

val instrumentation = InstrumentationRegistry.getInstrumentation()
params.locale?.let {
changeLocale(instrumentation.targetContext.applicationContext, mapToLocaleParams(it).toLocale())
}

val intent = if (params.optionalIntentArguments == null) {
val fullyQualifiedAppActivity = getFullyQualifiedActivityName(instrumentation, params.appPackage, params.appActivity!!)
val fullyQualifiedAppActivity = getFullyQualifiedActivityName(instrumentation.targetContext,
params.appPackage, params.appActivity!!)
val defaultOptions = mapOf<String, Any>(
"action" to "ACTION_MAIN",
"flags" to "ACTIVITY_NEW_TASK",
"className" to fullyQualifiedAppActivity
)
AndroidLogger.logger.info("Starting activity '$fullyQualifiedAppActivity' " +
"with default options: $defaultOptions")
makeIntent(defaultOptions)
makeIntent(instrumentation.targetContext, defaultOptions)
} else {
AndroidLogger.logger.info("Staring activity with custom options: ${params.optionalIntentArguments}")
makeIntent(params.optionalIntentArguments)
makeIntent(instrumentation.targetContext, params.optionalIntentArguments)
}

if (params.optionalActivityArguments == null) {
instrumentation.startActivitySync(intent)
instrumentation.startActivitySync(intent)
} else {
makeActivityOptions(params.optionalActivityArguments).let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Expand All @@ -105,5 +129,4 @@ object ActivityHelpers {
}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
package io.appium.espressoserver.lib.helpers

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.test.platform.app.InstrumentationRegistry
import kotlin.reflect.KClass
import kotlin.reflect.full.cast

Expand Down Expand Up @@ -152,7 +152,7 @@ private fun String.toComponentName(): ComponentName = ComponentName.unflattenFro
* @throws IllegalArgumentException if required options are missing or
* there is an issue with mapping value format
*/
fun makeIntent(options: Map<String, Any?>): Intent {
fun makeIntent(context: Context?, options: Map<String, Any?>): Intent {
val intent = Intent()
var hasIntentInfo = false
var data: Uri? = null
Expand All @@ -177,8 +177,7 @@ fun makeIntent(options: Map<String, Any?>): Intent {
hasIntentInfo = true
},
"className" to fun(key, value) {
intent.setClassName(InstrumentationRegistry.getInstrumentation().targetContext,
requireString(key, value))
intent.setClassName(context!!, requireString(key, value))
},
"component" to fun(key, value) {
intent.component = requireString(key, value).toComponentName()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.appium.espressoserver.lib.helpers

import android.content.Context
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.LocaleList
import java.util.*


fun changeLocale(context: Context, locale: Locale) {
val res = context.applicationContext.resources
val config = res.configuration

Locale.setDefault(locale)
if (SDK_INT >= Build.VERSION_CODES.O) {
Locale.setDefault(Locale.Category.DISPLAY, locale)
}

if (SDK_INT >= Build.VERSION_CODES.N) {
val localeList = LocaleList(locale)
LocaleList.setDefault(localeList)
config.locales = localeList
context.createConfigurationContext(config)
} else {
config.setLocale(locale)
config.setLayoutDirection(locale)
@Suppress("DEPRECATION")
res.updateConfiguration(config, res.displayMetrics)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.appium.espressoserver.lib.model

import java.util.*

// https://github.com/libyal/libfwnt/wiki/Language-Code-identifiers
data class LocaleParams(
val language: String,
val country: String? = null,
val variant: String? = null
) : AppiumParams() {
fun toLocale(): Locale {
return Locale(language, country ?: "", variant ?: "")
}
}

fun mapToLocaleParams(map: Map<String, Any?>): LocaleParams {
return LocaleParams(map["language"] as String, map["country"] as? String, map["variant"] as? String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.appium.espressoserver.lib.model
data class StartActivityParams(
val appPackage: String? = null,
val appActivity: String? = null,
val locale: Map<String, Any?>? = null,
val optionalIntentArguments: Map<String, Any?>? = null,
val optionalActivityArguments: Map<String, Any?>? = null
) : AppiumParams()
Original file line number Diff line number Diff line change
Expand Up @@ -30,54 +30,54 @@ class `Intent Creation Tests` {

@Test(expected = Exception::class)
fun `should throw if none of the required options have been provided`(){
makeIntent(mapOf())
makeIntent(null, mapOf())
}

@Test(expected = Exception::class)
fun `should throw if an invalid option name as been provided`(){
makeIntent(mapOf(
makeIntent(null, mapOf(
"foo" to "bar"
))
}

@Test
fun `should create intent if options were set properly`(){
assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"action" to "io.appium.espresso"
)).action, "io.appium.espresso")

assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"data" to "content://contacts/people/1"
)).data!!.toString(), "content://contacts/people/1")

assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"type" to "image/png"
)).type!!.toString(), "image/png")

assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"categories" to "android.intent.category.APP_CONTACTS, android.intent.category.DEFAULT"
)).categories.size, 2)
}

@Test
fun `should create intent with valid intFlags`(){
assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"action" to "io.appium.espresso",
"intFlags" to "0x0F"
)).flags, 0x0F)
}

@Test
fun `should create intent with valid flags`(){
assertEquals(makeIntent(mapOf(
assertEquals(makeIntent(null, mapOf(
"action" to "io.appium.espresso",
"flags" to "GRANT_READ_URI_PERMISSION, FLAG_EXCLUDE_STOPPED_PACKAGES"
)).flags, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_EXCLUDE_STOPPED_PACKAGES)
}

@Test
fun `should create intent with valid single extra args`(){
val intent = makeIntent(mapOf<String, Any>(
val intent = makeIntent(null, mapOf(
"action" to "io.appium.espresso",
"e" to mapOf(
"bar" to "baz",
Expand All @@ -101,7 +101,7 @@ class `Intent Creation Tests` {

@Test
fun `should create intent with valid array extra args`(){
val intent = makeIntent(mapOf<String, Any>(
val intent = makeIntent(null, mapOf<String, Any>(
"action" to "io.appium.espresso",
"eia" to mapOf("intarr" to "1,2, 3"),
"ela" to mapOf("longarr" to "4, 5, 6"),
Expand Down
2 changes: 1 addition & 1 deletion espresso-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ buildscript {
(propertyValue) ? Boolean.parseBoolean(propertyValue) : defaultValue
}

ext.android_gradle_plugin_version = getStringProperty('appiumAndroidGradlePlugin', '4.0.0-beta05')
ext.android_gradle_plugin_version = getStringProperty('appiumAndroidGradlePlugin', '4.0.0-rc01')
ext.kotlin_version = getStringProperty('appiumKotlin', '1.3.72')

repositories {
Expand Down
3 changes: 3 additions & 0 deletions lib/desired-caps.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ let espressoCapConstraints = {
},
activityOptions: {
isObject: true
},
appLocale: {
isObject: true,
}
};

Expand Down

0 comments on commit 74910bf

Please sign in to comment.