Skip to content

Commit 17cc491

Browse files
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
1 parent 5f75cc8 commit 17cc491

File tree

13 files changed

+403
-5
lines changed

13 files changed

+403
-5
lines changed

firebase-config/api.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ package com.google.firebase.remoteconfig {
1616
method public void remove();
1717
}
1818

19+
public class CustomSignals {
20+
}
21+
22+
public static class CustomSignals.Builder {
23+
ctor public CustomSignals.Builder();
24+
method @NonNull public com.google.firebase.remoteconfig.CustomSignals build();
25+
method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, @Nullable String);
26+
method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, long);
27+
method @NonNull public com.google.firebase.remoteconfig.CustomSignals.Builder put(@NonNull String, double);
28+
}
29+
1930
public class FirebaseRemoteConfig {
2031
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Boolean> activate();
2132
method @NonNull public com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration addOnConfigUpdateListener(@NonNull com.google.firebase.remoteconfig.ConfigUpdateListener);
@@ -35,6 +46,7 @@ package com.google.firebase.remoteconfig {
3546
method @NonNull public com.google.firebase.remoteconfig.FirebaseRemoteConfigValue getValue(@NonNull String);
3647
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> reset();
3748
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setConfigSettingsAsync(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings);
49+
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setCustomSignals(@NonNull com.google.firebase.remoteconfig.CustomSignals);
3850
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setDefaultsAsync(@NonNull java.util.Map<java.lang.String,java.lang.Object>);
3951
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setDefaultsAsync(@XmlRes int);
4052
field public static final boolean DEFAULT_VALUE_FOR_BOOLEAN = false;
@@ -121,6 +133,7 @@ package com.google.firebase.remoteconfig {
121133
}
122134

123135
public final class RemoteConfigKt {
136+
method @NonNull public static com.google.firebase.remoteconfig.CustomSignals customSignals(@NonNull kotlin.jvm.functions.Function1<? super com.google.firebase.remoteconfig.CustomSignals.Builder,kotlin.Unit> builder);
124137
method @NonNull public static operator com.google.firebase.remoteconfig.FirebaseRemoteConfigValue get(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfig, @NonNull String key);
125138
method @NonNull public static kotlinx.coroutines.flow.Flow<com.google.firebase.remoteconfig.ConfigUpdate> getConfigUpdates(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfig);
126139
method @NonNull public static com.google.firebase.remoteconfig.FirebaseRemoteConfig getRemoteConfig(@NonNull com.google.firebase.Firebase);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2024 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.remoteconfig;
16+
17+
import androidx.annotation.NonNull;
18+
import androidx.annotation.Nullable;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
22+
/**
23+
* Helper class which handles the storage and conversion to strings of key/value pairs with
24+
* heterogeneous value types for custom signals.
25+
*/
26+
public class CustomSignals {
27+
28+
final Map<String, String> customSignals;
29+
30+
public static class Builder {
31+
// Holds the converted pairs of custom keys and values.
32+
private Map<String, String> customSignals = new HashMap<String, String>();
33+
34+
// Methods to accept keys and values and convert values to strings.
35+
@NonNull
36+
public Builder put(@NonNull String key, @Nullable String value) {
37+
customSignals.put(key, value);
38+
return this;
39+
}
40+
41+
@NonNull
42+
public Builder put(@NonNull String key, long value) {
43+
customSignals.put(key, Long.toString(value));
44+
return this;
45+
}
46+
47+
@NonNull
48+
public Builder put(@NonNull String key, double value) {
49+
customSignals.put(key, Double.toString(value));
50+
return this;
51+
}
52+
53+
@NonNull
54+
public CustomSignals build() {
55+
return new CustomSignals(this);
56+
}
57+
}
58+
59+
CustomSignals(@NonNull Builder builder) {
60+
this.customSignals = builder.customSignals;
61+
}
62+
}

firebase-config/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,25 @@ private Task<Void> setDefaultsWithStringsMapAsync(Map<String, String> defaultsSt
652652
FirebaseExecutors.directExecutor(), (unusedContainer) -> Tasks.forResult(null));
653653
}
654654

655+
/**
656+
* Asynchronously changes the custom signals for this {@link FirebaseRemoteConfig} instance.
657+
*
658+
* <p>The {@code customSignals} parameter should be an instance of {@link CustomSignals}, which
659+
* enforces the allowed types for custom signal values (String, Long or Double).
660+
*
661+
* @param customSignals A dictionary of keys and the values of the custom signals to be set for
662+
* the app instance
663+
*/
664+
@NonNull
665+
public Task<Void> setCustomSignals(@NonNull CustomSignals customSignals) {
666+
return Tasks.call(
667+
executor,
668+
() -> {
669+
frcSharedPrefs.setCustomSignals(customSignals.customSignals);
670+
return null;
671+
});
672+
}
673+
655674
/**
656675
* Notifies the Firebase A/B Testing SDK about activated experiments.
657676
*

firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfig.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ fun remoteConfigSettings(
4848
return builder.build()
4949
}
5050

51+
fun customSignals(builder: CustomSignals.Builder.() -> Unit) =
52+
CustomSignals.Builder().apply(builder).build()
53+
5154
/**
5255
* Starts listening for config updates from the Remote Config backend and emits [ConfigUpdate]s via
5356
* a [Flow]. See [FirebaseRemoteConfig.addOnConfigUpdateListener] for more information.

firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigComponent.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ ConfigFetchHttpClient getFrcBackendApiClient(
262262
apiKey,
263263
namespace,
264264
/* connectTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(),
265-
/* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds());
265+
/* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(),
266+
/* customSignals= */ sharedPrefsClient.getCustomSignals());
266267
}
267268

