Skip to content

Add custom signals support in Remote Config. #6410

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
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
86ee29c
Add support for custom signals
tusharkhandelwal8 Oct 15, 2024
b241027
Add support for custom signals
tusharkhandelwal8 Oct 15, 2024
bb8336a
Merge remote-tracking branch 'origin/tushar-khandelwal/rc-custom-targ…
tusharkhandelwal8 Oct 15, 2024
ff596ae
Fix lint and build errors.
tusharkhandelwal8 Oct 25, 2024
2a678f6
Fix lint and build errors.
tusharkhandelwal8 Oct 25, 2024
bd05e9d
Merge remote-tracking branch 'origin/tushar-khandelwal/rc-custom-targ…
tusharkhandelwal8 Oct 25, 2024
60ae034
update the API definition file to include setCustomSignals
tusharkhandelwal8 Oct 25, 2024
d1cac74
Add limits, input validation, and unit tests for custom signals
tusharkhandelwal8 Nov 5, 2024
07ae4d4
Merge branch 'refs/heads/main' into tushar-khandelwal/rc-custom-targe…
tusharkhandelwal8 Nov 5, 2024
917a462
Rename ConfigMetadataClient to ConfigSharedPrefsClient in a different…
tusharkhandelwal8 Nov 6, 2024
deb3d84
Rename ConfigMetadataClient to ConfigSharedPrefsClient in a different…
tusharkhandelwal8 Nov 6, 2024
1e172a4
Merge remote-tracking branch 'origin/tushar-khandelwal/rc-custom-targ…
tusharkhandelwal8 Nov 6, 2024
92814b7
Log warnings for invalid custom signals.
tusharkhandelwal8 Nov 18, 2024
288dcd1
Add CustomSignals type for restricted values in setCustomSignals.
tusharkhandelwal8 Nov 21, 2024
88f268f
Add put(String, double) to the CustomSignals.Builder API definition
tusharkhandelwal8 Nov 21, 2024
ccd4b2e
Fix Formating
tusharkhandelwal8 Nov 22, 2024
5a15867
Fix Formating for custom signal limits
tusharkhandelwal8 Nov 22, 2024
21a39c5
Rename ConfigMetadataClient to ConfigSharedPrefsClient (#6440)
tusharkhandelwal8 Nov 23, 2024
3e94fe3
Improve Kotlin API, add logs and tests
tusharkhandelwal8 Nov 26, 2024
5a41f0d
Update API definition file to include kotlin
tusharkhandelwal8 Nov 26, 2024
becb76d
Merge branch 'refs/heads/remoteConfigCustomTargeting' into tushar-kha…
tusharkhandelwal8 Nov 27, 2024
c6b9c56
Update test and rename customSignalsMap to customSignals
tusharkhandelwal8 Dec 3, 2024
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
13 changes: 13 additions & 0 deletions firebase-config/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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<java.lang.Boolean> activate();
method @NonNull public com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration addOnConfigUpdateListener(@NonNull com.google.firebase.remoteconfig.ConfigUpdateListener);
Expand All @@ -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<java.lang.Void> reset();
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setConfigSettingsAsync(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings);
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setCustomSignals(@NonNull com.google.firebase.remoteconfig.CustomSignals);
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setDefaultsAsync(@NonNull java.util.Map<java.lang.String,java.lang.Object>);
method @NonNull public com.google.android.gms.tasks.Task<java.lang.Void> setDefaultsAsync(@XmlRes int);
field public static final boolean DEFAULT_VALUE_FOR_BOOLEAN = false;
Expand Down Expand Up @@ -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<? super com.google.firebase.remoteconfig.CustomSignals.Builder,kotlin.Unit> 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<com.google.firebase.remoteconfig.ConfigUpdate> getConfigUpdates(@NonNull com.google.firebase.remoteconfig.FirebaseRemoteConfig);
method @NonNull public static com.google.firebase.remoteconfig.FirebaseRemoteConfig getRemoteConfig(@NonNull com.google.firebase.Firebase);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> customSignals;

public static class Builder {
// Holds the converted pairs of custom keys and values.
private Map<String, String> customSignals = new HashMap<String, String>();

// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,25 @@ private Task<Void> setDefaultsWithStringsMapAsync(Map<String, String> defaultsSt
FirebaseExecutors.directExecutor(), (unusedContainer) -> Tasks.forResult(null));
}

/**
* Asynchronously changes the custom signals for this {@link FirebaseRemoteConfig} instance.
*
* <p>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<Void> setCustomSignals(@NonNull CustomSignals customSignals) {
return Tasks.call(
executor,
() -> {
frcSharedPrefs.setCustomSignals(customSignals.customSignals);
return null;
});
}

/**
* Notifies the Firebase A/B Testing SDK about activated experiments.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,8 @@ ConfigFetchHttpClient getFrcBackendApiClient(
apiKey,
namespace,
/* connectTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(),
/* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds());
/* readTimeoutInSeconds= */ sharedPrefsClient.getFetchTimeoutInSeconds(),
/* customSignals= */ sharedPrefsClient.getCustomSignals());
}

@VisibleForTesting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
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.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;
Expand Down Expand Up @@ -93,6 +94,7 @@ public class ConfigFetchHttpClient {
private final String apiKey;
private final String projectNumber;
private final String namespace;
Map<String, String> customSignalsMap;
private final long connectTimeoutInSeconds;
private final long readTimeoutInSeconds;

Expand All @@ -106,14 +108,16 @@ public ConfigFetchHttpClient(
String apiKey,
String namespace,
long connectTimeoutInSeconds,
long readTimeoutInSeconds) {
long readTimeoutInSeconds,
Map<String, String> customSignalsMap) {
this.context = context;
this.appId = appId;
this.apiKey = apiKey;
this.projectNumber = extractProjectNumberFromAppId(appId);
this.namespace = namespace;
this.connectTimeoutInSeconds = connectTimeoutInSeconds;
this.readTimeoutInSeconds = readTimeoutInSeconds;
this.customSignalsMap = customSignalsMap;
}

/** Used to verify that the timeout is being set correctly. */
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -251,6 +268,75 @@ void setBackoffMetadata(int numFailedFetches, Date backoffEndTime) {
}
}

public void setCustomSignals(Map<String, String> newCustomSignals) {
synchronized (customSignalsLock) {
// Retrieve existing custom signals
Map<String, String> existingCustomSignals = getCustomSignals();

for (Map.Entry<String, String> 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<String, String> getCustomSignals() {
String jsonString = frcSharedPrefs.getString(CUSTOM_SIGNALS, "{}");
try {
JSONObject existingCustomSignalsJson = new JSONObject(jsonString);
Map<String, String> custom_signals = new HashMap<>();
Iterator<String> 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);
}
Expand Down
Loading
Loading