From 17cc4919a727d1c8ee122a99c3e8e6f62364068f Mon Sep 17 00:00:00 2001 From: Tushar Khandelwal <64364243+tusharkhandelwal8@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:58:51 +0530 Subject: [PATCH] Add custom signals support in Remote Config. (#6410) feat(rc): Add custom signals support and methods to set custom signals for Remote Config Custom targeting --- firebase-config/api.txt | 13 +++ .../firebase/remoteconfig/CustomSignals.java | 62 +++++++++++++ .../remoteconfig/FirebaseRemoteConfig.java | 19 ++++ .../firebase/remoteconfig/RemoteConfig.kt | 3 + .../remoteconfig/RemoteConfigComponent.java | 3 +- .../remoteconfig/RemoteConfigConstants.java | 4 +- .../internal/ConfigFetchHttpClient.java | 13 ++- .../internal/ConfigSharedPrefsClient.java | 86 +++++++++++++++++++ .../remoteconfig/CustomSignalsTest.java | 82 ++++++++++++++++++ .../FirebaseRemoteConfigTest.java | 15 ++++ .../remoteconfig/RemoteConfigTests.kt | 55 ++++++++++++ .../internal/ConfigFetchHttpClientTest.java | 13 ++- .../internal/ConfigSharedPrefsClientTest.java | 40 +++++++++ 13 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 firebase-config/src/main/java/com/google/firebase/remoteconfig/CustomSignals.java create mode 100644 firebase-config/src/test/java/com/google/firebase/remoteconfig/CustomSignalsTest.java diff --git a/firebase-config/api.txt b/firebase-config/api.txt index 61528ec5d11..58e75c9e18c 100644 --- a/firebase-config/api.txt +++ b/firebase-config/api.txt @@ -16,6 +16,17 @@ package com.google.firebase.remoteconfig { method public void remove(); } + public class CustomSignals { + } + + public static class CustomSignals.Builder { + ctor public CustomSignals.Builder(); + method @NonNull public com.google.firebase.remoteconfig.CustomSignals build(); + method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, @Nullable String); + method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, long); + method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, double); + } + public class FirebaseRemoteConfig { method @NonNull public com.google.android.gms.tasks.Task activate(); method @NonNull public com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration addOnConfigUpdateListener(@NonNull com.google.firebase.remoteconfig.ConfigUpdateListener); @@ -35,6 +46,7 @@ package com.google.firebase.remoteconfig { method @NonNull public com.google.firebase.remoteconfig.FirebaseRemoteConfigValue getValue(@NonNull String); method @NonNull public com.google.android.gms.tasks.Task reset(); method @NonNull public com.google.android.gms.tasks.Task setConfigSettingsAsync(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings); + method @NonNull public com.google.android.gms.tasks.Task setCustomSignals(@NonNull com.google.firebase.remoteconfig.CustomSignals); method @NonNull public com.google.android.gms.tasks.Task setDefaultsAsync(@NonNull java.util.Map); method @NonNull public com.google.android.gms.tasks.Task setDefaultsAsync(@XmlRes int); field public static final boolean DEFAULT_VALUE_FOR_BOOLEAN = false; @@ -121,6 +133,7 @@ package com.google.firebase.remoteconfig { } public final class RemoteConfigKt { + method @NonNull public static com.google.firebase.remoteconfig.CustomSignals customSignals(@NonNull kotlin.jvm.functions.Function1 builder); method @NonNull public static operator com.google.firebase.remoteconfig.FirebaseRemoteConfigValue get(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfig, @NonNull String key); method @NonNull public static kotlinx.coroutines.flow.Flow getConfigUpdates(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfig); method @NonNull public static com.google.firebase.remoteconfig.FirebaseRemoteConfig getRemoteConfig(@NonNull com.google.firebase.Firebase); diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/CustomSignals.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/CustomSignals.java new file mode 100644 index 00000000000..3701138f9e4 --- /dev/null +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/CustomSignals.java @@ -0,0 +1,62 @@ +// Copyright 2024 Google LLC +// +// Licensed 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 CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.remoteconfig; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; + +/** + * Helper class which handles the storage and conversion to strings of key/value pairs with + * heterogeneous value types for custom signals. + */ +public class CustomSignals { + + final Map customSignals; + + public static class Builder { + // Holds the converted pairs of custom keys and values. + private Map customSignals = new HashMap(); + + // Methods to accept keys and values and convert values to strings. + @NonNull + public Builder put(@NonNull String key, @Nullable String value) { + customSignals.put(key, value); + return this; + } + + @NonNull + public Builder put(@NonNull String key, long value) { + customSignals.put(key, Long.toString(value)); + return this; + } + + @NonNull + public Builder put(@NonNull String key, double value) { + customSignals.put(key, Double.toString(value)); + return this; + } + + @NonNull + public CustomSignals build() { + return new CustomSignals(this); + } + } + + CustomSignals(@NonNull Builder builder) { + this.customSignals = builder.customSignals; + } +} diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java index 24f1f338266..50d6c36698a 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java @@ -652,6 +652,25 @@ private Task setDefaultsWithStringsMapAsync(Map defaultsSt FirebaseExecutors.directExecutor(), (unusedContainer) -> Tasks.forResult(null)); } + /** + * Asynchronously changes the custom signals for this {@link FirebaseRemoteConfig} instance. + * + *

The {@code customSignals} parameter should be an instance of {@link CustomSignals}, which + * enforces the allowed types for custom signal values (String, Long or Double). + * + * @param customSignals A dictionary of keys and the values of the custom signals to be set for + * the app instance + */ + @NonNull + public Task setCustomSignals(@NonNull CustomSignals customSignals) { + return Tasks.call( + executor, + () -> { + frcSharedPrefs.setCustomSignals(customSignals.customSignals); + return null; + }); + } + /** * Notifies the Firebase A/B Testing SDK about activated experiments. * diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfig.kt b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfig.kt index a944a7e8857..3a7ef220198 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfig.kt +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfig.kt @@ -48,6 +48,9 @@ fun remoteConfigSettings( return builder.build() } +fun customSignals(builder: CustomSignals.Builder.() -> Unit) = + CustomSignals.Builder().apply(builder).build() + /** * Starts listening for config updates from the Remote Config backend and emits [ConfigUpdate]s via * a [Flow]. See [FirebaseRemoteConfig.addOnConfigUpdateListener] for more information. diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java index 73fcec6e6c0..9474414b824 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java @@ -262,7 +262,8 @@ ConfigFetchHttpClient getFrcBackendApiClient( apiKey, namespace, /* connectTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(), - /* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds()); + /* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(), + /* customSignals= */ sharedPrefsClient.getCustomSignals()); } @VisibleForTesting 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 9c1a352dd0a..410306afd9c 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 @@ -51,7 +51,8 @@ public final class RemoteConfigConstants { RequestFieldKey.PACKAGE_NAME, RequestFieldKey.SDK_VERSION, RequestFieldKey.ANALYTICS_USER_PROPERTIES, - RequestFieldKey.FIRST_OPEN_TIME + RequestFieldKey.FIRST_OPEN_TIME, + RequestFieldKey.CUSTOM_SIGNALS }) @Retention(RetentionPolicy.SOURCE) public @interface RequestFieldKey { @@ -68,6 +69,7 @@ public final class RemoteConfigConstants { String SDK_VERSION = "sdkVersion"; String ANALYTICS_USER_PROPERTIES = "analyticsUserProperties"; String FIRST_OPEN_TIME = "firstOpenTime"; + String CUSTOM_SIGNALS = "customSignals"; } /** 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/ConfigFetchHttpClient.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java index b1867347580..60863a04326 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.CUSTOM_SIGNALS; 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; @@ -93,6 +94,7 @@ public class ConfigFetchHttpClient { private final String apiKey; private final String projectNumber; private final String namespace; + Map customSignalsMap; private final long connectTimeoutInSeconds; private final long readTimeoutInSeconds; @@ -106,7 +108,8 @@ public ConfigFetchHttpClient( String apiKey, String namespace, long connectTimeoutInSeconds, - long readTimeoutInSeconds) { + long readTimeoutInSeconds, + Map customSignalsMap) { this.context = context; this.appId = appId; this.apiKey = apiKey; @@ -114,6 +117,7 @@ public ConfigFetchHttpClient( this.namespace = namespace; this.connectTimeoutInSeconds = connectTimeoutInSeconds; this.readTimeoutInSeconds = readTimeoutInSeconds; + this.customSignalsMap = customSignalsMap; } /** Used to verify that the timeout is being set correctly. */ @@ -347,6 +351,13 @@ private JSONObject createFetchRequestBody( requestBodyMap.put(ANALYTICS_USER_PROPERTIES, new JSONObject(analyticsUserProperties)); + if (!customSignalsMap.isEmpty()) { + requestBodyMap.put(CUSTOM_SIGNALS, new JSONObject(customSignalsMap)); + + // Log the custom signals during fetch. + Log.d(TAG, "Fetching with custom signals: " + customSignalsMap); + } + if (firstOpenTime != null) { requestBodyMap.put(FIRST_OPEN_TIME, convertToISOString(firstOpenTime)); } diff --git a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClient.java b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClient.java index 51ae9c71035..c0d6fa0b69b 100644 --- a/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClient.java +++ b/firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClient.java @@ -18,11 +18,14 @@ import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET; import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS; import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED; +import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG; import static com.google.firebase.remoteconfig.RemoteConfigComponent.CONNECTION_TIMEOUT_IN_SECONDS; +import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.CUSTOM_SIGNALS; import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.content.SharedPreferences; +import android.util.Log; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -31,6 +34,11 @@ import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings; import java.lang.annotation.Retention; import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import org.json.JSONException; +import org.json.JSONObject; /** * Client for handling Firebase Remote Config (FRC) metadata and custom signals that are saved to @@ -75,17 +83,26 @@ public class ConfigSharedPrefsClient { private static final String REALTIME_BACKOFF_END_TIME_IN_MILLIS_KEY = "realtime_backoff_end_time_in_millis"; + /** Constants for custom signal limits.*/ + private static final int CUSTOM_SIGNALS_MAX_KEY_LENGTH = 250; + + private static final int CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH = 500; + + private static final int CUSTOM_SIGNALS_MAX_COUNT = 100; + private final SharedPreferences frcSharedPrefs; private final Object frcInfoLock; private final Object backoffMetadataLock; private final Object realtimeBackoffMetadataLock; + private final Object customSignalsLock; public ConfigSharedPrefsClient(SharedPreferences frcSharedPrefs) { this.frcSharedPrefs = frcSharedPrefs; this.frcInfoLock = new Object(); this.backoffMetadataLock = new Object(); this.realtimeBackoffMetadataLock = new Object(); + this.customSignalsLock = new Object(); } public long getFetchTimeoutInSeconds() { @@ -251,6 +268,75 @@ void setBackoffMetadata(int numFailedFetches, Date backoffEndTime) { } } + public void setCustomSignals(Map newCustomSignals) { + synchronized (customSignalsLock) { + // Retrieve existing custom signals + Map existingCustomSignals = getCustomSignals(); + + for (Map.Entry entry : newCustomSignals.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + // Validate key and value length + if (key.length() > CUSTOM_SIGNALS_MAX_KEY_LENGTH + || (value != null && value.length() > CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH)) { + Log.w( + TAG, + String.format( + "Invalid custom signal: Custom signal keys must be %d characters or less, and values must be %d characters or less.", + CUSTOM_SIGNALS_MAX_KEY_LENGTH, CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH)); + return; + } + + // Merge new signals with existing ones, overwriting existing keys. + // Also, remove entries where the new value is null. + if (value != null) { + existingCustomSignals.put(key, value); + } else { + existingCustomSignals.remove(key); + } + } + + // Check if the map has actually changed and the size limit + if (existingCustomSignals.equals(getCustomSignals())) { + return; + } + if (existingCustomSignals.size() > CUSTOM_SIGNALS_MAX_COUNT) { + Log.w( + TAG, + String.format( + "Invalid custom signal: Too many custom signals provided. The maximum allowed is %d.", + CUSTOM_SIGNALS_MAX_COUNT)); + return; + } + + frcSharedPrefs + .edit() + .putString(CUSTOM_SIGNALS, new JSONObject(existingCustomSignals).toString()) + .commit(); + + // Log the final updated custom signals. + Log.d(TAG, "Updated custom signals: " + getCustomSignals()); + } + } + + public Map getCustomSignals() { + String jsonString = frcSharedPrefs.getString(CUSTOM_SIGNALS, "{}"); + try { + JSONObject existingCustomSignalsJson = new JSONObject(jsonString); + Map custom_signals = new HashMap<>(); + Iterator keys = existingCustomSignalsJson.keys(); + while (keys.hasNext()) { + String key = keys.next(); + String value = existingCustomSignalsJson.optString(key); + custom_signals.put(key, value); + } + return custom_signals; + } catch (JSONException e) { + return new HashMap<>(); + } + } + void resetBackoff() { setBackoffMetadata(NO_FAILED_FETCHES, NO_BACKOFF_TIME); } diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/CustomSignalsTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/CustomSignalsTest.java new file mode 100644 index 00000000000..2596363c9a2 --- /dev/null +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/CustomSignalsTest.java @@ -0,0 +1,82 @@ +// Copyright 2024 Google LLC +// +// Licensed 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 CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.remoteconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit tests for the {@link CustomSignals}.*/ +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class CustomSignalsTest { + @Test + public void testCustomSignals_builderPutString() { + CustomSignals customSignals = + new CustomSignals.Builder().put("key1", "value1").put("key2", "value2").build(); + Map expectedSignals = ImmutableMap.of("key1", "value1", "key2", "value2"); + assertEquals(expectedSignals, customSignals.customSignals); + } + + @Test + public void testCustomSignals_builderPutLong() { + CustomSignals customSignals = + new CustomSignals.Builder().put("key1", 123L).put("key2", 456L).build(); + Map expectedSignals = ImmutableMap.of("key1", "123", "key2", "456"); + assertEquals(expectedSignals, customSignals.customSignals); + } + + @Test + public void testCustomSignals_builderPutDouble() { + CustomSignals customSignals = + new CustomSignals.Builder().put("key1", 12.34).put("key2", 56.78).build(); + Map expectedSignals = ImmutableMap.of("key1", "12.34", "key2", "56.78"); + assertEquals(expectedSignals, customSignals.customSignals); + } + + @Test + public void testCustomSignals_builderPutMixedTypes() { + CustomSignals customSignals = + new CustomSignals.Builder() + .put("key1", "value1") + .put("key2", 123L) + .put("key3", 45.67) + .build(); + Map expectedSignals = + ImmutableMap.of("key1", "value1", "key2", "123", "key3", "45.67"); + assertEquals(expectedSignals, customSignals.customSignals); + } + + @Test + public void testCustomSignals_builderPutNullValue() { + CustomSignals customSignals = new CustomSignals.Builder().put("key1", null).build(); + Map expectedSignals = new HashMap<>(); + expectedSignals.put("key1", null); + assertEquals(expectedSignals, customSignals.customSignals); + } + + @Test + public void testCustomSignals_builderEmpty() { + CustomSignals customSignals = new CustomSignals.Builder().build(); + assertTrue(customSignals.customSignals.isEmpty()); + } +} diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java index 5a40ccbe068..2b4f9b2f8ab 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/FirebaseRemoteConfigTest.java @@ -1660,6 +1660,21 @@ public void realtimeRequest_setRequestParams_succeedsWithCorrectParams() throws assertThat(fakeConnection.getRequestMethod()).isEqualTo("POST"); } + @Test + public void setCustomSignals_succeeds_and_calls_sharedPrefsClient() { + CustomSignals customSignals = + new CustomSignals.Builder() + .put("key1", "value1") + .put("key2", 123L) + .put("key3", 12.34) + .build(); + + Task setterTask = frc.setCustomSignals(customSignals); + + assertThat(setterTask.isSuccessful()).isTrue(); + verify(sharedPrefsClient).setCustomSignals(customSignals.customSignals); + } + private static void loadCacheWithConfig( ConfigCacheClient cacheClient, ConfigContainer container) { when(cacheClient.getBlocking()).thenReturn(container); diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigTests.kt b/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigTests.kt index 84b71905629..aa166a1ea81 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigTests.kt +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/RemoteConfigTests.kt @@ -17,6 +17,7 @@ package com.google.firebase.remoteconfig import androidx.test.core.app.ApplicationProvider +import com.google.common.collect.ImmutableMap import com.google.common.truth.Truth.assertThat import com.google.common.util.concurrent.MoreExecutors import com.google.firebase.Firebase @@ -147,6 +148,60 @@ class ConfigTests : BaseTestCase() { `when`(mockGetHandler.getValue("KEY")).thenReturn(StringRemoteConfigValue("non default value")) assertThat(remoteConfig["KEY"].asString()).isEqualTo("non default value") } + + @Test + fun `Custom Signals builder put string`() { + val customSignals = customSignals { + put("key1", "value1") + put("key2", "value2") + } + val expectedSignals = ImmutableMap.of("key1", "value1", "key2", "value2") + assertThat(customSignals.customSignals).isEqualTo(expectedSignals) + } + + @Test + fun `Custom Signals builder put long`() { + val customSignals = customSignals { + put("key1", 123L) + put("key2", 456L) + } + val expectedSignals = ImmutableMap.of("key1", "123", "key2", "456") + assertThat(customSignals.customSignals).isEqualTo(expectedSignals) + } + + @Test + fun `Custom Signals builder put double`() { + val customSignals = customSignals { + put("key1", 12.34) + put("key2", 56.78) + } + val expectedSignals = ImmutableMap.of("key1", "12.34", "key2", "56.78") + assertThat(customSignals.customSignals).isEqualTo(expectedSignals) + } + + @Test + fun `Custom Signals builder put mixed types`() { + val customSignals = customSignals { + put("key1", "value1") + put("key2", 123L) + put("key3", 45.67) + } + val expectedSignals = ImmutableMap.of("key1", "value1", "key2", "123", "key3", "45.67") + assertThat(customSignals.customSignals).isEqualTo(expectedSignals) + } + + @Test + fun `Custom Signals builder put null value`() { + val customSignals = customSignals { put("key1", null) } + val expectedSignals = mapOf("key1" to null) + assertThat(customSignals.customSignals).isEqualTo(expectedSignals) + } + + @Test + fun `Custom Signals empty builder`() { + val customSignals = customSignals {} + assertThat(customSignals.customSignals).isEmpty() + } } @RunWith(RobolectricTestRunner::class) 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 edbded6f79b..3b658a2dcbe 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.CUSTOM_SIGNALS; 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; @@ -85,6 +86,10 @@ public class ConfigFetchHttpClientTest { "etag-" + PROJECT_NUMBER + "-" + DEFAULT_NAMESPACE + "-fetch-%d"; private static final String FIRST_ETAG = String.format(ETAG_FORMAT, 1); private static final String SECOND_ETAG = String.format(ETAG_FORMAT, 2); + private static final Map SAMPLE_CUSTOM_SIGNALS = + ImmutableMap.of( + "subscription", "premium", + "age", "20"); private Context context; private ConfigFetchHttpClient configFetchHttpClient; @@ -105,7 +110,8 @@ public void setUp() throws Exception { API_KEY, DEFAULT_NAMESPACE, /* connectTimeoutInSeconds= */ 10L, - /* readTimeoutInSeconds= */ 10L); + /* readTimeoutInSeconds= */ 10L, + /* customSignals= */ SAMPLE_CUSTOM_SIGNALS); hasChangeResponseBody = new JSONObject() @@ -238,6 +244,8 @@ public void fetch_setsAllElementsOfRequestBody_sendsRequestBodyToServer() throws assertThat(requestBody.get(FIRST_OPEN_TIME)).isEqualTo(firstOpenTimeIsoString); assertThat(requestBody.getJSONObject(ANALYTICS_USER_PROPERTIES).toString()) .isEqualTo(new JSONObject(customUserProperties).toString()); + assertThat(requestBody.getJSONObject(CUSTOM_SIGNALS).toString()) + .isEqualTo(new JSONObject(SAMPLE_CUSTOM_SIGNALS).toString()); } @Test @@ -316,7 +324,8 @@ public void fetch_setsTimeouts_urlConnectionHasTimeouts() throws Exception { API_KEY, DEFAULT_NAMESPACE, /* connectTimeoutInSeconds= */ 15L, - /* readTimeoutInSeconds= */ 20L); + /* readTimeoutInSeconds= */ 20L, + /* customSignals= */ SAMPLE_CUSTOM_SIGNALS); setServerResponseTo(noChangeResponseBody, SECOND_ETAG); fetch(FIRST_ETAG); diff --git a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClientTest.java b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClientTest.java index 7edcc492e0d..2edfb171ddc 100644 --- a/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClientTest.java +++ b/firebase-config/src/test/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClientTest.java @@ -30,11 +30,15 @@ import android.content.SharedPreferences; import androidx.test.core.app.ApplicationProvider; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; import com.google.firebase.remoteconfig.FirebaseRemoteConfigInfo; import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings; import com.google.firebase.remoteconfig.internal.ConfigSharedPrefsClient.BackoffMetadata; import com.google.firebase.remoteconfig.internal.ConfigSharedPrefsClient.LastFetchStatus; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; +import java.util.Map; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -351,4 +355,40 @@ public void clear_hasSetValues_clearsAll() { assertThat(info.getConfigSettings().getMinimumFetchIntervalInSeconds()) .isEqualTo(DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS); } + + @Test + public void getCustomSignals_isNotSet_returnsEmptyMap() { + assertThat(sharedPrefsClient.getCustomSignals()).isEqualTo(Collections.emptyMap()); + } + + @Test + public void getCustomSignals_isSet_returnsCustomSignals() { + Map SAMPLE_CUSTOM_SIGNALS = + ImmutableMap.of( + "subscription", "premium", + "age", "20"); + sharedPrefsClient.setCustomSignals(SAMPLE_CUSTOM_SIGNALS); + assertThat(sharedPrefsClient.getCustomSignals()).isEqualTo(SAMPLE_CUSTOM_SIGNALS); + } + + @Test + public void setCustomSignals_multipleTimes_addsNewSignals() { + Map signals1 = ImmutableMap.of("subscription", "premium"); + Map signals2 = ImmutableMap.of("age", "20", "subscription", "basic"); + sharedPrefsClient.setCustomSignals(signals1); + sharedPrefsClient.setCustomSignals(signals2); + Map expectedSignals = ImmutableMap.of("subscription", "basic", "age", "20"); + assertThat(sharedPrefsClient.getCustomSignals()).isEqualTo(expectedSignals); + } + + @Test + public void setCustomSignals_nullValue_removesSignal() { + Map signals1 = ImmutableMap.of("subscription", "premium", "age", "20"); + sharedPrefsClient.setCustomSignals(signals1); + Map signals2 = new HashMap<>(); + signals2.put("age", null); + sharedPrefsClient.setCustomSignals(signals2); + Map expectedSignals = ImmutableMap.of("subscription", "premium"); + assertThat(sharedPrefsClient.getCustomSignals()).isEqualTo(expectedSignals); + } }