1 // Copyright 2023 The Chromium Authors 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.net.httpflags; 6 7 import androidx.annotation.Nullable; 8 import androidx.annotation.VisibleForTesting; 9 10 import com.google.protobuf.ByteString; 11 12 import java.util.Collections; 13 import java.util.HashMap; 14 import java.util.Map; 15 import java.util.StringTokenizer; 16 17 /** 18 * Holds the effective HTTP flags that apply to a given instance of the Cronet library. 19 * 20 * <p>Cronet business logic code is expected to use this class to enquire about the HTTP flag values 21 * that it should use. 22 */ 23 public final class ResolvedFlags { 24 /** 25 * Provides type-safe access to the value of a given HTTP flag. 26 * 27 * <p>This object can never hold a null flag value. 28 */ 29 public static final class Value { 30 public static enum Type { 31 BOOL, 32 INT, 33 FLOAT, 34 STRING, 35 BYTES 36 } 37 38 private final Object mValue; 39 40 @Nullable resolve(FlagValue flagValue, String appId, int[] cronetVersion)41 private static Value resolve(FlagValue flagValue, String appId, int[] cronetVersion) { 42 for (var constrainedValue : flagValue.getConstrainedValuesList()) { 43 if ((constrainedValue.hasAppId() && !constrainedValue.getAppId().equals(appId)) 44 || (constrainedValue.hasMinVersion() 45 && !matchesVersion( 46 cronetVersion, 47 parseVersionString(constrainedValue.getMinVersion())))) { 48 continue; 49 } 50 return fromConstrainedValue(constrainedValue); 51 } 52 return null; 53 } 54 matchesVersion(int[] cronetVersion, int[] minVersion)55 private static boolean matchesVersion(int[] cronetVersion, int[] minVersion) { 56 for (int i = 0; i < Math.max(cronetVersion.length, minVersion.length); i++) { 57 int cronetComponent = i < cronetVersion.length ? cronetVersion[i] : 0; 58 int minComponent = i < minVersion.length ? minVersion[i] : 0; 59 if (cronetComponent > minComponent) { 60 return true; 61 } else if (cronetComponent < minComponent) { 62 return false; 63 } 64 } 65 return true; 66 } 67 68 private static Value fromConstrainedValue(FlagValue.ConstrainedValue constrainedValue) { 69 FlagValue.ConstrainedValue.ValueCase valueCase = constrainedValue.getValueCase(); 70 switch (valueCase) { 71 case BOOL_VALUE: 72 return new Value(constrainedValue.getBoolValue()); 73 case INT_VALUE: 74 return new Value(constrainedValue.getIntValue()); 75 case FLOAT_VALUE: 76 return new Value(constrainedValue.getFloatValue()); 77 case STRING_VALUE: 78 return new Value(constrainedValue.getStringValue()); 79 case BYTES_VALUE: 80 return new Value(constrainedValue.getBytesValue()); 81 case VALUE_NOT_SET: 82 return null; 83 default: 84 throw new IllegalArgumentException( 85 "Flag value uses unknown value type " + valueCase); 86 } 87 } 88 89 @VisibleForTesting 90 public Value(boolean value) { 91 mValue = value; 92 } 93 94 @VisibleForTesting 95 public Value(long value) { 96 mValue = value; 97 } 98 99 @VisibleForTesting 100 public Value(float value) { 101 mValue = value; 102 } 103 104 @VisibleForTesting 105 public Value(String value) { 106 mValue = value; 107 } 108 109 @VisibleForTesting 110 public Value(ByteString value) { 111 mValue = value; 112 } 113 114 public Type getType() { 115 if (mValue instanceof Boolean) { 116 return Type.BOOL; 117 } else if (mValue instanceof Long) { 118 return Type.INT; 119 } else if (mValue instanceof Float) { 120 return Type.FLOAT; 121 } else if (mValue instanceof String) { 122 return Type.STRING; 123 } else if (mValue instanceof ByteString) { 124 return Type.BYTES; 125 } else { 126 throw new IllegalStateException( 127 "Unexpected flag value type: " + mValue.getClass().getName()); 128 } 129 } 130 131 private void checkType(Type requestedType) { 132 Type actualType = getType(); 133 if (requestedType != actualType) { 134 throw new IllegalStateException( 135 "Attempted to access flag value as " 136 + requestedType 137 + ", but actual type is " 138 + actualType); 139 } 140 } 141 142 /** @throws IllegalStateException Iff {@link #getType} is not {@link Type#BOOL} */ 143 public boolean getBoolValue() { 144 checkType(Type.BOOL); 145 return (Boolean) mValue; 146 } 147 148 /** @throws IllegalStateException Iff {@link #getType} is not {@link Type#INT} */ 149 public long getIntValue() { 150 checkType(Type.INT); 151 return (Long) mValue; 152 } 153 154 /** @throws IllegalStateException Iff {@link #getType} is not {@link Type#FLOAT} */ 155 public float getFloatValue() { 156 checkType(Type.FLOAT); 157 return (Float) mValue; 158 } 159 160 /** @throws IllegalStateException Iff {@link #getType} is not {@link Type#STRING} */ 161 public String getStringValue() { 162 checkType(Type.STRING); 163 return (String) mValue; 164 } 165 166 /** @throws IllegalStateException Iff {@link #getType} is not {@link Type#BYTES} */ 167 public ByteString getBytesValue() { 168 checkType(Type.BYTES); 169 return (ByteString) mValue; 170 } 171 } 172 173 private final Map<String, Value> mFlags; 174 175 /** 176 * Computes effective flag values based on the contents of a {@link Flags} proto. 177 * 178 * <p>This method will resolve {@link FlagValue.ConstrainedValue} filters according to the 179 * other arguments, producing the final values that should apply to the caller. 180 * 181 * <p>Note that a {@link FlagValue} that has no {@link FlagValue.ConstrainedValue} entry, or 182 * where the matching entry has no value set, will not be mentioned at all in the resulting 183 * {@link #flags}. 184 * 185 * @param flags The {@link Flags} proto to extract the flag values from. This would normally be 186 * the return value of {@link HttpFlagsLoader#load}. 187 * @param appId The App ID for resolving the {@link FlagValue.ConstrainedValue#getAppId} field. 188 * This would normally be the return value of 189 * {@link android.content.Context#getPackageName}. 190 * @param cronetVersion The version to use for filtering against the {@link 191 * FlagValue.ConstrainedValue#getMinVersion} field. 192 */ 193 public static ResolvedFlags resolve(Flags flags, String appId, String cronetVersion) { 194 int[] parsedCronetVersion = parseVersionString(cronetVersion); 195 Map<String, Value> resolvedFlags = new HashMap<String, Value>(); 196 for (var flag : flags.getFlagsMap().entrySet()) { 197 try { 198 Value value = Value.resolve(flag.getValue(), appId, parsedCronetVersion); 199 if (value == null) continue; 200 resolvedFlags.put(flag.getKey(), value); 201 } catch (RuntimeException exception) { 202 throw new IllegalArgumentException( 203 "Unable to resolve HTTP flag `" + flag.getKey() + "`", exception); 204 } 205 } 206 return new ResolvedFlags(resolvedFlags); 207 } 208 209 @VisibleForTesting 210 public ResolvedFlags(Map<String, Value> flags) { 211 mFlags = flags; 212 } 213 214 /** 215 * @return The effective HTTP flag values, keyed by flag name. Neither keys nor values can be 216 * null. Only flags that have actual values are included in the result. 217 */ 218 public Map<String, Value> flags() { 219 return Collections.unmodifiableMap(mFlags); 220 } 221 222 private static int[] parseVersionString(String versionString) { 223 try { 224 if (versionString.isEmpty()) { 225 throw new IllegalArgumentException("Version string is empty"); 226 } 227 StringTokenizer tokenizer = new StringTokenizer(versionString, "."); 228 int[] components = new int[tokenizer.countTokens()]; 229 for (int i = 0; i < components.length; i++) { 230 components[i] = Integer.parseInt(tokenizer.nextToken()); 231 } 232 return components; 233 } catch (RuntimeException exception) { 234 throw new IllegalArgumentException( 235 "Unable to parse HTTP flags version string: `" + versionString + "`", 236 exception); 237 } 238 } 239 } 240