@@ -206,9 +206,12 @@ private ExperimentationFlag parseFlag(JSONObject json) {
206206 isExperimentActive = json .optBoolean ("is_experiment_active" , false );
207207 }
208208
209+ // Parse hash_salt (may be null for legacy flags)
210+ String hashSalt = json .optString ("hash_salt" , null );
211+
209212 RuleSet ruleset = parseRuleSet (json .optJSONObject ("ruleset" ));
210213
211- return new ExperimentationFlag (id , name , key , status , projectId , ruleset , context , experimentId , isExperimentActive );
214+ return new ExperimentationFlag (id , name , key , status , projectId , ruleset , context , experimentId , isExperimentActive , hashSalt );
212215 }
213216
214217 /**
@@ -371,9 +374,13 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
371374 }
372375
373376 // Evaluate rollouts
374- float rolloutHash = HashUtils .normalizedHash (contextValue + flagKey , "rollout" );
377+ List <Rollout > rollouts = ruleset .getRollouts ();
378+ for (int rolloutIndex = 0 ; rolloutIndex < rollouts .size (); rolloutIndex ++) {
379+ Rollout rollout = rollouts .get (rolloutIndex );
380+
381+ // Calculate rollout hash
382+ float rolloutHash = calculateRolloutHash (contextValue , flagKey , flag .getHashSalt (), rolloutIndex );
375383
376- for (Rollout rollout : ruleset .getRollouts ()) {
377384 if (rolloutHash >= rollout .getRolloutPercentage ()) {
378385 continue ;
379386 }
@@ -391,8 +398,8 @@ public <T> SelectedVariant<T> getVariant(String flagKey, SelectedVariant<T> fall
391398 if (rollout .hasVariantOverride ()) {
392399 selectedVariant = findVariantByKey (ruleset .getVariants (), rollout .getVariantOverride ().getKey ());
393400 } else {
394- // Use variant hash to select from split
395- float variantHash = HashUtils . normalizedHash (contextValue + flagKey , "variant" );
401+ // Calculate variant hash
402+ float variantHash = calculateVariantHash (contextValue , flagKey , flag . getHashSalt () );
396403 selectedVariant = selectVariantBySplit (ruleset .getVariants (), variantHash , rollout );
397404 }
398405
@@ -558,6 +565,46 @@ private Variant selectVariantBySplit(List<Variant> variants, float hash, Rollout
558565 return variantsToUse .isEmpty () ? null : variantsToUse .get (variantsToUse .size () - 1 );
559566 }
560567
568+ /**
569+ * Calculates the rollout hash for a given context and rollout index.
570+ * <p>
571+ * This method can be overridden in tests to verify hash parameters.
572+ * </p>
573+ *
574+ * @param contextValue the context value (e.g., user ID)
575+ * @param flagKey the flag key
576+ * @param hashSalt the hash salt (null or empty for legacy behavior)
577+ * @param rolloutIndex the index of the rollout being evaluated
578+ * @return the normalized hash value (0.0 to 1.0)
579+ */
580+ protected float calculateRolloutHash (String contextValue , String flagKey ,
581+ String hashSalt , int rolloutIndex ) {
582+ if (hashSalt != null && !hashSalt .isEmpty ()) {
583+ return HashUtils .normalizedHash (contextValue + flagKey , hashSalt + rolloutIndex );
584+ } else {
585+ return HashUtils .normalizedHash (contextValue + flagKey , "rollout" );
586+ }
587+ }
588+
589+ /**
590+ * Calculates the variant hash for a given context.
591+ * <p>
592+ * This method can be overridden in tests to verify hash parameters.
593+ * </p>
594+ *
595+ * @param contextValue the context value (e.g., user ID)
596+ * @param flagKey the flag key
597+ * @param hashSalt the hash salt (null or empty for legacy behavior)
598+ * @return the normalized hash value (0.0 to 1.0)
599+ */
600+ protected float calculateVariantHash (String contextValue , String flagKey , String hashSalt ) {
601+ if (hashSalt != null && !hashSalt .isEmpty ()) {
602+ return HashUtils .normalizedHash (contextValue + flagKey , hashSalt + "variant" );
603+ } else {
604+ return HashUtils .normalizedHash (contextValue + flagKey , "variant" );
605+ }
606+ }
607+
561608 /**
562609 * Evaluates all flags and returns their selected variants.
563610 * <p>
0 commit comments