Skip to content

Add first-open time to Remote Config fetch request. #3653

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions firebase-config/firebase-config.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,6 @@ dependencies {
androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:2.25.0'
androidTestImplementation 'junit:junit:4.12'
androidTestImplementation "org.skyscreamer:jsonassert:1.5.0"

api 'joda-time:joda-time:2.10.14'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty heavy dependency, especially given that you don't seem to do any arithmetic on it but just rely on its toString() method, I suggest to use the long directly and use SimpleDateFormat to convert it to an iso string.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the dependency and replaced it with SimpleDataFormat. Thanks, I definitely struggled a bit with this and was looking for inputs!

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public final class RemoteConfigConstants {
RequestFieldKey.APP_VERSION,
RequestFieldKey.PACKAGE_NAME,
RequestFieldKey.SDK_VERSION,
RequestFieldKey.ANALYTICS_USER_PROPERTIES
RequestFieldKey.ANALYTICS_USER_PROPERTIES,
RequestFieldKey.FIRST_OPEN_TIME
})
@Retention(RetentionPolicy.SOURCE)
public @interface RequestFieldKey {
Expand All @@ -64,6 +65,7 @@ public final class RemoteConfigConstants {
String PACKAGE_NAME = "packageName";
String SDK_VERSION = "sdkVersion";
String ANALYTICS_USER_PROPERTIES = "analyticsUserProperties";
String FIRST_OPEN_TIME = "firstOpenTime";
}

/** Keys of fields in the Fetch response body from the Firebase Remote Config server. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Executor;
import org.joda.time.Instant;

/**
* A handler for fetch requests to the Firebase Remote Config backend.
Expand Down Expand Up @@ -79,6 +80,9 @@ public class ConfigFetchHandler {
*/
@VisibleForTesting static final int HTTP_TOO_MANY_REQUESTS = 429;

/** First Open time key in GA user-properties. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might add to the comment that this is a reserved property set by GA (I think that's the case?) per https://firebase.google.com/docs/reference/android/com/google/firebase/analytics/FirebaseAnalytics.Event

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is an internal property. Updated the comment to say that it's collected by GA as part of of the predefined "user-dimensions". I called it "user-dimensions" following the recent GA documentation (https://support.google.com/firebase/answer/9268042?hl=en&ref_topic=6317484) but on second thought, maybe something simpler is better: "internal user-property automatically collected by GA" ?

@VisibleForTesting static final String FIRST_OPEN_TIME_KEY = "_fot";

private final FirebaseInstallationsApi firebaseInstallations;
private final Provider<AnalyticsConnector> analyticsConnector;

Expand Down Expand Up @@ -110,7 +114,6 @@ public ConfigFetchHandler(
this.fetchedConfigsCache = fetchedConfigsCache;
this.frcBackendApiClient = frcBackendApiClient;
this.frcMetadata = frcMetadata;

this.customHttpHeaders = customHttpHeaders;
}

Expand Down Expand Up @@ -311,6 +314,7 @@ private FetchResponse fetchFromBackend(
getUserProperties(),
frcMetadata.getLastFetchETag(),
customHttpHeaders,
getFirstOpenTime(),
currentTime);

if (response.getLastFetchETag() != null) {
Expand Down Expand Up @@ -492,8 +496,8 @@ private void updateLastFetchStatusAndTime(
}

/**
* Returns the list of user properties in Analytics. If the Analytics SDK is not available,
* returns an empty list.
* Returns the list of custom user properties in Analytics. If the Analytics SDK is not available,
* this method returns an empty list.
*/
@WorkerThread
private Map<String, String> getUserProperties() {
Expand All @@ -510,6 +514,23 @@ private Map<String, String> getUserProperties() {
return userPropertiesMap;
}

/**
* Returns first-open time from Analytics. If the Analytics SDK is not available, or if Analytics
* does not have first-open time for the app, this method returns null.
*/
@WorkerThread
private Instant getFirstOpenTime() {
AnalyticsConnector connector = this.analyticsConnector.get();
if (connector == null) {
return null;
}
Map<String, Object> userPropertiesMap = connector.getUserProperties(/*includeInternal=*/ true);

return userPropertiesMap.containsKey(FIRST_OPEN_TIME_KEY)
? Instant.ofEpochMilli((long) userPropertiesMap.get(FIRST_OPEN_TIME_KEY))
: null;
}

/** Used to verify that the fetch handler is getting Analytics as expected. */
@VisibleForTesting
public Provider<AnalyticsConnector> getAnalyticsConnector() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_ID;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_VERSION;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.COUNTRY_CODE;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.FIRST_OPEN_TIME;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID_TOKEN;
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.LANGUAGE_CODE;
Expand Down Expand Up @@ -65,6 +66,7 @@
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.joda.time.Instant;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
Expand Down Expand Up @@ -163,6 +165,7 @@ HttpURLConnection createHttpURLConnection() throws FirebaseRemoteConfigException
* server uses this ETag to determine if there has been a change in the response body since
* the last fetch.
* @param customHeaders custom HTTP headers that will be sent to the FRC server.
* @param firstOpenTime first time the app was opened. This value comes from Google Analytics.
* @param currentTime the current time on the device that is performing the fetch.
*/
// TODO(issues/263): Set custom headers in ConfigFetchHttpClient's constructor.
Expand All @@ -174,6 +177,7 @@ FetchResponse fetch(
Map<String, String> analyticsUserProperties,
String lastFetchETag,
Map<String, String> customHeaders,
Instant firstOpenTime,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking aloud: it doesn't make sense to merge this in with the other analyticsUserProperties since we want to send it as its own request param.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and the reason we want to send it in its own request param is because analyticsUserProperties in fetch traditionally contains custom user-defined properties only. And we want to keep, and highlight, that distinction. It also lets us explicitly call out the information that we are sending up to the server.

Date currentTime)
throws FirebaseRemoteConfigException {
setUpUrlConnection(urlConnection, lastFetchETag, installationAuthToken, customHeaders);
Expand All @@ -182,7 +186,8 @@ FetchResponse fetch(
JSONObject fetchResponse;
try {
byte[] requestBody =
createFetchRequestBody(installationId, installationAuthToken, analyticsUserProperties)
createFetchRequestBody(
installationId, installationAuthToken, analyticsUserProperties, firstOpenTime)
.toString()
.getBytes("utf-8");
setFetchRequestBody(urlConnection, requestBody);
Expand Down Expand Up @@ -292,7 +297,8 @@ private String getFingerprintHashForPackage() {
private JSONObject createFetchRequestBody(
String installationId,
String installationAuthToken,
Map<String, String> analyticsUserProperties)
Map<String, String> analyticsUserProperties,
Instant firstOpenTime)
throws FirebaseRemoteConfigClientException {
Map<String, Object> requestBodyMap = new HashMap<>();

Expand All @@ -315,7 +321,7 @@ private JSONObject createFetchRequestBody(
: locale.toString();
requestBodyMap.put(LANGUAGE_CODE, languageCode);

requestBodyMap.put(PLATFORM_VERSION, Integer.toString(android.os.Build.VERSION.SDK_INT));
requestBodyMap.put(PLATFORM_VERSION, Integer.toString(Build.VERSION.SDK_INT));

requestBodyMap.put(TIME_ZONE, TimeZone.getDefault().getID());

Expand All @@ -336,6 +342,10 @@ private JSONObject createFetchRequestBody(

requestBodyMap.put(ANALYTICS_USER_PROPERTIES, new JSONObject(analyticsUserProperties));

if (firstOpenTime != null) {
requestBodyMap.put(FIRST_OPEN_TIME, firstOpenTime.toString());
}

return new JSONObject(requestBodyMap);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.STATE;
import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.BACKOFF_TIME_DURATIONS_IN_MINUTES;
import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.FIRST_OPEN_TIME_KEY;
import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.HTTP_TOO_MANY_REQUESTS;
import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.LAST_FETCH_TIME_NO_FETCH_YET;
import static com.google.firebase.remoteconfig.internal.ConfigMetadataClient.NO_BACKOFF_TIME;
Expand All @@ -42,6 +43,7 @@
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
Expand Down Expand Up @@ -77,6 +79,7 @@
import java.util.Map;
import java.util.Random;
import java.util.concurrent.Executor;
import org.joda.time.Instant;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
Expand Down Expand Up @@ -195,6 +198,7 @@ public void fetch_firstFetch_includesInstallationAuthToken() throws Exception {
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
}

Expand Down Expand Up @@ -394,7 +398,7 @@ public void fetch_gettingFetchCacheFails_doesNotThrowException() throws Exceptio

@Test
public void fetch_fetchBackendCallFails_taskThrowsException() throws Exception {
when(mockBackendFetchApiClient.fetch(any(), any(), any(), any(), any(), any(), any()))
when(mockBackendFetchApiClient.fetch(any(), any(), any(), any(), any(), any(), any(), any()))
.thenThrow(
new FirebaseRemoteConfigClientException("Fetch failed due to an unexpected error."));

Expand Down Expand Up @@ -628,21 +632,46 @@ public void fetch_serverReturnsUnexpectedCode_throwsServerException() throws Exc
}

@Test
public void fetch_hasAnalyticsSdk_sendsUserProperties() throws Exception {
public void fetch_hasAnalyticsSdk_sendsUserPropertiesAndFirstOpenTime() throws Exception {
// Provide the mock Analytics SDK.
AnalyticsConnector mockAnalyticsConnector = mock(AnalyticsConnector.class);
fetchHandler = getNewFetchHandler(mockAnalyticsConnector);
long testFirstOpenTimeVal = 1636146000000L;

Map<String, String> userProperties =
Map<String, String> customUserProperties =
ImmutableMap.of("up_key1", "up_val1", "up_key2", "up_val2");
Map<String, Object> allUserProperties =
ImmutableMap.of(
"up_key1", "up_val1", "up_key2", "up_val2", FIRST_OPEN_TIME_KEY, testFirstOpenTimeVal);
when(mockAnalyticsConnector.getUserProperties(/*includeInternal=*/ false))
.thenReturn(ImmutableMap.copyOf(customUserProperties));
when(mockAnalyticsConnector.getUserProperties(/*includeInternal=*/ true))
.thenReturn(ImmutableMap.copyOf(allUserProperties));

fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);

assertWithMessage("Fetch() failed!").that(fetchHandler.fetch().isSuccessful()).isTrue();

Instant expectedFirstOpenTime = Instant.ofEpochMilli(testFirstOpenTimeVal);
verifyBackendIsCalled(customUserProperties, expectedFirstOpenTime);
}

@Test
public void fetch_hasAnalyticsSdk_sendsUserPropertiesNoFirstOpenTime() throws Exception {
// Provide the mock Analytics SDK.
AnalyticsConnector mockAnalyticsConnector = mock(AnalyticsConnector.class);
fetchHandler = getNewFetchHandler(mockAnalyticsConnector);

Map<String, String> userProperties =
ImmutableMap.of("up_key1", "up_val1", "up_key2", "up_val2");
when(mockAnalyticsConnector.getUserProperties(anyBoolean()))
.thenReturn(ImmutableMap.copyOf(userProperties));

fetchCallToHttpClientUpdatesClockAndReturnsConfig(firstFetchedContainer);

assertWithMessage("Fetch() failed!").that(fetchHandler.fetch().isSuccessful()).isTrue();

verifyBackendIsCalled(userProperties);
verifyBackendIsCalled(userProperties, null);
}

@Test
Expand Down Expand Up @@ -751,6 +780,7 @@ private void setBackendResponseConfigsTo(ConfigContainer container) throws Excep
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
}

Expand All @@ -762,6 +792,7 @@ private void setBackendResponseToNoChange(Date date) throws Exception {
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any()))
.thenReturn(FetchResponse.forBackendHasNoUpdates(date));
}
Expand All @@ -776,6 +807,7 @@ private void fetchCallToBackendThrowsException(int httpErrorCode) throws Excepti
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
}

Expand Down Expand Up @@ -855,10 +887,12 @@ private void verifyBackendIsCalled() throws Exception {
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
}

private void verifyBackendIsCalled(Map<String, String> userProperties) throws Exception {
private void verifyBackendIsCalled(Map<String, String> userProperties, Instant firstOpenTime)
throws Exception {
verify(mockBackendFetchApiClient)
.fetch(
any(HttpURLConnection.class),
Expand All @@ -867,6 +901,7 @@ private void verifyBackendIsCalled(Map<String, String> userProperties) throws Ex
/* analyticsUserProperties= */ eq(userProperties),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ eq(firstOpenTime),
/* currentTime= */ any());
}

Expand All @@ -879,6 +914,7 @@ private void verifyBackendIsNeverCalled() throws Exception {
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ any(),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
}

Expand All @@ -891,6 +927,7 @@ private void verifyETags(@Nullable String requestETag, String responseETag) thro
/* analyticsUserProperties= */ any(),
/* lastFetchETag= */ eq(requestETag),
/* customHeaders= */ any(),
/* firstOpenTime= */ any(),
/* currentTime= */ any());
assertThat(metadataClient.getLastFetchETag()).isEqualTo(responseETag);
}
Expand Down
Loading