From 11173bca7ec8bf5b2ec00e70b5f21832400c3487 Mon Sep 17 00:00:00 2001 From: Kevin Lind <40409666+kevinlind@users.noreply.github.com> Date: Tue, 13 Dec 2022 11:12:08 -0800 Subject: [PATCH] [MOB-17661] Lifecycle v2 set BCP 47 style language tag for XDM field 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 --- .../mobile/lifecycle/LifecycleUtil.java | 49 +++- .../lifecycle/LifecycleV2MetricsBuilder.java | 3 +- .../lifecycle/XDMLifecycleEnvironment.java | 36 ++- .../lifecycle/LifecycleUtilLocaleTest.java | 218 ++++++++++++++++++ .../XDMLifecycleEnvironmentLanguageTest.java | 156 +++++++++++++ 5 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtilLocaleTest.java create mode 100644 code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironmentLanguageTest.java diff --git a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtil.java b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtil.java index 10c1474b1..f8b5e905e 100644 --- a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtil.java +++ b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtil.java @@ -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; @@ -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. * + *

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; } diff --git a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleV2MetricsBuilder.java b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleV2MetricsBuilder.java index 392356d32..aaa85986c 100644 --- a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleV2MetricsBuilder.java +++ b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/LifecycleV2MetricsBuilder.java @@ -30,7 +30,6 @@ * */ class LifecycleV2MetricsBuilder { - private static final String SELF_LOG_TAG = "LifecycleV2MetricsBuilder"; private final DeviceInforming deviceInfoService; private XDMLifecycleDevice xdmDeviceInfo; @@ -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; } diff --git a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironment.java b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironment.java index 75a6974af..8f4db8d4b 100644 --- a/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironment.java +++ b/code/android-lifecycle-library/src/main/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironment.java @@ -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. @@ -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() {} @@ -37,10 +44,19 @@ Map serializeToXdm() { map.put("carrier", this.carrier); } - if (this.language != null) { - Map dublinCoreLanguage = new HashMap(); - dublinCoreLanguage.put("language", this.language); - map.put("_dc", dublinCoreLanguage); + if (!StringUtils.isNullOrEmpty(this.language)) { + if (isValidLanguageTag(this.language)) { + Map dublinCoreLanguage = new HashMap(); + 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) { @@ -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(); + } } diff --git a/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtilLocaleTest.java b/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtilLocaleTest.java new file mode 100644 index 000000000..9948b82ab --- /dev/null +++ b/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/LifecycleUtilLocaleTest.java @@ -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 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); + } +} diff --git a/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironmentLanguageTest.java b/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironmentLanguageTest.java new file mode 100644 index 000000000..365a11857 --- /dev/null +++ b/code/android-lifecycle-library/src/test/java/com/adobe/marketing/mobile/lifecycle/XDMLifecycleEnvironmentLanguageTest.java @@ -0,0 +1,156 @@ +/* + 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 com.adobe.marketing.mobile.util.DataReader; +import com.adobe.marketing.mobile.util.DataReaderException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class XDMLifecycleEnvironmentLanguageTest { + + @Parameterized.Parameters(name = "{index}: {0} ({2})") + public static Collection data() { + // List of various language tags, some use BCP 47 formatting while others use + // Locale.toString formatting + // LifecycleUtil.formatLocaleXDM trims the language tag so not all these values are seen in + // end-to-end scenarios. + // 0: test name, 1: expected result 2: language tag to test + return Arrays.asList( + new Object[][] { + { + // Language only + "Language only", "en", "en" + }, + { + // Language + Region + "Language + Region", "es-US", "es-US" + }, + { + // Language + Region + Script + "Language + Script + Region", "zh-Hant-HK", "zh-Hant-HK" + }, + { + // Language + Region + Variant + "Language + Region + Variant", "de-DE-POSIX", "de-DE-POSIX" + }, + { + // Language + Region + Script + Variant + "Language + Script + Region + Variant", + "de-Latn-DE-POSIX", + "de-Latn-DE-POSIX" + }, + { + // Undefined Language + Region + "Undefined Language + Region", "und-US", "und-US" + }, + { + // Undefined Language + Script + "Undefined Language + Script", "und-Hant", "und-Hant" + }, + { + // Undefined Language + Variant + "Undefined Language + Variant", "und-POSIX", "und-POSIX" + }, + { + // Japanese Calendar - for compatibility + "Japanese Calendar", "ja-JP-x-lvariant-JP", "ja-JP-x-lvariant-JP" + }, + { + // Japanese Calendar + "Japanese Calendar", "ja-JP-u-ca-japanese", "ja-JP-u-ca-japanese" + }, + { + // Thai Buddhist Calendar + "Thai Buddhist Calendar", + "th-TH-u-ca-buddhist-nu-thai", + "th-TH-u-ca-buddhist-nu-thai" + }, + { + // Serbian Montenegro - for compatibility + "Serbian Montenegro", "sr-ME-x-lvariant-Latn", "sr-ME-x-lvariant-Latn" + }, + { + // Grandfathered + "Grandfather Klingon", "i-klingon", "i-klingon" + }, + { + // Null Locale + "Null locale", null, null + }, + { + // Empty Locale + "Empty locale", null, "" + }, + { + // Non BCP 47 tag - pound sign + "Invalid script format '-#'", null, "zh-HK-#Hant" + }, + { + // Non BCP 47 tag - ampersand + "Invalid calendar format '@'", null, "en-US@calendar=buddhist" + }, + { + // Non BCP 47 tag - double hyphen + "Invalid language + variant '--'", null, "de--POSIX" + }, + { + // Non BCP 47 tag - leading hyphen + "Invalid country only '-'", null, "-US" + }, + { + // Non BCP 47 tag - pound sign + "Invalid script + extension '#'", null, "zh-TW-#Hant-x-java" + }, + { + // Non BCP 47 tag - underscore instead of hyphen + "Invalid underscore", null, "en_US" + }, + { + // Non BCP 47 tag - Thai special case + "Invalid Thai special case", null, "th-TH-TH-#u-nu-thai" + }, + }); + } + + @Parameterized.Parameter() public String testName; + + @Parameterized.Parameter(1) + public String expected; + + @Parameterized.Parameter(2) + public String languageTag; + + // Test various language tag strings and verify only valid language tags are returned in XDM + // mapping. + @Test + public void testSerializeToXDM_and_isValidLanguageTag() throws DataReaderException { + final XDMLifecycleEnvironment environment = new XDMLifecycleEnvironment(); + environment.setLanguage(languageTag); + final Map result = environment.serializeToXdm(); + + if (expected != null) { + assertEquals(1, result.size()); + Map dublinCore = DataReader.getTypedMap(Object.class, result, "_dc"); + assertEquals(expected, DataReader.getString(dublinCore, "language")); + } else { + assertEquals(0, result.size()); + } + } +}