268269
@VisibleForTesting

firebase-config/src/main/java/com/google/firebase/remoteconfig/RemoteConfigConstants.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ public final class RemoteConfigConstants {
5151
RequestFieldKey.PACKAGE_NAME,
5252
RequestFieldKey.SDK_VERSION,
5353
RequestFieldKey.ANALYTICS_USER_PROPERTIES,
54-
RequestFieldKey.FIRST_OPEN_TIME
54+
RequestFieldKey.FIRST_OPEN_TIME,
55+
RequestFieldKey.CUSTOM_SIGNALS
5556
})
5657
@Retention(RetentionPolicy.SOURCE)
5758
public @interface RequestFieldKey {
@@ -68,6 +69,7 @@ public final class RemoteConfigConstants {
6869
String SDK_VERSION = "sdkVersion";
6970
String ANALYTICS_USER_PROPERTIES = "analyticsUserProperties";
7071
String FIRST_OPEN_TIME = "firstOpenTime";
72+
String CUSTOM_SIGNALS = "customSignals";
7173
}
7274

7375
/** Keys of fields in the Fetch response body from the Firebase Remote Config server. */

firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigFetchHttpClient.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_ID;
2222
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.APP_VERSION;
2323
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.COUNTRY_CODE;
24+
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.CUSTOM_SIGNALS;
2425
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.FIRST_OPEN_TIME;
2526
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID;
2627
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.INSTANCE_ID_TOKEN;
@@ -93,6 +94,7 @@ public class ConfigFetchHttpClient {
9394
private final String apiKey;
9495
private final String projectNumber;
9596
private final String namespace;
97+
Map<String, String> customSignalsMap;
9698
private final long connectTimeoutInSeconds;
9799
private final long readTimeoutInSeconds;
98100

@@ -106,14 +108,16 @@ public ConfigFetchHttpClient(
106108
String apiKey,
107109
String namespace,
108110
long connectTimeoutInSeconds,
109-
long readTimeoutInSeconds) {
111+
long readTimeoutInSeconds,
112+
Map<String, String> customSignalsMap) {
110113
this.context = context;
111114
this.appId = appId;
112115
this.apiKey = apiKey;
113116
this.projectNumber = extractProjectNumberFromAppId(appId);
114117
this.namespace = namespace;
115118
this.connectTimeoutInSeconds = connectTimeoutInSeconds;
116119
this.readTimeoutInSeconds = readTimeoutInSeconds;
120+
this.customSignalsMap = customSignalsMap;
117121
}
118122

119123
/** Used to verify that the timeout is being set correctly. */
@@ -347,6 +351,13 @@ private JSONObject createFetchRequestBody(
347351

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

354+
if (!customSignalsMap.isEmpty()) {
355+
requestBodyMap.put(CUSTOM_SIGNALS, new JSONObject(customSignalsMap));
356+
357+
// Log the custom signals during fetch.
358+
Log.d(TAG, "Fetching with custom signals: " + customSignalsMap);
359+
}
360+
350361
if (firstOpenTime != null) {
351362
requestBodyMap.put(FIRST_OPEN_TIME, convertToISOString(firstOpenTime));
352363
}

firebase-config/src/main/java/com/google/firebase/remoteconfig/internal/ConfigSharedPrefsClient.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET;
1919
import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_SUCCESS;
2020
import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.LAST_FETCH_STATUS_THROTTLED;
21+
import static com.google.firebase.remoteconfig.FirebaseRemoteConfig.TAG;
2122
import static com.google.firebase.remoteconfig.RemoteConfigComponent.CONNECTION_TIMEOUT_IN_SECONDS;
23+
import static com.google.firebase.remoteconfig.RemoteConfigConstants.RequestFieldKey.CUSTOM_SIGNALS;
2224
import static com.google.firebase.remoteconfig.internal.ConfigFetchHandler.DEFAULT_MINIMUM_FETCH_INTERVAL_IN_SECONDS;
2325
import static java.lang.annotation.RetentionPolicy.SOURCE;
2426

2527
import android.content.SharedPreferences;
28+
import android.util.Log;
2629
import androidx.annotation.IntDef;
2730
import androidx.annotation.Nullable;
2831
import androidx.annotation.VisibleForTesting;
@@ -31,6 +34,11 @@
3134
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings;
3235
import java.lang.annotation.Retention;
3336
import java.util.Date;
37+
import java.util.HashMap;
38+
import java.util.Iterator;
39+
import java.util.Map;
40+
import org.json.JSONException;
41+
import org.json.JSONObject;
3442

3543
/**
3644
* Client for handling Firebase Remote Config (FRC) metadata and custom signals that are saved to
@@ -75,17 +83,26 @@ public class ConfigSharedPrefsClient {
7583
private static final String REALTIME_BACKOFF_END_TIME_IN_MILLIS_KEY =
7684
"realtime_backoff_end_time_in_millis";
7785

86+
/** Constants for custom signal limits.*/
87+
private static final int CUSTOM_SIGNALS_MAX_KEY_LENGTH = 250;
88+
89+
private static final int CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH = 500;
90+
91+
private static final int CUSTOM_SIGNALS_MAX_COUNT = 100;
92+
7893
private final SharedPreferences frcSharedPrefs;
7994

8095
private final Object frcInfoLock;
8196
private final Object backoffMetadataLock;
8297
private final Object realtimeBackoffMetadataLock;
98+
private final Object customSignalsLock;
8399

84100
public ConfigSharedPrefsClient(SharedPreferences frcSharedPrefs) {
85101
this.frcSharedPrefs = frcSharedPrefs;
86102
this.frcInfoLock = new Object();
87103
this.backoffMetadataLock = new Object();
88104
this.realtimeBackoffMetadataLock = new Object();
105+
this.customSignalsLock = new Object();
89106
}
90107

91108
public long getFetchTimeoutInSeconds() {
@@ -251,6 +268,75 @@ void setBackoffMetadata(int numFailedFetches, Date backoffEndTime) {
251268
}
252269
}
253270

271+
public void setCustomSignals(Map<String, String> newCustomSignals) {
272+
synchronized (customSignalsLock) {
273+
// Retrieve existing custom signals
274+
Map<String, String> existingCustomSignals = getCustomSignals();
275+
276+
for (Map.Entry<String, String> entry : newCustomSignals.entrySet()) {
277+
String key = entry.getKey();
278+
String value = entry.getValue();
279+
280+
// Validate key and value length
281+
if (key.length() > CUSTOM_SIGNALS_MAX_KEY_LENGTH
282+
|| (value != null && value.length() > CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH)) {
283+
Log.w(
284+
TAG,
285+
String.format(
286+
"Invalid custom signal: Custom signal keys must be %d characters or less, and values must be %d characters or less.",
287+
CUSTOM_SIGNALS_MAX_KEY_LENGTH, CUSTOM_SIGNALS_MAX_STRING_VALUE_LENGTH));
288+
return;
289+
}
290+
291+
// Merge new signals with existing ones, overwriting existing keys.
292+
// Also, remove entries where the new value is null.
293+
if (value != null) {
294+
existingCustomSignals.put(key, value);
295+
} else {
296+
existingCustomSignals.remove(key);
297+
}
298+
}
299+
300+
// Check if the map has actually changed and the size limit
301+
if (existingCustomSignals.equals(getCustomSignals())) {
302+
return;
303+
}
304+
if (existingCustomSignals.size() > CUSTOM_SIGNALS_MAX_COUNT) {
305+
Log.w(
306+
TAG,
307+
String.format(
308+
"Invalid custom signal: Too many custom signals provided. The maximum allowed is %d.",
309+
CUSTOM_SIGNALS_MAX_COUNT));
310+
return;
311+
}
312+
313+
frcSharedPrefs
314+
.edit()
315+
.putString(CUSTOM_SIGNALS, new JSONObject(existingCustomSignals).toString())
316+
.commit();
317+
318+
// Log the final updated custom signals.
319+
Log.d(TAG, "Updated custom signals: " + getCustomSignals());
320+
}
321+
}
322+
323+
public Map<String, String> getCustomSignals() {
324+
String jsonString = frcSharedPrefs.getString(CUSTOM_SIGNALS, "{}");
325+
try {
326+
JSONObject existingCustomSignalsJson = new JSONObject(jsonString);
327+
Map<String, String> custom_signals = new HashMap<>();
328+
Iterator<String> keys = existingCustomSignalsJson.keys();
329+
while (keys.hasNext()) {
330+
String key = keys.next();
331+
String value = existingCustomSignalsJson.optString(key);
332+
custom_signals.put(key, value);
333+
}
334+
return custom_signals;
335+
} catch (JSONException e) {
336+
return new HashMap<>();
337+
}
338+
}
339+
254340
void resetBackoff() {
255341
setBackoffMetadata(NO_FAILED_FETCHES, NO_BACKOFF_TIME);
256342
}

0 commit comments

Comments
 (0)