Skip to content

Commit bf43533

Browse files
authored
Release 17.8.0 (#1390)
1 parent 50d2a13 commit bf43533

File tree

13 files changed

+195
-14
lines changed

13 files changed

+195
-14
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
[Migration Guides](https://github.com/urbanairship/android-library/tree/main/documentation/migration)
44

5+
## Version 17.8.0, April 11, 2024
6+
Minor release that fixes potential crashes when evaluating experiments before a Channel ID has been created. Apps that make use of experiments or holdout groups should update to this version or later.
7+
8+
### Changes
9+
- Avoid NPE in `ExperimentManager` when evaluating experiments before a Channel ID has been created.
10+
511
## Version 17.7.4, April 5, 2024
612
Patch release that fixes a potential crash on Android 13 (API 33) channel ID creation delay after enabling a feature when none was enabled. The SDK will new create the channel ID without having to relaunch the app. Apps that have no features enabled at launch should update to this version or later.
713

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Airship SDK for Android.
1818

1919
```
2020
dependencies {
21-
def airshipVersion = "17.7.4"
21+
def airshipVersion = "17.8.0"
2222
2323
// FCM push provider
2424
implementation "com.urbanairship.android:urbanairship-fcm:$airshipVersion"

build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
buildscript {
22
ext {
33
// Airship Version - major.minor.patch
4-
airshipVersion = '17.7.4'
4+
airshipVersion = '17.8.0'
55

66
// Airship Version Qualifier beta, release, etc...
77
// airshipVersionQualifier = "beta"

urbanairship-automation/src/main/java/com/urbanairship/automation/InAppAutomation.java

+7
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import java.util.HashMap;
5252
import java.util.List;
5353
import java.util.Map;
54+
import java.util.Objects;
5455
import java.util.UUID;
5556
import java.util.concurrent.ExecutionException;
5657
import java.util.concurrent.Executor;
@@ -64,6 +65,8 @@
6465
import androidx.annotation.VisibleForTesting;
6566
import androidx.annotation.WorkerThread;
6667

68+
import static com.urbanairship.util.Checks.checkNotNull;
69+
6770
/**
6871
* In-app automation.
6972
*/
@@ -648,6 +651,10 @@ private void onPrepareSchedule(final @NonNull Schedule<? extends ScheduleData> s
648651
PendingResult<ExperimentResult> experimentResults = new PendingResult<>();
649652
RetryingExecutor.Operation evaluateExperiments = () -> {
650653
try {
654+
// Ensure we have a channel ID available. Otherwise, throw so that we'll retry.
655+
Objects.requireNonNull(infoProvider.getChannelId(),
656+
"Channel ID must be available to evaluate experiments.");
657+
651658
ExperimentResult result = evaluateExperiments(schedule);
652659
experimentResults.setResult(result);
653660
return RetryingExecutor.finishedResult();

urbanairship-automation/src/test/java/com/urbanairship/automation/InAppAutomationTest.java

+37
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.urbanairship.PrivacyManager;
1111
import com.urbanairship.ShadowAirshipExecutorsLegacy;
1212
import com.urbanairship.TestApplication;
13+
import com.urbanairship.TestDeviceInfoProvider;
1314
import com.urbanairship.TestRequestSession;
1415
import com.urbanairship.UAirship;
1516
import com.urbanairship.analytics.CustomEvent;
@@ -79,6 +80,7 @@
7980
import static org.junit.Assert.assertFalse;
8081
import static org.junit.Assert.assertTrue;
8182
import static org.mockito.ArgumentMatchers.any;
83+
import static org.mockito.ArgumentMatchers.anyInt;
8284
import static org.mockito.ArgumentMatchers.eq;
8385
import static org.mockito.ArgumentMatchers.nullable;
8486
import static org.mockito.Mockito.atLeastOnce;
@@ -139,6 +141,9 @@ public class InAppAutomationTest {
139141
@Before
140142
public void setup() {
141143
when(mockRuntimeConfig.getConfigOptions()).thenAnswer((Answer<AirshipConfigOptions>) invocation -> config);
144+
145+
when(mockInfoProvider.getChannelId()).thenReturn("channel-id");
146+
142147
mockChannel = mock(AirshipChannel.class);
143148
mockIamManager = mock(InAppMessageManager.class);
144149
mockObserver = mock(InAppRemoteDataObserver.class);
@@ -324,6 +329,38 @@ public void testPrepareSchedule() {
324329
verify(callback).onFinish(AutomationDriver.PREPARE_RESULT_CONTINUE);
325330
}
326331

332+
333+
@Test
334+
public void testPrepareScheduleNoChannelId() {
335+
when(mockInfoProvider.getChannelId()).thenReturn(null);
336+
337+
InAppMessage message = InAppMessage.newBuilder()
338+
.setDisplayContent(new CustomDisplayContent(JsonValue.NULL))
339+
.build();
340+
341+
Schedule<InAppMessage> schedule = Schedule.newBuilder(message)
342+
.addTrigger(Triggers.newAppInitTriggerBuilder().setGoal(1).build())
343+
.setBypassHoldoutGroups(true)
344+
.build();
345+
346+
when(mockObserver.requiresRefresh(eq(schedule))).thenReturn(false);
347+
when(mockObserver.bestEffortRefresh(eq(schedule))).thenReturn(true);
348+
349+
AutomationDriver.PrepareScheduleCallback callback = mock(AutomationDriver.PrepareScheduleCallback.class);
350+
driver.onPrepareSchedule(schedule, null, callback);
351+
verify(mockMessageScheduleDelegate, never()).onPrepareSchedule(eq(schedule), eq(schedule.getData()), any(), any());
352+
verify(callback, never()).onFinish(anyInt());
353+
354+
// Sanity check: we should be able to prepare a schedule once we do have a channel ID.
355+
when(mockInfoProvider.getChannelId()).thenReturn("channel-id");
356+
357+
driver.onPrepareSchedule(schedule, null, callback);
358+
ArgumentCaptor<AutomationDriver.PrepareScheduleCallback> argumentCaptor = ArgumentCaptor.forClass(AutomationDriver.PrepareScheduleCallback.class);
359+
verify(mockMessageScheduleDelegate).onPrepareSchedule(eq(schedule), eq(schedule.getData()), any(), argumentCaptor.capture());
360+
argumentCaptor.getValue().onFinish(AutomationDriver.PREPARE_RESULT_CONTINUE);
361+
verify(callback).onFinish(AutomationDriver.PREPARE_RESULT_CONTINUE);
362+
}
363+
327364
@Test
328365
public void testPrepareScheduleRequiresRefresh() {
329366
InAppMessage message = InAppMessage.newBuilder()

urbanairship-core/src/main/java/com/urbanairship/experiment/Experiment.kt

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import com.urbanairship.json.optionalField
1212
import com.urbanairship.json.optionalFieldConverted
1313
import com.urbanairship.json.requireField
1414
import com.urbanairship.util.DateUtils
15+
import java.text.ParseException
1516

1617
internal enum class ExperimentType(val jsonValue: String) {
1718
HOLDOUT_GROUP("holdout");
@@ -179,6 +180,9 @@ internal data class Experiment(
179180
} catch (ex: JsonException) {
180181
UALog.e { "failed to parse Experiment from json $json" }
181182
return null
183+
} catch (ex: ParseException) {
184+
UALog.e { "failed to parse Experiment from json $json" }
185+
return null
182186
}
183187
}
184188
}

urbanairship-core/src/main/java/com/urbanairship/experiment/ExperimentManager.kt

+8-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import com.urbanairship.json.JsonException
1616
import com.urbanairship.json.JsonMap
1717
import com.urbanairship.remotedata.RemoteData
1818
import com.urbanairship.util.Clock
19-
import kotlin.jvm.Throws
2019
import kotlinx.coroutines.CoroutineScope
2120
import kotlinx.coroutines.SupervisorJob
2221
import kotlinx.coroutines.launch
@@ -47,6 +46,7 @@ public class ExperimentManager internal constructor(
4746
/**
4847
* Returns an optional Experiment with the given [id].
4948
*
49+
* @param messageInfo The message info.
5050
* @param id The ID of the Experiment.
5151
*/
5252
internal suspend fun getExperimentWithId(messageInfo: MessageInfo, id: String): Experiment? {
@@ -56,11 +56,12 @@ public class ExperimentManager internal constructor(
5656

5757
/**
5858
* Checks if the channel and/or contact is part of a global holdout or not.
59+
* @hide
60+
*
61+
* @param messageInfo The message info.
5962
* @param contactId The contact ID. If not provided, the stable contact ID will be used.
6063
* @return The experiments result. If no experiment matches, null is returned.
6164
*/
62-
/** @hide */
63-
@Throws(NullPointerException::class)
6465
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
6566
public suspend fun evaluateExperiments(messageInfo: MessageInfo, contactId: String? = null): ExperimentResult? {
6667

@@ -70,7 +71,10 @@ public class ExperimentManager internal constructor(
7071
}
7172

7273
val channelId = infoProvider.channelId
73-
?: throw NullPointerException("Channel ID missing, unable to evaluate hold out groups.")
74+
if (channelId == null) {
75+
UALog.d("Channel ID not available, unable to evaluate hold out groups.")
76+
return null
77+
}
7478

7579
val evaluationContactId = contactId ?: infoProvider.getStableContactId()
7680

urbanairship-core/src/test/java/com/urbanairship/experiment/ExperimentManagerTest.kt

+25
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@file:OptIn(ExperimentalCoroutinesApi::class)
2+
13
package com.urbanairship.experiment
24

35
import android.content.Context
@@ -20,6 +22,7 @@ import io.mockk.every
2022
import io.mockk.mockk
2123
import java.util.Date
2224
import junit.framework.Assert.assertTrue
25+
import kotlinx.coroutines.ExperimentalCoroutinesApi
2326
import kotlinx.coroutines.test.TestResult
2427
import kotlinx.coroutines.test.runTest
2528
import org.junit.Assert.assertEquals
@@ -184,6 +187,28 @@ public class ExperimentManagerTest {
184187
assert(result.allEvaluatedExperimentsMetadata.contains(extractReportingMetadata(experimentJson)))
185188
}
186189

190+
@Test
191+
public fun testHoldoutGroupEvaluationNoChannelId(): TestResult = runTest {
192+
channelId = null
193+
contactId = "some-contact-id"
194+
195+
val experimentJson = generateExperimentsPayload(
196+
id = "fake-id",
197+
hashIdentifier = "channel")
198+
.build()
199+
200+
val data = RemoteDataPayload(
201+
type = PAYLOAD_TYPE,
202+
timestamp = 1L,
203+
data = jsonMapOf(PAYLOAD_TYPE to jsonListOf(experimentJson))
204+
)
205+
206+
coEvery { remoteData.payloads(PAYLOAD_TYPE) } returns listOf(data)
207+
208+
val result = subject.evaluateExperiments(messageInfo)
209+
assertNull(result)
210+
}
211+
187212
@Test
188213
public fun testHoldoutGroupEvaluationRespectHashBuckets(): TestResult = runTest {
189214
channelId = "channel-id"

urbanairship-feature-flag/src/main/java/com/urbanairship/featureflag/FeatureFlag.kt

+4-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package com.urbanairship.featureflag
44

5+
import com.urbanairship.json.JsonException
56
import com.urbanairship.json.JsonMap
67
import com.urbanairship.json.JsonSerializable
78
import com.urbanairship.json.JsonValue
@@ -123,15 +124,15 @@ class FeatureFlag private constructor(
123124
* Parses a `JsonValue` as a `FeatureFlag`.
124125
* @throws `JsonException`
125126
*/
126-
@Throws
127127
@JvmStatic
128+
@Throws(JsonException::class)
128129
public fun fromJson(json: JsonValue): FeatureFlag {
129130
return FeatureFlag(
130131
name = json.requireMap().requireField(KEY_NAME),
131132
isEligible = json.requireMap().requireField(KEY_IS_ELIGIBLE),
132133
exists = json.requireMap().requireField(KEY_EXISTS),
133134
reportingInfo = json.requireMap().get(KEY_REPORTING_INFO)?.let {
134-
ReportingInfo.fromJson(it)
135+
ReportingInfo.fromJson(it)
135136
},
136137
variables = json.requireMap().optionalField(KEY_VARIABLES),
137138
)
@@ -149,7 +150,7 @@ class FeatureFlag private constructor(
149150
private const val KEY_CHANNEL_ID = "channel_id"
150151
private const val KEY_CONTACT_ID = "contact_id"
151152

152-
@Throws
153+
@Throws(JsonException::class)
153154
fun fromJson(json: JsonValue): ReportingInfo {
154155
return ReportingInfo(
155156
reportingMetadata = json.requireMap().requireField(KEY_REPORTING_METADATA),

urbanairship-feature-flag/src/main/java/com/urbanairship/featureflag/FeatureFlagInfo.kt

+8-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.urbanairship.json.jsonMapOf
1818
import com.urbanairship.json.optionalField
1919
import com.urbanairship.json.requireField
2020
import com.urbanairship.util.DateUtils
21+
import java.text.ParseException
2122
import kotlin.jvm.Throws
2223

2324
internal enum class FeatureFlagVariablesType(val jsonValue: String) {
@@ -211,6 +212,9 @@ internal class FeatureFlagInfo(
211212
} catch (ex: JsonException) {
212213
UALog.e { "failed to parse FeatureFlagInfo from json $json" }
213214
return null
215+
} catch (ex: ParseException) {
216+
UALog.e { "failed to parse FeatureFlagInfo from json $json" }
217+
return null
214218
}
215219
}
216220

@@ -233,6 +237,7 @@ internal class DeferredPayload(
233237
companion object {
234238
private const val KEY_URL = "url"
235239

240+
@Throws(JsonException::class)
236241
fun fromJson(json: JsonMap): DeferredPayload {
237242

238243
return DeferredPayload(
@@ -256,11 +261,12 @@ internal class StaticPayload(
256261
) : FeatureFlagPayload {
257262

258263
companion object {
264+
265+
@Throws(JsonException::class)
259266
fun fromJson(json: JsonMap): StaticPayload {
260267
if (json.isEmpty) {
261268
val variables = FeatureFlagVariables(
262-
type = FeatureFlagVariablesType.FIXED,
263-
variants = listOf(VariablesVariant.empty)
269+
type = FeatureFlagVariablesType.FIXED, variants = listOf(VariablesVariant.empty)
264270
)
265271
return StaticPayload(variables)
266272
}

urbanairship-feature-flag/src/main/java/com/urbanairship/featureflag/FeatureFlagInteractionEvent.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
package com.urbanairship.featureflag
44

55
import com.urbanairship.analytics.Event
6+
import com.urbanairship.json.JsonException
67
import com.urbanairship.json.JsonMap
78
import com.urbanairship.json.jsonMapOf
89

910
internal class FeatureFlagInteractionEvent private constructor(
1011
val data: JsonMap
1112
) : Event() {
1213

13-
@Throws
14+
@Throws(JsonException::class)
1415
internal constructor(flag: FeatureFlag) : this(
1516
jsonMapOf(
1617
"flag_name" to flag.name,

urbanairship-feature-flag/src/main/java/com/urbanairship/featureflag/FeatureFlagManager.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ public class FeatureFlagManager
7272
* @param name The flag name
7373
* @return an instance of `PendingResult<FeatureFlag>`.
7474
*/
75-
@Throws(FeatureFlagException::class)
7675
fun flagAsPendingResult(name: String): PendingResult<FeatureFlag> {
7776
val result = PendingResult<FeatureFlag>()
7877
pendingResultScope.launch {
@@ -92,7 +91,7 @@ public class FeatureFlagManager
9291

9392
private suspend fun flag(name: String, allowRefresh: Boolean): Result<FeatureFlag> {
9493
if (!isComponentEnabled) {
95-
throw FeatureFlagException.FailedToFetch()
94+
return Result.failure(FeatureFlagException.FailedToFetch())
9695
}
9796

9897
val remoteDataInfo = fetchFlagRemoteInfo(name)
@@ -123,6 +122,7 @@ public class FeatureFlagManager
123122
Result.failure(FeatureFlagException.FailedToFetch())
124123
}
125124
}
125+
126126
else -> Result.failure(FeatureFlagException.FailedToFetch())
127127
}
128128
}

0 commit comments

Comments
 (0)