Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
12 changes: 12 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
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 customSignalsMap 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 customSignalsMap) {
return Tasks.call(
executor,
() -> {
frcMetadata.setCustomSignals(customSignalsMap.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 @@ -262,7 +262,8 @@ ConfigFetchHttpClient getFrcBackendApiClient(
apiKey,
namespace,
/* connectTimeoutInSeconds= */ metadataClient.getFetchTimeoutInSeconds(),
/* readTimeoutInSeconds= */ metadataClient.getFetchTimeoutInSeconds());
/* readTimeoutInSeconds= */ metadataClient.getFetchTimeoutInSeconds(),
/* customSignals= */ metadataClient.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,10 @@ private JSONObject createFetchRequestBody(

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

if (!customSignalsMap.isEmpty()) {
requestBodyMap.put(CUSTOM_SIGNALS, new JSONObject(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 that is saved to disk and persisted
Expand Down Expand Up @@ -75,17 +83,26 @@ public class ConfigMetadataClient {
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 frcMetadata;

private final Object frcInfoLock;
private final Object backoffMetadataLock;
private final Object realtimeBackoffMetadataLock;
private final Object customSignalsLock;

public ConfigMetadataClient(SharedPreferences frcMetadata) {
this.frcMetadata = frcMetadata;
this.frcInfoLock = new Object();
this.backoffMetadataLock = new Object();
this.realtimeBackoffMetadataLock = new Object();
this.customSignalsLock = new Object();
}

public long getFetchTimeoutInSeconds() {
Expand Down Expand Up @@ -249,6 +266,72 @@ 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;
}

frcMetadata
.edit()
.putString(CUSTOM_SIGNALS, new JSONObject(existingCustomSignals).toString())
.commit();
}
}

public Map<String, String> getCustomSignals() {
String jsonString = frcMetadata.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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> SAMPLE_CUSTOM_SIGNALS =
ImmutableMap.of(
"subscription", "premium",
"age", "20");

private Context context;
private ConfigFetchHttpClient configFetchHttpClient;
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading