diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java index 29e05574b70..06312ec2919 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java @@ -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 { @@ -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. */ diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java index 26a768d3ab4..42fe0919b66 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandler.java @@ -79,6 +79,12 @@ public class ConfigFetchHandler { */ @VisibleForTesting static final int HTTP_TOO_MANY_REQUESTS = 429; + /** + * First-open time key name in GA user-properties. First-open time is a predefined user-dimension + * automatically collected by GA. + */ + @VisibleForTesting static final String FIRST_OPEN_TIME_KEY = "_fot"; + private final FirebaseInstallationsApi firebaseInstallations; private final Provider analyticsConnector; @@ -110,7 +116,6 @@ public ConfigFetchHandler( this.fetchedConfigsCache = fetchedConfigsCache; this.frcBackendApiClient = frcBackendApiClient; this.frcMetadata = frcMetadata; - this.customHttpHeaders = customHttpHeaders; } @@ -311,6 +316,7 @@ private FetchResponse fetchFromBackend( getUserProperties(), frcMetadata.getLastFetchETag(), customHttpHeaders, + getFirstOpenTime(), currentTime); if (response.getLastFetchETag() != null) { @@ -492,8 +498,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 getUserProperties() { @@ -510,6 +516,20 @@ private Map 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 Long getFirstOpenTime() { + AnalyticsConnector connector = this.analyticsConnector.get(); + if (connector == null) { + return null; + } + + return (Long) connector.getUserProperties(/*includeInternal=*/ true).get(FIRST_OPEN_TIME_KEY); + } + /** Used to verify that the fetch handler is getting Analytics as expected. */ @VisibleForTesting public Provider getAnalyticsConnector() { diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java index 196ea131283..f2c4c925929 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java @@ -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; @@ -58,6 +59,7 @@ import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Locale; @@ -92,6 +94,9 @@ public class ConfigFetchHttpClient { private final long connectTimeoutInSeconds; private final long readTimeoutInSeconds; + /** ISO-8601 UTC timestamp format. */ + private static final String ISO_DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + /** Creates a client for {@link #fetch}ing data from the Firebase Remote Config server. */ public ConfigFetchHttpClient( Context context, @@ -163,6 +168,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. @@ -174,6 +180,7 @@ FetchResponse fetch( Map analyticsUserProperties, String lastFetchETag, Map customHeaders, + Long firstOpenTime, Date currentTime) throws FirebaseRemoteConfigException { setUpUrlConnection(urlConnection, lastFetchETag, installationAuthToken, customHeaders); @@ -182,7 +189,8 @@ FetchResponse fetch( JSONObject fetchResponse; try { byte[] requestBody = - createFetchRequestBody(installationId, installationAuthToken, analyticsUserProperties) + createFetchRequestBody( + installationId, installationAuthToken, analyticsUserProperties, firstOpenTime) .toString() .getBytes("utf-8"); setFetchRequestBody(urlConnection, requestBody); @@ -292,7 +300,8 @@ private String getFingerprintHashForPackage() { private JSONObject createFetchRequestBody( String installationId, String installationAuthToken, - Map analyticsUserProperties) + Map analyticsUserProperties, + Long firstOpenTime) throws FirebaseRemoteConfigClientException { Map requestBodyMap = new HashMap<>(); @@ -315,7 +324,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()); @@ -336,9 +345,19 @@ private JSONObject createFetchRequestBody( requestBodyMap.put(ANALYTICS_USER_PROPERTIES, new JSONObject(analyticsUserProperties)); + if (firstOpenTime != null) { + requestBodyMap.put(FIRST_OPEN_TIME, convertToISOString(firstOpenTime)); + } + return new JSONObject(requestBodyMap); } + private String convertToISOString(long millisFromEpoch) { + SimpleDateFormat isoDateFormat = new SimpleDateFormat(ISO_DATE_PATTERN); + isoDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + return isoDateFormat.format(millisFromEpoch); + } + private void setFetchRequestBody(HttpURLConnection urlConnection, byte[] requestBody) throws IOException { urlConnection.setFixedLengthStreamingMode(requestBody.length); diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java index 5edc639a9fd..320c616deb9 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHandlerTest.java @@ -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; @@ -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; @@ -195,6 +197,7 @@ public void fetch_firstFetch_includesInstallationAuthToken() throws Exception { /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any()); } @@ -394,7 +397,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.")); @@ -628,21 +631,45 @@ 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 firstOpenTime = 1636146000000L; - Map userProperties = + Map customUserProperties = ImmutableMap.of("up_key1", "up_val1", "up_key2", "up_val2"); + Map allUserProperties = + ImmutableMap.of( + "up_key1", "up_val1", "up_key2", "up_val2", FIRST_OPEN_TIME_KEY, firstOpenTime); 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(); + + verifyBackendIsCalled(customUserProperties, firstOpenTime); + } + + @Test + public void fetch_hasAnalyticsSdk_sendsUserPropertiesNoFirstOpenTime() throws Exception { + // Provide the mock Analytics SDK. + AnalyticsConnector mockAnalyticsConnector = mock(AnalyticsConnector.class); + fetchHandler = getNewFetchHandler(mockAnalyticsConnector); + + Map 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 @@ -751,6 +778,7 @@ private void setBackendResponseConfigsTo(ConfigContainer container) throws Excep /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any()); } @@ -762,6 +790,7 @@ private void setBackendResponseToNoChange(Date date) throws Exception { /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any())) .thenReturn(FetchResponse.forBackendHasNoUpdates(date)); } @@ -776,6 +805,7 @@ private void fetchCallToBackendThrowsException(int httpErrorCode) throws Excepti /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any()); } @@ -855,10 +885,12 @@ private void verifyBackendIsCalled() throws Exception { /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any()); } - private void verifyBackendIsCalled(Map userProperties) throws Exception { + private void verifyBackendIsCalled(Map userProperties, Long firstOpenTime) + throws Exception { verify(mockBackendFetchApiClient) .fetch( any(HttpURLConnection.class), @@ -867,6 +899,7 @@ private void verifyBackendIsCalled(Map userProperties) throws Ex /* analyticsUserProperties= */ eq(userProperties), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ eq(firstOpenTime), /* currentTime= */ any()); } @@ -879,6 +912,7 @@ private void verifyBackendIsNeverCalled() throws Exception { /* analyticsUserProperties= */ any(), /* lastFetchETag= */ any(), /* customHeaders= */ any(), + /* firstOpenTime= */ any(), /* currentTime= */ any()); } @@ -891,6 +925,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); } diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClientTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClientTest.java index 6cf1f525810..96b1f0c9896 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClientTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClientTest.java @@ -24,6 +24,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; @@ -34,6 +35,7 @@ import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.ENTRIES; import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.EXPERIMENT_DESCRIPTIONS; import static com.google.firebase.remoteconfig.RemoteConfigConstants.ResponseFieldKey.STATE; +import static com.google.firebase.remoteconfig.testutil.Assert.assertFalse; import static com.google.firebase.remoteconfig.testutil.Assert.assertThrows; import static org.mockito.MockitoAnnotations.initMocks; @@ -187,7 +189,7 @@ public void fetch_setsAllHeaders_sendsAllHeadersToServer() throws Exception { // Custom user-defined headers. expectedHeaders.putAll(customHeaders); - fetch(FIRST_ETAG, /* userProperties= */ ImmutableMap.of(), customHeaders); + fetch(FIRST_ETAG, customHeaders); assertThat(fakeHttpURLConnection.getRequestHeaders()).isEqualTo(expectedHeaders); } @@ -195,9 +197,13 @@ public void fetch_setsAllHeaders_sendsAllHeadersToServer() throws Exception { @Test public void fetch_setsAllElementsOfRequestBody_sendsRequestBodyToServer() throws Exception { setServerResponseTo(noChangeResponseBody, SECOND_ETAG); - Map userProperties = ImmutableMap.of("up1", "hello", "up2", "world"); + Map customUserProperties = ImmutableMap.of("up1", "hello", "up2", "world"); - fetch(FIRST_ETAG, userProperties); + long firstOpenTimeEpochFromMillis = 1636146000000L; + // ISO-8601 value corresponding to 1636146000000 ms-from-epoch in UTC + String firstOpenTimeIsoString = "2021-11-05T21:00:00.000Z"; + + fetch(FIRST_ETAG, customUserProperties, firstOpenTimeEpochFromMillis); JSONObject requestBody = new JSONObject(fakeHttpURLConnection.getOutputStream().toString()); assertThat(requestBody.get(INSTANCE_ID)).isEqualTo(INSTALLATION_ID_STRING); @@ -215,8 +221,23 @@ public void fetch_setsAllElementsOfRequestBody_sendsRequestBodyToServer() throws .isEqualTo(Long.toString(packageInfo.getLongVersionCode())); assertThat(requestBody.get(PACKAGE_NAME)).isEqualTo(context.getPackageName()); assertThat(requestBody.get(SDK_VERSION)).isEqualTo(BuildConfig.VERSION_NAME); + assertThat(requestBody.get(FIRST_OPEN_TIME)).isEqualTo(firstOpenTimeIsoString); + assertThat(requestBody.getJSONObject(ANALYTICS_USER_PROPERTIES).toString()) + .isEqualTo(new JSONObject(customUserProperties).toString()); + } + + @Test + public void fetch_nullFirstOpenTime_fieldNotPresentInRequestBody() throws Exception { + setServerResponseTo(noChangeResponseBody, SECOND_ETAG); + Map customUserProperties = ImmutableMap.of("up1", "hello", "up2", "world"); + + fetch(FIRST_ETAG, customUserProperties, null); + + JSONObject requestBody = new JSONObject(fakeHttpURLConnection.getOutputStream().toString()); + assertThat(requestBody.getJSONObject(ANALYTICS_USER_PROPERTIES).toString()) - .isEqualTo(new JSONObject(userProperties).toString()); + .isEqualTo(new JSONObject(customUserProperties).toString()); + assertFalse(requestBody.has(FIRST_OPEN_TIME)); } @Test @@ -226,8 +247,7 @@ public void fetch_requestEncodesLanguageSubtags() throws Exception { setServerResponseTo(noChangeResponseBody, SECOND_ETAG); - Map userProperties = ImmutableMap.of("up1", "hello", "up2", "world"); - fetch(FIRST_ETAG, userProperties); + fetch(FIRST_ETAG); JSONObject requestBody = new JSONObject(fakeHttpURLConnection.getOutputStream().toString()); assertThat(requestBody.get(LANGUAGE_CODE)).isEqualTo(languageTag); @@ -242,8 +262,7 @@ public void fetch_localeUsesToStringBelowLollipop() throws Exception { setServerResponseTo(noChangeResponseBody, SECOND_ETAG); - Map userProperties = ImmutableMap.of("up1", "hello", "up2", "world"); - fetch(FIRST_ETAG, userProperties); + fetch(FIRST_ETAG); JSONObject requestBody = new JSONObject(fakeHttpURLConnection.getOutputStream().toString()); assertThat(requestBody.get(LANGUAGE_CODE)).isEqualTo(languageString); @@ -306,10 +325,12 @@ private FetchResponse fetch(String eTag) throws Exception { /* analyticsUserProperties= */ ImmutableMap.of(), eTag, /* customHeaders= */ ImmutableMap.of(), + /* firstOpenTime= */ null, /* currentTime= */ new Date(mockClock.currentTimeMillis())); } - private FetchResponse fetch(String eTag, Map userProperties) throws Exception { + private FetchResponse fetch(String eTag, Map userProperties, Long firstOpenTime) + throws Exception { return configFetchHttpClient.fetch( fakeHttpURLConnection, INSTALLATION_ID_STRING, @@ -317,19 +338,19 @@ private FetchResponse fetch(String eTag, Map userProperties) thr userProperties, eTag, /* customHeaders= */ ImmutableMap.of(), + firstOpenTime, new Date(mockClock.currentTimeMillis())); } - private FetchResponse fetch( - String eTag, Map userProperties, Map customHeaders) - throws Exception { + private FetchResponse fetch(String eTag, Map customHeaders) throws Exception { return configFetchHttpClient.fetch( fakeHttpURLConnection, INSTALLATION_ID_STRING, INSTALLATION_AUTH_TOKEN_STRING, - userProperties, + /* analyticsUserProperties= */ ImmutableMap.of(), eTag, customHeaders, + /* firstOpenTime= */ null, new Date(mockClock.currentTimeMillis())); } @@ -341,6 +362,7 @@ private FetchResponse fetchWithoutInstallationId() throws Exception { /* analyticsUserProperties= */ ImmutableMap.of(), /* lastFetchETag= */ "bogus-etag", /* customHeaders= */ ImmutableMap.of(), + /* firstOpenTime= */ null, new Date(mockClock.currentTimeMillis())); } @@ -352,6 +374,7 @@ private FetchResponse fetchWithoutInstallationAuthToken() throws Exception { /* analyticsUserProperties= */ ImmutableMap.of(), /* lastFetchETag= */ "bogus-etag", /* customHeaders= */ ImmutableMap.of(), + /* firstOpenTime= */ null, new Date(mockClock.currentTimeMillis())); }