Skip to content

Commit

Permalink
[MOB-17661] Lifecycle v2 set BCP 47 style language tag for XDM field …
Browse files Browse the repository at this point in the history
…environment._dc.language (#293)

* LifecycleV2 sets langauge code using Locale.toLanguageTag from SDK >=21.
Fixes issue where some locale strings fail XDM validation. The failure is due to the use of Locale.toString, which doesn't always produce a valid locale tag. For SDK level 21 and higher, use Locale.toLanguageTag to set Environment._dc.language, and for lower API levels use simple concatenation of Locale.getLanguage and Locale.getCountry.

* Add validation for _dc.language string in XDMLifecycleEnvironment class

* Remove regex pattern tests from LifecycleUtilLocaleTest, tests moved to XDMLifecycleEnvironmentLanguageTest

* refactor LifecycleUtil.formatLocaleXDM for readability

* Update comment for LifecycleUtil.formatLocale to clarify the response format

* Refactor LifecycleUtil.formatLocaleXDM to conform to checkstyle rules

* use DataReader in test case to avoid NPE

* fix format

* Reduce nesting statements in getlocaleBCPString
  • Loading branch information
kevinlind authored Dec 13, 2022
1 parent aa9efbf commit 11173bc
Show file tree
Hide file tree
Showing 5 changed files with 454 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

package com.adobe.marketing.mobile.lifecycle;

import android.annotation.SuppressLint;
import android.os.Build;
import androidx.annotation.VisibleForTesting;
import com.adobe.marketing.mobile.util.StringUtils;
import java.text.SimpleDateFormat;
import java.util.Date;
Expand Down Expand Up @@ -59,12 +62,56 @@ private static String dateToISO8601String(final Date timestamp, final String tim
}

/**
* Formats the locale value from SystemInfoService and replaces '_' with '-'
* Formats the locale value by replacing '_' with '-'. Uses {@link Locale#toString()} to
* retrieve the language tag.
*
* <p>Note. the use of {@code Locale#toString()} does not return a value formatted to BCP 47.
* For example, script codes are appended to the locale as "-#scriptCode", as in "zh-HK-#Hant",
* where BCP 47 requires the format as "zh-Hant-HK".
*
* @see #formatLocaleXDM(Locale)
* @param locale active locale value
* @return string representation of the locale
*/
static String formatLocale(final Locale locale) {
return locale == null ? null : locale.toString().replace('_', '-');
}

/**
* Format the locale to the string format used in XDM. For Android API version >= Lollipop (21),
* returns {@link Locale#toLanguageTag()}. For Android API version < 21, returns a concatenation
* of {@link Locale#getLanguage()} and {@link Locale#getCountry()}, separated by '-'.
*
* @param locale active Locale value
* @return String representation of the locale
*/
@SuppressLint("NewApi")
static String formatLocaleXDM(final Locale locale) {
if (locale == null) {
return null;
}

if (isLollipopOrGreater.check()) {
return locale.toLanguageTag();
}

String language = locale.getLanguage();
String region = locale.getCountry();

if (StringUtils.isNullOrEmpty(language)) {
return null;
}

return StringUtils.isNullOrEmpty(region)
? language
: String.format("%s-%s", language, region);
}

interface BuildVersionCheck {
boolean check();
}

@VisibleForTesting
static BuildVersionCheck isLollipopOrGreater =
() -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
* </ul>
*/
class LifecycleV2MetricsBuilder {

private static final String SELF_LOG_TAG = "LifecycleV2MetricsBuilder";
private final DeviceInforming deviceInfoService;
private XDMLifecycleDevice xdmDeviceInfo;
Expand Down Expand Up @@ -195,7 +194,7 @@ private XDMLifecycleEnvironment computeEnvironmentData() {
xdmEnvironmentInfo.setOperatingSystem(deviceInfoService.getOperatingSystemName());
xdmEnvironmentInfo.setOperatingSystemVersion(deviceInfoService.getOperatingSystemVersion());
xdmEnvironmentInfo.setLanguage(
LifecycleUtil.formatLocale(deviceInfoService.getActiveLocale()));
LifecycleUtil.formatLocaleXDM(deviceInfoService.getActiveLocale()));

return xdmEnvironmentInfo;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@

package com.adobe.marketing.mobile.lifecycle;

import androidx.annotation.NonNull;
import com.adobe.marketing.mobile.services.Log;
import com.adobe.marketing.mobile.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

/**
* Class {@code Environment} representing a subset of the XDM Environment data type fields.
Expand All @@ -21,12 +25,15 @@
*/
@SuppressWarnings("unused")
class XDMLifecycleEnvironment {

private final String LOG_SOURCE = "XDMLifecycleEnvironment";
private String carrier;
private String language;
private String operatingSystem;
private String operatingSystemVersion;
private XDMLifecycleEnvironmentTypeEnum type;
private final String languageRegex =
"^(((([A-Za-z]{2,3}(-([A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-([A-Za-z]{4}))?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-([0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(x(-[A-Za-z0-9]{1,8})+))?)|(x(-[A-Za-z0-9]{1,8})+)|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$";
private final Pattern languagePattern = Pattern.compile(languageRegex);

XDMLifecycleEnvironment() {}

Expand All @@ -37,10 +44,19 @@ Map<String, Object> serializeToXdm() {
map.put("carrier", this.carrier);
}

if (this.language != null) {
Map<String, Object> dublinCoreLanguage = new HashMap<String, Object>();
dublinCoreLanguage.put("language", this.language);
map.put("_dc", dublinCoreLanguage);
if (!StringUtils.isNullOrEmpty(this.language)) {
if (isValidLanguageTag(this.language)) {
Map<String, Object> dublinCoreLanguage = new HashMap<String, Object>();
dublinCoreLanguage.put("language", this.language);
map.put("_dc", dublinCoreLanguage);
} else {
Log.warning(
LifecycleConstants.LOG_TAG,
LOG_SOURCE,
"Language tag '%s' failed validation and will be dropped. Values for XDM"
+ " field 'environment._dc.language' must conform to BCP 47.",
this.language);
}
}

if (this.operatingSystem != null) {
Expand Down Expand Up @@ -164,4 +180,14 @@ XDMLifecycleEnvironmentTypeEnum getType() {
void setType(final XDMLifecycleEnvironmentTypeEnum newValue) {
this.type = newValue;
}

/**
* Validate the language tag is formatted per the XDM Environment Schema required pattern.
*
* @param tag the language tag to validate
* @return true if the language tag matches the pattern.
*/
private boolean isValidLanguageTag(@NonNull final String tag) {
return languagePattern.matcher(tag).matches();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
Copyright 2022 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. 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 REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

package com.adobe.marketing.mobile.lifecycle;

import static org.junit.Assert.assertEquals;

import android.os.Build;
import java.util.Arrays;
import java.util.Collection;
import java.util.Locale;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

@RunWith(Parameterized.class)
public class LifecycleUtilLocaleTest {

@After
public void teardown() {
// Reset build check version for tests outside this class
LifecycleUtil.isLollipopOrGreater =
() -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
}

@Parameterized.Parameters(name = "{index}: {0}")
public static Collection<Object[]> data() {
// 0: test name, 1: expected result for Android KitKat, 2: expected result for Android
// Lollipop, 3: Locale object to test with
return Arrays.asList(
new Object[][] {
{
// Language only
"Language only", "en", "en", new Locale.Builder().setLanguage("en").build()
},
{
// Region only
"Region only", null, "und-US", new Locale.Builder().setRegion("US").build()
},
{
// Script only
"Script only",
null,
"und-Hant",
new Locale.Builder().setScript("Hant").build()
},
{
// Variant only
"Variant only",
null,
"und-POSIX",
new Locale.Builder().setVariant("POSIX").build()
},
{
// Undefined
"Undefined Locale", null, "und", new Locale.Builder().build()
},
{
// Null
"Null Locale", null, null, null
},
{
// Language + Region
"Language + Region",
"es-US",
"es-US",
new Locale.Builder().setLanguage("es").setRegion("US").build()
},
{
// Language + Region + Variant
"Language + Region + Variant",
"de-DE",
"de-DE-POSIX",
new Locale("DE", "DE", "POSIX")
},
{
// Language + Variant
"Language + Variant",
"de",
"de-POSIX",
new Locale.Builder().setLanguage("de").setVariant("POSIX").build()
},
{
// Language + Script + Region + Variant
"Language + Script + Region + Variant",
"de-DE",
"de-Latn-DE-POSIX",
new Locale.Builder()
.setLanguage("de")
.setRegion("DE")
.setScript("Latn")
.setVariant("POSIX")
.build()
},
{
// Language + Script + Region
"Chinese Hong Kong",
"zh-HK",
"zh-Hant-HK",
new Locale.Builder()
.setLanguage("zh")
.setRegion("HK")
.setScript("Hant")
.build()
},
{
// Language + Script + Region
"Chinese China",
"zh-CN",
"zh-Hans-CN",
new Locale.Builder()
.setLanguage("zh")
.setRegion("CN")
.setScript("Hans")
.build()
},
{
// Language + Script
"Language + Script",
"it",
"it-Latn",
new Locale.Builder().setLanguage("it").setScript("Latn").build()
},
{
// Serbian Montenegro
"Serbian Montenegro",
"sr-ME",
"sr-ME-x-lvariant-Latn",
new Locale("sr", "ME", "Latn")
},
{
// "no" is treated as Norwegian Nynorsk (nn)
"Norwegian Nynorsk", "no-NO", "nn-NO", new Locale("no", "NO", "NY")
},
{
// Special case for Japanese Calendar
"ja-JP-u-ca-japanese",
"ja-JP",
"ja-JP-u-ca-japanese",
Locale.forLanguageTag("ja-JP-u-ca-japanese")
},
{
// Special case for Japanese Calendar
"Special case: ja_JP_JP",
"ja-JP",
"ja-JP-u-ca-japanese-x-lvariant-JP",
new Locale("JA", "JP", "JP")
},
{
// Special case for Thai Buddhist Calendar
"Special case: th_TH_TH",
"th-TH",
"th-TH-u-nu-thai-x-lvariant-TH",
new Locale("TH", "TH", "TH")
},
{
// Special case for Thai Buddhist Calendar
"th-TH-u-ca-buddhist",
"th-TH",
"th-TH-u-ca-buddhist",
Locale.forLanguageTag("th-TH-u-ca-buddhist")
},
{
// Special case for Thai Buddhist Calendar
"th-TH-u-ca-buddhist-nu-thai",
"th-TH",
"th-TH-u-ca-buddhist-nu-thai",
Locale.forLanguageTag("th-TH-u-ca-buddhist-nu-thai")
},
{
// Grandfathered locale
"Grandfathered i-klingon", "tlh", "tlh", Locale.forLanguageTag("i-klingon")
},
{
// English US with Buddhist Calendar
"en-US-ca-buddhist",
"en-US",
"en-US",
Locale.forLanguageTag("en-US-ca-buddhist")
}
});
}

@Parameterized.Parameter(0)
public String testName;

@Parameterized.Parameter(1)
public String expectedKitKat;

@Parameterized.Parameter(2)
public String expectedLollipop;

@Parameterized.Parameter(3)
public Locale testLocale;

@Test
public void testFormatLocaleXDM_usingLollipopSDK() {
LifecycleUtil.isLollipopOrGreater = () -> true;
String result = LifecycleUtil.formatLocaleXDM(testLocale);
assertEquals(expectedLollipop, result);
}

@Test
public void testFormatLocaleXDM_usingKitKatSDK() {
LifecycleUtil.isLollipopOrGreater = () -> false;
String result = LifecycleUtil.formatLocaleXDM(testLocale);
assertEquals(expectedKitKat, result);
}
}
Loading

0 comments on commit 11173bc

Please sign in to comment.