diff --git a/contract-tests/Representations.cs b/contract-tests/Representations.cs index 56199496..1836c4dd 100644 --- a/contract-tests/Representations.cs +++ b/contract-tests/Representations.cs @@ -77,6 +77,7 @@ public class SdkClientSideParams public Context? InitialContext { get; set; } public User InitialUser { get; set; } public bool? UseReport { get; set; } + public bool? IncludeEnvironmentAttributes { get; set; } } public class CommandParams diff --git a/contract-tests/SdkClientEntity.cs b/contract-tests/SdkClientEntity.cs index 81317bef..ec54e4b2 100644 --- a/contract-tests/SdkClientEntity.cs +++ b/contract-tests/SdkClientEntity.cs @@ -266,7 +266,11 @@ private ContextBuildResponse DoContextConvert(ContextConvertParams p) private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapter logAdapter, string tag) { - var builder = Configuration.Builder(sdkParams.Credential); + var autoEnvAttributes = (sdkParams.ClientSide.IncludeEnvironmentAttributes ?? false) + ? ConfigurationBuilder.AutoEnvAttributes.Enabled + : ConfigurationBuilder.AutoEnvAttributes.Disabled; + + var builder = Configuration.Builder(sdkParams.Credential, autoEnvAttributes); builder.Logging(Components.Logging(logAdapter).BaseLoggerName(tag + ".SDK")); diff --git a/contract-tests/TestService.cs b/contract-tests/TestService.cs index c485fc8b..59940839 100644 --- a/contract-tests/TestService.cs +++ b/contract-tests/TestService.cs @@ -37,7 +37,8 @@ public class Webapp "singleton", "strongly-typed", "user-type", - "tags" + "tags", + "auto-env-attributes" }; public readonly Handler Handler; @@ -54,7 +55,7 @@ public Webapp(EventWaitHandle quitSignal) _quitSignal = quitSignal; _version = LdClient.Version.ToString(); - + var service = new SimpleJsonService(); Handler = service.Handler; diff --git a/src/LaunchDarkly.ClientSdk/Configuration.cs b/src/LaunchDarkly.ClientSdk/Configuration.cs index 319cf400..9b457674 100644 --- a/src/LaunchDarkly.ClientSdk/Configuration.cs +++ b/src/LaunchDarkly.ClientSdk/Configuration.cs @@ -1,21 +1,34 @@ using System; +using System.Collections.Immutable; using LaunchDarkly.Sdk.Client.Integrations; using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal.Interfaces; +using LaunchDarkly.Sdk.Client.PlatformSpecific; using LaunchDarkly.Sdk.Client.Subsystems; namespace LaunchDarkly.Sdk.Client { /// - /// Configuration options for . + /// Configuration options for . /// /// /// Instances of are immutable once created. They can be created with the factory method - /// , or using a builder pattern with - /// or . + /// , or using a builder pattern + /// with or . /// public sealed class Configuration { + /// + /// ApplicationInfo configuration which contains info about the application the SDK is running in. + /// + public ApplicationInfoBuilder ApplicationInfo { get; } + + /// + /// True if Auto Environment Attributes functionality is enabled. When enabled, the SDK will automatically + /// provide data about the environment where the application is running. + /// + public bool AutoEnvAttributes { get; } + /// /// Default value for and /// . @@ -83,13 +96,13 @@ public sealed class Configuration /// /// HTTP configuration properties for the SDK. /// - /// + /// public HttpConfigurationBuilder HttpConfigurationBuilder { get; } /// /// Logging configuration properties for the SDK. /// - /// + /// public LoggingConfigurationBuilder LoggingConfigurationBuilder { get; } /// @@ -109,7 +122,7 @@ public sealed class Configuration /// /// Persistent storage configuration properties for the SDK. /// - /// + /// public PersistenceConfigurationBuilder PersistenceConfigurationBuilder { get; } /// @@ -118,19 +131,21 @@ public sealed class Configuration /// public ServiceEndpoints ServiceEndpoints { get; } - /// - /// ApplicationInfo configuration which contains info about the application the SDK is running in. - /// - public ApplicationInfoBuilder ApplicationInfo { get; } - /// /// Creates a configuration with all parameters set to the default. /// /// the SDK key for your LaunchDarkly environment + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// a instance - public static Configuration Default(string mobileKey) + public static Configuration Default(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes) { - return Builder(mobileKey).Build(); + return Builder(mobileKey, autoEnvAttributes).Build(); } /// @@ -151,14 +166,23 @@ public static Configuration Default(string mobileKey) /// /// /// the mobile SDK key for your LaunchDarkly environment + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// a builder object - public static ConfigurationBuilder Builder(string mobileKey) + public static ConfigurationBuilder Builder(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes) { if (String.IsNullOrEmpty(mobileKey)) { throw new ArgumentOutOfRangeException(nameof(mobileKey), "key is required"); } - return new ConfigurationBuilder(mobileKey); + + return new ConfigurationBuilder(mobileKey, autoEnvAttributes); } /// @@ -173,6 +197,8 @@ public static ConfigurationBuilder Builder(Configuration fromConfiguration) internal Configuration(ConfigurationBuilder builder) { + ApplicationInfo = builder._applicationInfo; + AutoEnvAttributes = builder._autoEnvAttributes; DataSource = builder._dataSource; DiagnosticOptOut = builder._diagnosticOptOut; EnableBackgroundUpdating = builder._enableBackgroundUpdating; @@ -185,7 +211,6 @@ internal Configuration(ConfigurationBuilder builder) Offline = builder._offline; PersistenceConfigurationBuilder = builder._persistenceConfigurationBuilder; ServiceEndpoints = (builder._serviceEndpointsBuilder ?? Components.ServiceEndpoints()).Build(); - ApplicationInfo = builder._applicationInfo; BackgroundModeManager = builder._backgroundModeManager; ConnectivityStateManager = builder._connectivityStateManager; } diff --git a/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs b/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs index d7d05a45..7a3ed36e 100644 --- a/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs +++ b/src/LaunchDarkly.ClientSdk/ConfigurationBuilder.cs @@ -11,7 +11,7 @@ namespace LaunchDarkly.Sdk.Client /// /// /// - /// Obtain an instance of this class by calling . + /// Obtain an instance of this class by calling . /// /// /// All of the builder methods for setting a configuration property return a reference to the same builder, so they can be @@ -25,12 +25,39 @@ namespace LaunchDarkly.Sdk.Client /// public sealed class ConfigurationBuilder { + /// + /// Enable / disable options for Auto Environment Attributes functionality. When enabled, the SDK will automatically + /// provide data about the environment where the application is running. This data makes it simpler to target + /// your mobile customers based on application name or version, or on device characteristics including manufacturer, + /// model, operating system, locale, and so on. We recommend enabling this when you configure the SDK. See + /// our documentation + /// for more details. + /// For example, consider a “dark mode” feature being added to an app. Versions 10 through 14 contain early, + /// incomplete versions of the feature. These versions are available to all customers, but the “dark mode” feature is only + /// enabled for testers. With version 15, the feature is considered complete. With Auto Environment Attributes enabled, + /// you can use targeting rules to enable "dark mode" for all customers who are using version 15 or greater, and ensure + /// that customers on previous versions don't use the earlier, unfinished version of the feature. + /// + public enum AutoEnvAttributes + { + /// + /// Enables the Auto EnvironmentAttributes functionality. + /// + Enabled, + + /// + /// Disables the Auto EnvironmentAttributes functionality. + /// + Disabled + } + // This exists so that we can distinguish between leaving the HttpMessageHandler property unchanged // and explicitly setting it to null. If the property value is the exact same instance as this, we // will replace it with a platform-specific implementation. internal static readonly HttpMessageHandler DefaultHttpMessageHandlerInstance = new HttpClientHandler(); internal ApplicationInfoBuilder _applicationInfo; + internal bool _autoEnvAttributes = false; internal IComponentConfigurer _dataSource = null; internal bool _diagnosticOptOut = false; internal bool _enableBackgroundUpdating = true; @@ -48,13 +75,16 @@ public sealed class ConfigurationBuilder internal IBackgroundModeManager _backgroundModeManager; internal IConnectivityStateManager _connectivityStateManager; - internal ConfigurationBuilder(string mobileKey) + internal ConfigurationBuilder(string mobileKey, AutoEnvAttributes autoEnvAttributes) { _mobileKey = mobileKey; + _autoEnvAttributes = autoEnvAttributes == AutoEnvAttributes.Enabled; // map enum to boolean } internal ConfigurationBuilder(Configuration copyFrom) { + _applicationInfo = copyFrom.ApplicationInfo; + _autoEnvAttributes = copyFrom.AutoEnvAttributes; _dataSource = copyFrom.DataSource; _diagnosticOptOut = copyFrom.DiagnosticOptOut; _enableBackgroundUpdating = copyFrom.EnableBackgroundUpdating; @@ -66,7 +96,6 @@ internal ConfigurationBuilder(Configuration copyFrom) _offline = copyFrom.Offline; _persistenceConfigurationBuilder = copyFrom.PersistenceConfigurationBuilder; _serviceEndpointsBuilder = new ServiceEndpointsBuilder(copyFrom.ServiceEndpoints); - _applicationInfo = copyFrom.ApplicationInfo; } /// @@ -78,7 +107,7 @@ public Configuration Build() { return new Configuration(this); } - + /// /// Sets the SDK's application metadata, which may be used in the LaunchDarkly analytics or other product /// features. This object is normally a configuration builder obtained from , @@ -92,6 +121,23 @@ public ConfigurationBuilder ApplicationInfo(ApplicationInfoBuilder applicationIn return this; } + /// + /// Specifies whether the SDK will use Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. + /// + /// Enable / disable Auto Environment Attributes functionality. + /// the same builder + public ConfigurationBuilder AutoEnvironmentAttributes(AutoEnvAttributes autoEnvAttributes) + { + _autoEnvAttributes = autoEnvAttributes == AutoEnvAttributes.Enabled; // map enum to boolean + return this; + } + /// /// Sets the implementation of the component that receives feature flag data from LaunchDarkly, /// using a factory object. @@ -205,7 +251,7 @@ public ConfigurationBuilder Events(IComponentConfigurer eventsC /// /// /// If enabled, this option changes the SDK's behavior whenever the (as given to - /// methods like or + /// methods like or /// ) has an /// property of , as follows: /// diff --git a/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs b/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs index ce8458af..4f138db4 100644 --- a/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs +++ b/src/LaunchDarkly.ClientSdk/Integrations/HttpConfigurationBuilder.cs @@ -237,7 +237,7 @@ public HttpConfigurationBuilder Wrapper(string wrapperName, string wrapperVersio /// Key for authenticating with LD service /// Application Info for this application environment /// an - public HttpConfiguration CreateHttpConfiguration(string authKey, ApplicationInfo applicationInfo) => + public HttpConfiguration CreateHttpConfiguration(string authKey, ApplicationInfo? applicationInfo) => new HttpConfiguration( MakeHttpProperties(authKey, applicationInfo), _messageHandler, @@ -255,7 +255,7 @@ public LdValue DescribeConfiguration(LdClientContext context) => // which is more correct, but we can't really set ReadTimeout in this SDK .Build(); - private HttpProperties MakeHttpProperties(string authToken, ApplicationInfo applicationInfo) + private HttpProperties MakeHttpProperties(string authToken, ApplicationInfo? applicationInfo) { Func handlerFn; if (_messageHandler is null) @@ -273,9 +273,13 @@ private HttpProperties MakeHttpProperties(string authToken, ApplicationInfo appl .WithHttpMessageHandlerFactory(handlerFn) .WithProxy(_proxy) .WithUserAgent(SdkPackage.UserAgent) - .WithApplicationTags(applicationInfo) .WithWrapper(_wrapperName, _wrapperVersion); + if (applicationInfo.HasValue) + { + httpProperties = httpProperties.WithApplicationTags(applicationInfo.Value); + } + foreach (var kv in _customHeaders) { httpProperties = httpProperties.WithHeader(kv.Key, kv.Value); diff --git a/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs b/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs index 972c8cf4..1d88a15f 100644 --- a/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs +++ b/src/LaunchDarkly.ClientSdk/Integrations/PersistenceConfigurationBuilder.cs @@ -84,7 +84,7 @@ public PersistenceConfigurationBuilder Storage(IComponentConfigurer /// /// - /// + /// the builder public PersistenceConfigurationBuilder MaxCachedContexts(int maxCachedContexts) { _maxCachedContexts = maxCachedContexts; diff --git a/src/LaunchDarkly.ClientSdk/Internal/ContextDecorator.cs b/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs similarity index 96% rename from src/LaunchDarkly.ClientSdk/Internal/ContextDecorator.cs rename to src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs index 69d256cc..008a8bed 100644 --- a/src/LaunchDarkly.ClientSdk/Internal/ContextDecorator.cs +++ b/src/LaunchDarkly.ClientSdk/Internal/AnonymousKeyContextDecorator.cs @@ -5,7 +5,7 @@ namespace LaunchDarkly.Sdk.Client.Internal { - internal class ContextDecorator + internal class AnonymousKeyContextDecorator { private readonly PersistentDataStoreWrapper _store; private readonly bool _generateAnonymousKeys; @@ -13,7 +13,7 @@ internal class ContextDecorator private Dictionary _cachedGeneratedKey = new Dictionary(); private object _generatedKeyLock = new object(); - public ContextDecorator( + public AnonymousKeyContextDecorator( PersistentDataStoreWrapper store, bool generateAnonymousKeys ) diff --git a/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs b/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs new file mode 100644 index 00000000..c41670aa --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/Internal/AutoEnvContextDecorator.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Logging; +using LaunchDarkly.Sdk.EnvReporting; +using LaunchDarkly.Sdk.Client.Internal.DataStores; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + /// + /// This class can decorate a context by adding additional contexts to it using auto environment attributes + /// provided by . + /// + internal class AutoEnvContextDecorator + { + internal const string LdApplicationKind = "ld_application"; + internal const string LdDeviceKind = "ld_device"; + internal const string AttrId = "id"; + internal const string AttrName = "name"; + internal const string AttrVersion = "version"; + internal const string AttrVersionName = "versionName"; + internal const string AttrManufacturer = "manufacturer"; + internal const string AttrModel = "model"; + internal const string AttrLocale = "locale"; + internal const string AttrOs = "os"; + internal const string AttrFamily = "family"; + internal const string EnvAttributesVersion = "envAttributesVersion"; + internal const string SpecVersion = "1.0"; + + private readonly PersistentDataStoreWrapper _persistentData; + private readonly IEnvironmentReporter _environmentReporter; + private readonly Logger _logger; + + /// + /// Creates a . + /// + /// the data source that will be used for retrieving/saving information related + /// to the generated contexts. Example data includes the stable key of the ld_device context kind. + /// the environment reporter that will be used to source the + /// environment attributes + /// the logger + public AutoEnvContextDecorator( + PersistentDataStoreWrapper persistentData, + IEnvironmentReporter environmentReporter, + Logger logger) + { + _persistentData = persistentData; + _environmentReporter = environmentReporter; + _logger = logger; + } + + /// + /// Decorates the provided context with additional contexts containing environment attributes. + /// + /// the context to be decorated + /// the decorated context + public Context DecorateContext(Context context) + { + var builder = Context.MultiBuilder(); + builder.Add(context); + + foreach (var recipe in MakeRecipeList()) + { + if (!context.TryGetContextByKind(recipe.Kind, out _)) + { + // only add contexts for recipe Kinds not already in context to avoid overwriting data. + recipe.TryWrite(builder); + } + else + { + _logger.Warn("Unable to automatically add environment attributes for kind:{0}. {1} already exists.", + recipe.Kind, recipe.Kind); + } + } + + return builder.Build(); + } + + private readonly struct ContextRecipe + { + public ContextKind Kind { get; } + private Func KeyCallable { get; } + private List RecipeNodes { get; } + + public ContextRecipe(ContextKind kind, Func keyCallable, List recipeNodes) + { + Kind = kind; + KeyCallable = keyCallable; + RecipeNodes = recipeNodes; + } + + public void TryWrite(ContextMultiBuilder multiBuilder) + { + var contextBuilder = Context.Builder(Kind, KeyCallable.Invoke()); + var adaptedBuilder = new ContextBuilderAdapter(contextBuilder); + if (RecipeNodes.Aggregate(false, (wrote, node) => wrote | node.TryWrite(adaptedBuilder))) + { + contextBuilder.Set(EnvAttributesVersion, SpecVersion); + multiBuilder.Add(contextBuilder.Build()); + } + } + } + + private List MakeRecipeList() + { + var ldApplicationKind = ContextKind.Of(LdApplicationKind); + var applicationNodes = new List + { + new Node(AttrId, LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationId)), + new Node(AttrName, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationName)), + new Node(AttrVersion, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationVersion)), + new Node(AttrVersionName, + LdValue.Of(_environmentReporter.ApplicationInfo?.ApplicationVersionName)), + new Node(AttrLocale, LdValue.Of(_environmentReporter.Locale)), + }; + + var ldDeviceKind = ContextKind.Of(LdDeviceKind); + var deviceNodes = new List + { + new Node(AttrManufacturer, + LdValue.Of(_environmentReporter.DeviceInfo?.Manufacturer)), + new Node(AttrModel, LdValue.Of(_environmentReporter.DeviceInfo?.Model)), + new Node(AttrOs, new List + { + new Node(AttrFamily, LdValue.Of(_environmentReporter.OsInfo?.Family)), + new Node(AttrName, LdValue.Of(_environmentReporter.OsInfo?.Name)), + new Node(AttrVersion, LdValue.Of(_environmentReporter.OsInfo?.Version)), + }) + }; + + return new List + { + new ContextRecipe( + ldApplicationKind, + () => Base64.UrlSafeSha256Hash( + _environmentReporter.ApplicationInfo?.ApplicationId ?? "" + ), + applicationNodes + ), + new ContextRecipe( + ldDeviceKind, + () => GetOrCreateAutoContextKey(_persistentData, ldDeviceKind), + deviceNodes + ) + }; + } + + private string GetOrCreateAutoContextKey(PersistentDataStoreWrapper store, ContextKind contextKind) + { + var uniqueId = store.GetGeneratedContextKey(contextKind); + if (uniqueId is null) + { + uniqueId = Guid.NewGuid().ToString(); + store.SetGeneratedContextKey(contextKind, uniqueId); + } + return uniqueId; + } + + private interface ISettableMap + { + void Set(string attributeName, LdValue value); + } + + private class Node + { + private readonly string _key; + private readonly LdValue? _value; + private readonly List _children; + + public Node(string key, List children) + { + _key = key; + _children = children; + } + + public Node(string key, LdValue value) + { + _key = key; + _value = value; + } + + public bool TryWrite(ISettableMap settableMap) + { + if (_value.HasValue && !_value.Value.IsNull) + { + settableMap.Set(_key, _value.Value); + return true; + } + + if (_children == null) return false; + + var objBuilder = LdValue.BuildObject(); + var adaptedBuilder = new ObjectBuilderAdapter(objBuilder); + + if (!_children.Aggregate(false, (wrote, node) => wrote | node.TryWrite(adaptedBuilder))) return false; + + settableMap.Set(_key, objBuilder.Build()); + return true; + } + } + + + private class ObjectBuilderAdapter : ISettableMap + { + private readonly LdValue.ObjectBuilder _underlyingBuilder; + + public ObjectBuilderAdapter(LdValue.ObjectBuilder builder) + { + _underlyingBuilder = builder; + } + + public void Set(string attributeName, LdValue value) + { + _underlyingBuilder.Set(attributeName, value); + } + } + + private class ContextBuilderAdapter : ISettableMap + { + private readonly ContextBuilder _underlyingBuilder; + + public ContextBuilderAdapter(ContextBuilder builder) + { + _underlyingBuilder = builder; + } + + public void Set(string attributeName, LdValue value) + { + _underlyingBuilder.Set(attributeName, value); + } + } + } +} diff --git a/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs b/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs index e6749c14..2a9fa3f4 100644 --- a/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs +++ b/src/LaunchDarkly.ClientSdk/Internal/SdkPackage.cs @@ -2,7 +2,6 @@ namespace LaunchDarkly.Sdk.Client.Internal { - /// /// Defines common information about the SDK itself for usage /// in various components. diff --git a/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj b/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj index 9228e2ca..e99aea05 100644 --- a/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj +++ b/src/LaunchDarkly.ClientSdk/LaunchDarkly.ClientSdk.csproj @@ -37,13 +37,12 @@ - + - + - @@ -81,6 +80,15 @@ + + + + + + + + + ../../LaunchDarkly.ClientSdk.snk true diff --git a/src/LaunchDarkly.ClientSdk/LdClient.cs b/src/LaunchDarkly.ClientSdk/LdClient.cs index 147ce5f9..6034dfa2 100644 --- a/src/LaunchDarkly.ClientSdk/LdClient.cs +++ b/src/LaunchDarkly.ClientSdk/LdClient.cs @@ -25,8 +25,8 @@ namespace LaunchDarkly.Sdk.Client /// /// /// Like all client-side LaunchDarkly SDKs, the LdClient always has a single current . - /// You specify this context at initialization time, and you can change it later with - /// or . All subsequent calls to evaluation methods like + /// You specify this context at initialization time, and you can change it later with + /// or . All subsequent calls to evaluation methods like /// refer to the flag values for the current context. /// /// @@ -59,7 +59,8 @@ public sealed class LdClient : ILdClient readonly IEventProcessor _eventProcessor; readonly IFlagTracker _flagTracker; readonly TaskExecutor _taskExecutor; - readonly ContextDecorator _contextDecorator; + readonly AnonymousKeyContextDecorator _anonymousKeyContextDecorator; + private readonly AutoEnvContextDecorator _autoEnvContextDecorator; private readonly Logger _log; @@ -72,8 +73,8 @@ public sealed class LdClient : ILdClient /// create a new client instance unless you first call on this one. /// /// - /// Use the static factory methods or - /// to set this instance. + /// Use the static factory methods or + /// to set this instance. /// public static LdClient Instance => _instance; @@ -91,9 +92,9 @@ public sealed class LdClient : ILdClient /// The current evaluation context for all SDK operations. /// /// - /// This is initially the context specified for or - /// , but can be changed later with - /// or . + /// This is initially the context specified for or + /// , but can be changed later with + /// or . /// public Context Context => LockUtils.WithReadLock(_stateLock, () => _context); @@ -135,17 +136,19 @@ public sealed class LdClient : ILdClient // private constructor prevents initialization of this class // without using WithConfigAnduser(config, user) - LdClient() { } + LdClient() + { + } LdClient(Configuration configuration, Context initialContext, TimeSpan startWaitTime) { _config = configuration ?? throw new ArgumentNullException(nameof(configuration)); - var baseContext = new LdClientContext(configuration, initialContext, this); + var baseContext = new LdClientContext(_config, initialContext, this); - var diagnosticStore = _config.DiagnosticOptOut ? null : - new ClientDiagnosticStore(baseContext, _config, startWaitTime); - var diagnosticDisabler = _config.DiagnosticOptOut ? null : - new DiagnosticDisablerImpl(); + var diagnosticStore = _config.DiagnosticOptOut + ? null + : new ClientDiagnosticStore(baseContext, _config, startWaitTime); + var diagnosticDisabler = _config.DiagnosticOptOut ? null : new DiagnosticDisablerImpl(); _clientContext = baseContext.WithDiagnostics(diagnosticDisabler, diagnosticStore); _log = _clientContext.BaseLogger; @@ -153,16 +156,26 @@ public sealed class LdClient : ILdClient _log.Info("Starting LaunchDarkly Client {0}", Version); - var persistenceConfiguration = (configuration.PersistenceConfigurationBuilder ?? Components.Persistence()) + var persistenceConfiguration = (_config.PersistenceConfigurationBuilder ?? Components.Persistence()) .Build(_clientContext); _dataStore = new FlagDataManager( - configuration.MobileKey, + _config.MobileKey, persistenceConfiguration, _log.SubLogger(LogNames.DataStoreSubLog) - ); + ); - _contextDecorator = new ContextDecorator(_dataStore.PersistentStore, configuration.GenerateAnonymousKeys); - _context = _contextDecorator.DecorateContext(initialContext); + _anonymousKeyContextDecorator = + new AnonymousKeyContextDecorator(_dataStore.PersistentStore, _config.GenerateAnonymousKeys); + var decoratedContext = _anonymousKeyContextDecorator.DecorateContext(initialContext); + + if (_config.AutoEnvAttributes) + { + _autoEnvContextDecorator = new AutoEnvContextDecorator(_dataStore.PersistentStore, + _clientContext.EnvironmentReporter, _log); + decoratedContext = _autoEnvContextDecorator.DecorateContext(decoratedContext); + } + + _context = decoratedContext; // If we had cached data for the new context, set the current in-memory flag data state to use // that data, so that any Variation calls made before Identify has completed will use the @@ -171,30 +184,31 @@ public sealed class LdClient : ILdClient if (cachedData != null) { _log.Debug("Cached flag data is available for this context"); - _dataStore.Init(_context, cachedData.Value, false); // false means "don't rewrite the flags to persistent storage" + _dataStore.Init(_context, cachedData.Value, + false); // false means "don't rewrite the flags to persistent storage" } var dataSourceUpdateSink = new DataSourceUpdateSinkImpl( _dataStore, - configuration.Offline, + _config.Offline, _taskExecutor, _log.SubLogger(LogNames.DataSourceSubLog) - ); + ); _dataSourceUpdateSink = dataSourceUpdateSink; _dataSourceStatusProvider = new DataSourceStatusProviderImpl(dataSourceUpdateSink); _flagTracker = new FlagTrackerImpl(dataSourceUpdateSink); - var dataSourceFactory = configuration.DataSource ?? Components.StreamingDataSource(); + var dataSourceFactory = _config.DataSource ?? Components.StreamingDataSource(); - _connectivityStateManager = Factory.CreateConnectivityStateManager(configuration); + _connectivityStateManager = Factory.CreateConnectivityStateManager(_config); var isConnected = _connectivityStateManager.IsConnected; - diagnosticDisabler?.SetDisabled(!isConnected || configuration.Offline); + diagnosticDisabler?.SetDisabled(!isConnected || _config.Offline); - _eventProcessor = (configuration.Events ?? Components.SendEvents()) + _eventProcessor = (_config.Events ?? Components.SendEvents()) .Build(_clientContext); - _eventProcessor.SetOffline(configuration.Offline || !isConnected); + _eventProcessor.SetOffline(_config.Offline || !isConnected); _connectionManager = new ConnectionManager( _clientContext, @@ -202,13 +216,13 @@ public sealed class LdClient : ILdClient _dataSourceUpdateSink, _eventProcessor, diagnosticDisabler, - configuration.EnableBackgroundUpdating, + _config.EnableBackgroundUpdating, _context, _log - ); - _connectionManager.SetForceOffline(configuration.Offline); + ); + _connectionManager.SetForceOffline(_config.Offline); _connectionManager.SetNetworkEnabled(isConnected); - if (configuration.Offline) + if (_config.Offline) { _log.Info("Starting LaunchDarkly client in offline mode"); } @@ -216,12 +230,12 @@ public sealed class LdClient : ILdClient _connectivityStateManager.ConnectionChanged += networkAvailable => { _log.Debug("Setting online to {0} due to a connectivity change event", networkAvailable); - _ = _connectionManager.SetNetworkEnabled(networkAvailable); // do not await the result + _ = _connectionManager.SetNetworkEnabled(networkAvailable); // do not await the result }; - + // Send an initial identify event, but only if we weren't explicitly set to be offline - if (!configuration.Offline) + if (!_config.Offline) { _eventProcessor.RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent { @@ -260,9 +274,10 @@ async Task StartAsync() /// an property of . /// /// - /// If you would rather this happen asynchronously, use . To + /// If you would rather this happen asynchronously, use + /// . To /// specify additional configuration options rather than just the mobile key, use - /// or . + /// or . /// /// /// You must use one of these static factory methods to instantiate the single instance of LdClient @@ -270,16 +285,24 @@ async Task StartAsync() /// /// /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// the initial evaluation context; see for more /// about setting the context and optionally requesting a unique key for it /// the maximum length of time to wait for the client to initialize /// the singleton instance - /// - /// - /// - public static LdClient Init(string mobileKey, Context initialContext, TimeSpan maxWaitTime) + /// + /// + /// + public static LdClient Init(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, + Context initialContext, TimeSpan maxWaitTime) { - var config = Configuration.Default(mobileKey); + var config = Configuration.Default(mobileKey, autoEnvAttributes); return Init(config, initialContext, maxWaitTime); } @@ -288,19 +311,28 @@ public static LdClient Init(string mobileKey, Context initialContext, TimeSpan m /// Creates a new singleton instance and attempts to initialize feature flags. /// /// - /// This is equivalent to , but using the + /// This is equivalent to + /// , but using the /// type instead of . /// /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// the initial user attributes; see for more /// about setting the context and optionally requesting a unique key for it /// the maximum length of time to wait for the client to initialize /// the singleton instance /// - /// - /// - public static LdClient Init(string mobileKey, User initialUser, TimeSpan maxWaitTime) => - Init(mobileKey, Context.FromUser(initialUser), maxWaitTime); + /// + /// + public static LdClient Init(string mobileKey, ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, + User initialUser, TimeSpan maxWaitTime) => + Init(mobileKey, autoEnvAttributes, Context.FromUser(initialUser), maxWaitTime); /// /// Creates a new singleton instance and attempts to initialize feature flags @@ -312,9 +344,10 @@ public static LdClient Init(string mobileKey, User initialUser, TimeSpan maxWait /// the LaunchDarkly service is returned (or immediately if it is in offline mode). /// /// - /// If you would rather this happen synchronously, use . To + /// If you would rather this happen synchronously, use + /// . To /// specify additional configuration options rather than just the mobile key, you can use - /// or . + /// or . /// /// /// You must use one of these static factory methods to instantiate the single instance of LdClient @@ -322,12 +355,20 @@ public static LdClient Init(string mobileKey, User initialUser, TimeSpan maxWait /// /// /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// the initial evaluation context; see for more /// about setting the context and optionally requesting a unique key for it /// a Task that resolves to the singleton LdClient instance - public static async Task InitAsync(string mobileKey, Context initialContext) + public static async Task InitAsync(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, Context initialContext) { - var config = Configuration.Default(mobileKey); + var config = Configuration.Default(mobileKey, autoEnvAttributes); return await InitAsync(config, initialContext); } @@ -337,17 +378,26 @@ public static async Task InitAsync(string mobileKey, Context initialCo /// asynchronously. /// /// - /// This is equivalent to , but using the + /// This is equivalent to + /// , but using the /// type instead of . /// /// the mobile key given to you by LaunchDarkly + /// Enable / disable Auto Environment Attributes functionality. When enabled, + /// the SDK will automatically provide data about the environment where the application is running. + /// This data makes it simpler to target your mobile customers based on application name or version, or on + /// device characteristics including manufacturer, model, operating system, locale, and so on. We recommend + /// enabling this when you configure the SDK. See + /// our documentation for + /// more details. /// the initial user attributes /// a Task that resolves to the singleton LdClient instance - public static Task InitAsync(string mobileKey, User initialUser) => - InitAsync(mobileKey, Context.FromUser(initialUser)); + public static Task InitAsync(string mobileKey, + ConfigurationBuilder.AutoEnvAttributes autoEnvAttributes, User initialUser) => + InitAsync(mobileKey, autoEnvAttributes, Context.FromUser(initialUser)); /// - /// Creates and returns a new LdClient singleton instance, then starts the workflow for + /// Creates and returns a new LdClient singleton instance, then starts the workflow for /// fetching Feature Flags. /// /// @@ -358,9 +408,10 @@ public static Task InitAsync(string mobileKey, User initialUser) => /// an property of . /// /// - /// If you would rather this happen asynchronously, use . + /// If you would rather this happen asynchronously, use . /// If you do not need to specify configuration options other than the mobile key, you can use - /// or . + /// or + /// . /// /// /// You must use one of these static factory methods to instantiate the single instance of LdClient @@ -375,8 +426,8 @@ public static Task InitAsync(string mobileKey, User initialUser) => /// an uninitialized state /// the singleton LdClient instance /// - /// - /// + /// + /// public static LdClient Init(Configuration config, Context initialContext, TimeSpan maxWaitTime) { if (maxWaitTime.Ticks < 0 && maxWaitTime != Timeout.InfiniteTimeSpan) @@ -390,11 +441,11 @@ public static LdClient Init(Configuration config, Context initialContext, TimeSp } /// - /// Creates and returns a new LdClient singleton instance, then starts the workflow for + /// Creates and returns a new LdClient singleton instance, then starts the workflow for /// fetching Feature Flags. /// /// - /// This is equivalent to , but using the + /// This is equivalent to , but using the /// type instead of . /// /// the client configuration @@ -403,8 +454,8 @@ public static LdClient Init(Configuration config, Context initialContext, TimeSp /// if this time elapses, the method will not throw an exception but will return the client in /// an uninitialized state /// the singleton LdClient instance - /// - /// + /// + /// /// public static LdClient Init(Configuration config, User initialUser, TimeSpan maxWaitTime) => Init(config, Context.FromUser(initialUser), maxWaitTime); @@ -419,9 +470,10 @@ public static LdClient Init(Configuration config, User initialUser, TimeSpan max /// the LaunchDarkly service is returned (or immediately if it is in offline mode). /// /// - /// If you would rather this happen synchronously, use . + /// If you would rather this happen synchronously, use . /// If you do not need to specify configuration options other than the mobile key, you can use - /// or . + /// or + /// . /// /// /// You must use one of these static factory methods to instantiate the single instance of LdClient @@ -433,8 +485,8 @@ public static LdClient Init(Configuration config, User initialUser, TimeSpan max /// about setting the context and optionally requesting a unique key for it /// a Task that resolves to the singleton LdClient instance /// - /// - /// + /// + /// public static async Task InitAsync(Configuration config, Context initialContext) { var c = CreateInstance(config, initialContext, TimeSpan.Zero); @@ -447,14 +499,14 @@ public static async Task InitAsync(Configuration config, Context initi /// asynchronously. /// /// - /// This is equivalent to , but using the + /// This is equivalent to , but using the /// type instead of . /// /// the client configuration /// the initial user attributes /// a Task that resolves to the singleton LdClient instance - /// - /// + /// + /// /// public static Task InitAsync(Configuration config, User initialUser) => InitAsync(config, Context.FromUser(initialUser)); @@ -490,61 +542,71 @@ public async Task SetOfflineAsync(bool value) /// public bool BoolVariation(string key, bool defaultValue = false) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, _eventFactoryDefault).Value; + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, + _eventFactoryDefault).Value; } /// public EvaluationDetail BoolVariationDetail(string key, bool defaultValue = false) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, _eventFactoryWithReasons); + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Bool, true, + _eventFactoryWithReasons); } /// public string StringVariation(string key, string defaultValue) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, _eventFactoryDefault).Value; + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, + _eventFactoryDefault).Value; } /// public EvaluationDetail StringVariationDetail(string key, string defaultValue) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, _eventFactoryWithReasons); + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.String, true, + _eventFactoryWithReasons); } /// public float FloatVariation(string key, float defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, _eventFactoryDefault).Value; + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, + _eventFactoryDefault).Value; } /// public EvaluationDetail FloatVariationDetail(string key, float defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, _eventFactoryWithReasons); + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Float, true, + _eventFactoryWithReasons); } /// public double DoubleVariation(string key, double defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, _eventFactoryDefault).Value; + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, + _eventFactoryDefault).Value; } /// public EvaluationDetail DoubleVariationDetail(string key, double defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, _eventFactoryWithReasons); + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Double, true, + _eventFactoryWithReasons); } /// public int IntVariation(string key, int defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, _eventFactoryDefault).Value; + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, _eventFactoryDefault) + .Value; } /// public EvaluationDetail IntVariationDetail(string key, int defaultValue = 0) { - return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, _eventFactoryWithReasons); + return VariationInternal(key, LdValue.Of(defaultValue), LdValue.Convert.Int, true, + _eventFactoryWithReasons); } /// @@ -559,7 +621,8 @@ public EvaluationDetail JsonVariationDetail(string key, LdValue default return VariationInternal(key, defaultValue, LdValue.Convert.Json, false, _eventFactoryWithReasons); } - EvaluationDetail VariationInternal(string featureKey, LdValue defaultJson, LdValue.Converter converter, bool checkType, EventFactory eventFactory) + EvaluationDetail VariationInternal(string featureKey, LdValue defaultJson, LdValue.Converter converter, + bool checkType, EventFactory eventFactory) { T defaultValue = converter.ToType(defaultJson); @@ -572,14 +635,16 @@ EvaluationDetail errorResult(EvaluationErrorKind kind) => if (!Initialized) { _log.Warn("LaunchDarkly client has not yet been initialized. Returning default value"); - SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, defaultJson, + SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, + defaultJson, EvaluationErrorKind.ClientNotReady)); return errorResult(EvaluationErrorKind.ClientNotReady); } else { _log.Info("Unknown feature flag {0}; returning default value", featureKey); - SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, defaultJson, + SendEvaluationEventIfOnline(eventFactory.NewUnknownFlagEvaluationEvent(featureKey, Context, + defaultJson, EvaluationErrorKind.FlagNotFound)); return errorResult(EvaluationErrorKind.FlagNotFound); } @@ -597,23 +662,28 @@ EvaluationDetail errorResult(EvaluationErrorKind kind) => if (flag.Value.IsNull) { valueJson = defaultJson; - result = new EvaluationDetail(defaultValue, flag.Variation, flag.Reason ?? EvaluationReason.OffReason); + result = new EvaluationDetail(defaultValue, flag.Variation, + flag.Reason ?? EvaluationReason.OffReason); } else { if (checkType && !defaultJson.IsNull && flag.Value.Type != defaultJson.Type) { valueJson = defaultJson; - result = new EvaluationDetail(defaultValue, null, EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); + result = new EvaluationDetail(defaultValue, null, + EvaluationReason.ErrorReason(EvaluationErrorKind.WrongType)); } else { valueJson = flag.Value; - result = new EvaluationDetail(converter.ToType(flag.Value), flag.Variation, flag.Reason ?? EvaluationReason.OffReason); + result = new EvaluationDetail(converter.ToType(flag.Value), flag.Variation, + flag.Reason ?? EvaluationReason.OffReason); } } + var featureEvent = eventFactory.NewEvaluationEvent(featureKey, flag, Context, - new EvaluationDetail(valueJson, flag.Variation, flag.Reason ?? EvaluationReason.OffReason), defaultJson); + new EvaluationDetail(valueJson, flag.Variation, flag.Reason ?? EvaluationReason.OffReason), + defaultJson); SendEvaluationEventIfOnline(featureEvent); return result; } @@ -631,6 +701,7 @@ public IDictionary AllFlags() { return ImmutableDictionary.Empty; } + return data.Value.Items.Where(entry => entry.Value.Item != null) .ToDictionary(p => p.Key, p => p.Value.Item.Value); } @@ -685,8 +756,15 @@ public bool Identify(Context context, TimeSpan maxWaitTime) /// public async Task IdentifyAsync(Context context) { - Context newContext = _contextDecorator.DecorateContext(context); - Context oldContext = newContext; // this initialization is overwritten below, it's only here to satisfy the compiler + Context newContext = _anonymousKeyContextDecorator.DecorateContext(context); + if (_config.AutoEnvAttributes) + { + newContext = _autoEnvContextDecorator.DecorateContext(newContext); + } + + Context + oldContext = + newContext; // this initialization is overwritten below, it's only here to satisfy the compiler LockUtils.WithWriteLock(_stateLock, () => { @@ -706,11 +784,12 @@ public async Task IdentifyAsync(Context context) { _log.Debug("Identify found cached flag data for the new context"); } + _dataStore.Init( newContext, cachedData ?? new DataStoreTypes.FullDataSet(null), false // false means "don't rewrite the flags to persistent storage" - ); + ); EventProcessorIfEnabled().RecordIdentifyEvent(new EventProcessorTypes.IdentifyEvent { diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.android.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.android.cs new file mode 100644 index 00000000..a4f71f11 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.android.cs @@ -0,0 +1,97 @@ +/* +Xamarin.Essentials + +The MIT License (MIT) + +Copyright (c) Microsoft Corporation + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +using System.Globalization; +using Android.Content; +using Android.Content.PM; +using Android.Content.Res; +using Android.Provider; +using LaunchDarkly.Sdk.EnvReporting; +#if __ANDROID_29__ +using AndroidX.Core.Content.PM; +#else +using Android.Support.V4.Content.PM; +#endif + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + static ApplicationInfo? PlatformGetApplicationInfo() => new ApplicationInfo( + PlatformGetAppId(), + PlatformGetAppName(), + PlatformGetAppVersion(), + PlatformGetAppVersionName()); + + // The following methods are added by LaunchDarkly to align with the Application Info + // required by the SDK. + static string PlatformGetAppId() => Platform.AppContext.PackageName; + static string PlatformGetAppName() => PlatformGetName(); + static string PlatformGetAppVersion() => PlatformGetBuild(); + static string PlatformGetAppVersionName() => PlatformGetVersionString(); + + // End LaunchDarkly additions. + + static string PlatformGetName() + { + var applicationInfo = Platform.AppContext.ApplicationInfo; + var packageManager = Platform.AppContext.PackageManager; + return applicationInfo.LoadLabel(packageManager); + } + + static string PlatformGetVersionString() + { + var pm = Platform.AppContext.PackageManager; + var packageName = Platform.AppContext.PackageName; +#pragma warning disable CS0618 // Type or member is obsolete + using (var info = pm.GetPackageInfo(packageName, PackageInfoFlags.MetaData)) +#pragma warning restore CS0618 // Type or member is obsolete + { + return info.VersionName; + } + } + + static string PlatformGetBuild() + { + var pm = Platform.AppContext.PackageManager; + var packageName = Platform.AppContext.PackageName; +#pragma warning disable CS0618 // Type or member is obsolete + using (var info = pm.GetPackageInfo(packageName, PackageInfoFlags.MetaData)) +#pragma warning restore CS0618 // Type or member is obsolete + { +#if __ANDROID_28__ + return PackageInfoCompat.GetLongVersionCode(info).ToString(CultureInfo.InvariantCulture); +#else +#pragma warning disable CS0618 // Type or member is obsolete + return info.VersionCode.ToString(CultureInfo.InvariantCulture); +#pragma warning restore CS0618 // Type or member is obsolete +#endif + } + } + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.ios.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.ios.cs new file mode 100644 index 00000000..1557e6dd --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.ios.cs @@ -0,0 +1,22 @@ +using Foundation; +#if __IOS__ || __TVOS__ +using UIKit; + +#elif __MACOS__ +using AppKit; +#endif + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + static ApplicationInfo? PlatformGetApplicationInfo() => new ApplicationInfo( + GetBundleValue("CFBundleIdentifier"), + GetBundleValue("CFBundleName"), + GetBundleValue("CFBundleVersion"), + GetBundleValue("CFBundleShortString")); + + static string GetBundleValue(string key) + => NSBundle.MainBundle.ObjectForInfoDictionary(key)?.ToString(); + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs new file mode 100644 index 00000000..c7e072b7 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.netstandard.cs @@ -0,0 +1,7 @@ +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + private static ApplicationInfo? PlatformGetApplicationInfo() => null; + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.shared.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.shared.cs new file mode 100644 index 00000000..1573db1b --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/AppInfo.shared.cs @@ -0,0 +1,7 @@ +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class AppInfo + { + internal static ApplicationInfo? GetAppInfo() => PlatformGetApplicationInfo(); + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.android.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.android.cs new file mode 100644 index 00000000..3212c1a4 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.android.cs @@ -0,0 +1,21 @@ +using Android.OS; +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + private static OsInfo? PlatformGetOsInfo() => + new OsInfo( + DevicePlatform.Android.ToString(), + DevicePlatform.Android.ToString()+Build.VERSION.SdkInt, + Build.VERSION.Release + ); + + private static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? PlatformGetDeviceInfo() => + new EnvReporting.LayerModels.DeviceInfo( + Build.Manufacturer, + Build.Model + ); + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.ios.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.ios.cs new file mode 100644 index 00000000..0e5158e9 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.ios.cs @@ -0,0 +1,29 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; +#if __WATCHOS__ +using WatchKit; +using UIDevice = WatchKit.WKInterfaceDevice; +#else +using UIKit; +#endif + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + + private static OsInfo? PlatformGetOsInfo() => + new OsInfo("Apple", GetPlatform().ToString(), UIDevice.CurrentDevice.SystemVersion); + + private static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? PlatformGetDeviceInfo() => + new EnvReporting.LayerModels.DeviceInfo( + "Apple", UIDevice.CurrentDevice.Model); + static DevicePlatform GetPlatform() => +#if __IOS__ + DevicePlatform.iOS; +#elif __TVOS__ + DevicePlatform.tvOS; +#elif __WATCHOS__ + DevicePlatform.watchOS; +#endif + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs new file mode 100644 index 00000000..80f12528 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.netstandard.cs @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + private static OsInfo? PlatformGetOsInfo() => null; + + private static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? PlatformGetDeviceInfo() => null; + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.shared.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.shared.cs new file mode 100644 index 00000000..4f4d267b --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DeviceInfo.shared.cs @@ -0,0 +1,11 @@ +using LaunchDarkly.Sdk.EnvReporting.LayerModels; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal static partial class DeviceInfo + { + internal static OsInfo? GetOsInfo() => PlatformGetOsInfo(); + + internal static LaunchDarkly.Sdk.EnvReporting.LayerModels.DeviceInfo? GetDeviceInfo() => PlatformGetDeviceInfo(); + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/DevicePlatform.shared.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DevicePlatform.shared.cs new file mode 100644 index 00000000..dc745bb6 --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/DevicePlatform.shared.cs @@ -0,0 +1,60 @@ +using System; + +namespace LaunchDarkly.Sdk.Client.PlatformSpecific +{ + internal readonly struct DevicePlatform : IEquatable + { + readonly string devicePlatform; + + public static DevicePlatform Android { get; } = new DevicePlatform(nameof(Android)); + + public static DevicePlatform iOS { get; } = new DevicePlatform(nameof(iOS)); + + public static DevicePlatform macOS { get; } = new DevicePlatform(nameof(macOS)); + + public static DevicePlatform tvOS { get; } = new DevicePlatform(nameof(tvOS)); + + public static DevicePlatform Tizen { get; } = new DevicePlatform(nameof(Tizen)); + + public static DevicePlatform UWP { get; } = new DevicePlatform(nameof(UWP)); + + public static DevicePlatform watchOS { get; } = new DevicePlatform(nameof(watchOS)); + + public static DevicePlatform Unknown { get; } = new DevicePlatform(nameof(Unknown)); + + DevicePlatform(string devicePlatform) + { + if (devicePlatform == null) + throw new ArgumentNullException(nameof(devicePlatform)); + + if (devicePlatform.Length == 0) + throw new ArgumentException(nameof(devicePlatform)); + + this.devicePlatform = devicePlatform; + } + + public static DevicePlatform Create(string devicePlatform) => + new DevicePlatform(devicePlatform); + + public bool Equals(DevicePlatform other) => + Equals(other.devicePlatform); + + internal bool Equals(string other) => + string.Equals(devicePlatform, other, StringComparison.Ordinal); + + public override bool Equals(object obj) => + obj is DevicePlatform && Equals((DevicePlatform)obj); + + public override int GetHashCode() => + devicePlatform == null ? 0 : devicePlatform.GetHashCode(); + + public override string ToString() => + devicePlatform ?? string.Empty; + + public static bool operator ==(DevicePlatform left, DevicePlatform right) => + left.Equals(right); + + public static bool operator !=(DevicePlatform left, DevicePlatform right) => + !left.Equals(right); + } +} diff --git a/src/LaunchDarkly.ClientSdk/PlatformSpecific/Platform.android.cs b/src/LaunchDarkly.ClientSdk/PlatformSpecific/Platform.android.cs index 7972425f..fad87cbe 100644 --- a/src/LaunchDarkly.ClientSdk/PlatformSpecific/Platform.android.cs +++ b/src/LaunchDarkly.ClientSdk/PlatformSpecific/Platform.android.cs @@ -87,7 +87,7 @@ internal static partial class Platform internal static bool HasApiLevel(BuildVersionCodes versionCode) => (int)Build.VERSION.SdkInt >= (int)versionCode; - + //internal static CameraManager CameraManager => // AppContext.GetSystemService(Context.CameraService) as CameraManager; diff --git a/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs b/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs index 57d28a51..eea983d7 100644 --- a/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs +++ b/src/LaunchDarkly.ClientSdk/Subsystems/LdClientContext.cs @@ -1,4 +1,5 @@ -using LaunchDarkly.Logging; +using System; +using LaunchDarkly.Logging; using LaunchDarkly.Sdk.Client.Interfaces; using LaunchDarkly.Sdk.Client.Internal; using LaunchDarkly.Sdk.Client.PlatformSpecific; @@ -95,7 +96,8 @@ public LdClientContext( ) { var logger = MakeLogger(configuration); - var environmentReporter = MakeEnvironmentReporter(configuration.ApplicationInfo); + var environmentReporter = MakeEnvironmentReporter(configuration); + MobileKey = configuration.MobileKey; BaseLogger = logger; CurrentContext = currentContext; @@ -228,16 +230,29 @@ internal static Logger MakeLogger(Configuration configuration) return logAdapter.Logger(logConfig.BaseLoggerName ?? LogNames.Base); } - internal static IEnvironmentReporter MakeEnvironmentReporter(ApplicationInfoBuilder applicationInfoBuilder) + internal static IEnvironmentReporter MakeEnvironmentReporter(Configuration configuration) { + var applicationInfoBuilder = configuration.ApplicationInfo; + var builder = new EnvironmentReporterBuilder(); if (applicationInfoBuilder != null) { var applicationInfo = applicationInfoBuilder.Build(); + + // If AppInfo is provided by the user, then the Config layer has first priority in the environment reporter. builder.SetConfigLayer(new ConfigLayerBuilder().SetAppInfo(applicationInfo).Build()); } + // Enable the platform layer if auto env attributes is opted in. + if (configuration.AutoEnvAttributes) + { + // The platform layer has second priority if properties aren't set by the Config layer. + builder.SetPlatformLayer(PlatformAttributes.Layer); + } + + // The SDK layer has third priority if properties aren't set by the Platform layer. builder.SetSdkLayer(SdkAttributes.Layer); + return builder.Build(); } } diff --git a/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs b/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs new file mode 100644 index 00000000..ff0b43aa --- /dev/null +++ b/src/LaunchDarkly.ClientSdk/Subsystems/PlatformAttributes.cs @@ -0,0 +1,21 @@ +using System.Globalization; +using LaunchDarkly.Sdk.Client.PlatformSpecific; +using LaunchDarkly.Sdk.EnvReporting; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + internal static class PlatformAttributes + { + internal static Layer Layer => new Layer( + AppInfo.GetAppInfo(), + DeviceInfo.GetOsInfo(), + DeviceInfo.GetDeviceInfo(), + // The InvariantCulture is default if none is set by the application. Microsoft says: + // "...it is associated with the English language but not with any country/region.." + // Source: https://learn.microsoft.com/en-us/dotnet/api/system.globalization.cultureinfo.invariantculture + // In order to avoid returning an empty string (their representation of InvariantCulture) as a context attribute, + // we will return "en" instead as the closest representation. + CultureInfo.CurrentCulture.Equals(CultureInfo.InvariantCulture) ? "en" : CultureInfo.CurrentCulture.ToString() + ); + } +} diff --git a/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs b/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs index 791d17f1..23a2d6f9 100644 --- a/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs +++ b/src/LaunchDarkly.ClientSdk/Subsystems/SdkAttributes.cs @@ -1,3 +1,4 @@ +using System.Globalization; using LaunchDarkly.Sdk.Client.Internal; using LaunchDarkly.Sdk.EnvReporting; @@ -10,6 +11,6 @@ internal static class SdkAttributes SdkPackage.Name, SdkPackage.Version, SdkPackage.Version), - null, null); + null, null, null); } } diff --git a/tests/LaunchDarkly.ClientSdk.Android.Tests/LaunchDarkly.ClientSdk.Android.Tests.csproj b/tests/LaunchDarkly.ClientSdk.Android.Tests/LaunchDarkly.ClientSdk.Android.Tests.csproj index ea6aef1d..b124682c 100644 --- a/tests/LaunchDarkly.ClientSdk.Android.Tests/LaunchDarkly.ClientSdk.Android.Tests.csproj +++ b/tests/LaunchDarkly.ClientSdk.Android.Tests/LaunchDarkly.ClientSdk.Android.Tests.csproj @@ -93,6 +93,7 @@ project file format, they need to be referenced explicitly even though they're in the project directory. --> + diff --git a/tests/LaunchDarkly.ClientSdk.Android.Tests/LdClientContextTests.cs b/tests/LaunchDarkly.ClientSdk.Android.Tests/LdClientContextTests.cs new file mode 100644 index 00000000..c7add35b --- /dev/null +++ b/tests/LaunchDarkly.ClientSdk.Android.Tests/LdClientContextTests.cs @@ -0,0 +1,56 @@ +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Subsystems +{ + /// + /// These tests are for the . Since some of the behavior is platform dependent + /// and the .NET Standard does not support some platform capabilities we want to test, this test class is + /// in the Android tests package. + /// + public class LdClientContextTests + { + [Fact] + public void TestMakeEnvironmentReporterUsesApplicationInfoWhenSet() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .ApplicationInfo( + Components.ApplicationInfo().ApplicationId("mockId").ApplicationName("mockName") + ).Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal("mockId", output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterDefaultsToSdkLayerWhenNothingSet() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal(SdkAttributes.Layer.ApplicationInfo?.ApplicationId, output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterUsesPlatformLayerWhenAutoEnvEnabled() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Enabled) + .Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.NotEqual(SdkAttributes.Layer.ApplicationInfo?.ApplicationId, output.ApplicationInfo?.ApplicationId); + } + + [Fact] + public void TestMakeEnvironmentReporterUsesApplicationInfoWhenSetAndAutoEnvEnabled() + { + var configuration = Configuration.Builder("aKey", ConfigurationBuilder.AutoEnvAttributes.Enabled) + .ApplicationInfo( + Components.ApplicationInfo().ApplicationId("mockId").ApplicationName("mockName") + ).Build(); + + var output = LdClientContext.MakeEnvironmentReporter(configuration); + Assert.Equal("mockId", output.ApplicationInfo?.ApplicationId); + } + } +} diff --git a/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs index f4490fb5..a0a03822 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/BaseTest.cs @@ -43,7 +43,7 @@ public void Dispose() // needed, protects against accidental interaction with external services and also makes it easier to // see which properties are important in a test. protected ConfigurationBuilder BasicConfig() => - Configuration.Builder(BasicMobileKey) + Configuration.Builder(BasicMobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled) .BackgroundModeManager(new MockBackgroundModeManager()) .ConnectivityStateManager(new MockConnectivityStateManager(true)) .DataSource(new MockDataSource().AsSingletonFactory()) diff --git a/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs index eb95f354..beae849e 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/ConfigurationTest.cs @@ -10,24 +10,27 @@ namespace LaunchDarkly.Sdk.Client public class ConfigurationTest : BaseTest { private readonly BuilderBehavior.BuildTester _tester = - BuilderBehavior.For(() => Configuration.Builder(mobileKey), b => b.Build()) + BuilderBehavior.For(() => Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled), + b => b.Build()) .WithCopyConstructor(c => Configuration.Builder(c)); const string mobileKey = "any-key"; - public ConfigurationTest(ITestOutputHelper testOutput) : base(testOutput) { } + public ConfigurationTest(ITestOutputHelper testOutput) : base(testOutput) + { + } [Fact] public void DefaultSetsKey() { - var config = Configuration.Default(mobileKey); + var config = Configuration.Default(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled); Assert.Equal(mobileKey, config.MobileKey); } [Fact] public void BuilderSetsKey() { - var config = Configuration.Builder(mobileKey).Build(); + var config = Configuration.Builder(mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled).Build(); Assert.Equal(mobileKey, config.MobileKey); } @@ -99,7 +102,8 @@ public void Logging() public void LoggingAdapterShortcut() { var adapter = Logs.ToWriter(Console.Out); - var config = Configuration.Builder("key").Logging(adapter).Build(); + var config = Configuration.Builder("key", ConfigurationBuilder.AutoEnvAttributes.Disabled).Logging(adapter) + .Build(); var logConfig = config.LoggingConfigurationBuilder.CreateLoggingConfiguration(); Assert.Same(adapter, logConfig.LogAdapter); } @@ -130,13 +134,15 @@ public void Persistence() [Fact] public void MobileKeyCannotBeNull() { - Assert.Throws(() => Configuration.Default(null)); + Assert.Throws(() => + Configuration.Default(null, ConfigurationBuilder.AutoEnvAttributes.Disabled)); } [Fact] public void MobileKeyCannotBeEmpty() { - Assert.Throws(() => Configuration.Default("")); + Assert.Throws(() => + Configuration.Default("", ConfigurationBuilder.AutoEnvAttributes.Disabled)); } } } diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs index b6bd55fa..906d8fcb 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataTest.cs @@ -21,7 +21,8 @@ public class TestDataTest : BaseTest public TestDataTest(ITestOutputHelper testOutput) : base(testOutput) { - _context = new LdClientContext(Configuration.Builder("key").Logging(testLogging).Build(), _initialUser); + _context = new LdClientContext(Configuration.Builder("key", ConfigurationBuilder.AutoEnvAttributes.Disabled) + .Logging(testLogging).Build(), _initialUser); } [Fact] diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs index aeeb9ad4..ff622c7a 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/Integrations/TestDataWithClientTest.cs @@ -12,7 +12,7 @@ public class TestDataWithClientTest : BaseTest public TestDataWithClientTest(ITestOutputHelper testOutput) : base(testOutput) { - _config = Configuration.Builder("mobile-key") + _config = Configuration.Builder("mobile-key", ConfigurationBuilder.AutoEnvAttributes.Disabled) .DataSource(_td) .Events(Components.NoEvents) .Build(); diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Internal/ContextDecoratorTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs similarity index 94% rename from tests/LaunchDarkly.ClientSdk.Tests/Internal/ContextDecoratorTest.cs rename to tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs index 908d732a..cdec94ae 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/Internal/ContextDecoratorTest.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/Internal/AnonymousKeyContextDecoratorTest.cs @@ -5,7 +5,7 @@ namespace LaunchDarkly.Sdk.Client.Internal { - public class ContextDecoratorTest : BaseTest + public class AnonymousKeyContextDecoratorTest : BaseTest { private static readonly ContextKind Kind1 = ContextKind.Of("kind1"); private static readonly ContextKind Kind2 = ContextKind.Of("kind2"); @@ -162,10 +162,10 @@ public void GeneratedKeysAreNotReusedAcrossRestartsIfPersistentStorageIsDisabled Assert.NotEqual(c2TransformedA.Key, c2TransformedB.Key); } - private ContextDecorator MakeDecoratorWithPersistence(IPersistentDataStore store, bool generateAnonymousKeys = false) => - new ContextDecorator(new PersistentDataStoreWrapper(store, BasicMobileKey, testLogger), generateAnonymousKeys); + private AnonymousKeyContextDecorator MakeDecoratorWithPersistence(IPersistentDataStore store, bool generateAnonymousKeys = false) => + new AnonymousKeyContextDecorator(new PersistentDataStoreWrapper(store, BasicMobileKey, testLogger), generateAnonymousKeys); - private ContextDecorator MakeDecoratorWithoutPersistence(bool generateAnonymousKeys = false) => + private AnonymousKeyContextDecorator MakeDecoratorWithoutPersistence(bool generateAnonymousKeys = false) => MakeDecoratorWithPersistence(new NullPersistentDataStore(), generateAnonymousKeys); private void AssertContextHasBeenTransformedWithNewKey(Context original, Context transformed) diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs b/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs new file mode 100644 index 00000000..e52bf1e2 --- /dev/null +++ b/tests/LaunchDarkly.ClientSdk.Tests/Internal/AutoEnvContextDecoratorTest.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using LaunchDarkly.Sdk.Client.Internal.DataStores; +using LaunchDarkly.Sdk.Client.Subsystems; +using LaunchDarkly.Sdk.EnvReporting; +using LaunchDarkly.Sdk.EnvReporting.LayerModels; +using Xunit; + +namespace LaunchDarkly.Sdk.Client.Internal +{ + public class AutoEnvContextDecoratorTest : BaseTest + { + [Fact] + public void AdheresToSchemaTest() + { + const string osFamily = "family_foo"; + const string osName = "name_bar"; + const string osVersion = null; + + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer) + .SetPlatformLayer(new Layer(null, new OsInfo(osFamily, osName, osVersion), null, null)).Build(); + + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder("aKey").Kind(ContextKind.Of("aKind")) + .Set("dontOverwriteMeBro", "really bro").Build(); + var output = decoratorUnderTest.DecorateContext(input); + + // Create the expected context after the code runs + // because there will be persistence side effects + var applicationKind = ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind); + var expectedApplicationKey = Base64.UrlSafeSha256Hash(envReporter.ApplicationInfo?.ApplicationId ?? ""); + var expectedAppContext = Context.Builder(applicationKind, expectedApplicationKey) + .Set(AutoEnvContextDecorator.EnvAttributesVersion, AutoEnvContextDecorator.SpecVersion) + .Set(AutoEnvContextDecorator.AttrId, SdkPackage.Name) + .Set(AutoEnvContextDecorator.AttrName, SdkPackage.Name) + .Set(AutoEnvContextDecorator.AttrVersion, SdkPackage.Version) + .Set(AutoEnvContextDecorator.AttrVersionName, SdkPackage.Version) + .Build(); + + var deviceKind = ContextKind.Of(AutoEnvContextDecorator.LdDeviceKind); + var expectedDeviceContext = Context.Builder(deviceKind, store.GetGeneratedContextKey(deviceKind)) + .Set(AutoEnvContextDecorator.EnvAttributesVersion, AutoEnvContextDecorator.SpecVersion) + .Set(AutoEnvContextDecorator.AttrOs, + LdValue.BuildObject().Set("family", osFamily).Set("name", osName).Build()).Build(); + + var expectedOutput = Context.MultiBuilder() + .Add(input) + .Add(expectedAppContext) + .Add(expectedDeviceContext) + .Build(); + + Assert.Equal(expectedOutput, output); + } + + [Fact] + public void CustomCultureInPlatformLayerIsPropagated() + { + var platform = new Layer(null, null, null, "en-GB"); + + var envReporter = new EnvironmentReporterBuilder().SetPlatformLayer(platform).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder("aKey").Kind(ContextKind.Of("aKind")).Build(); + var output = decoratorUnderTest.DecorateContext(input); + + + Context outContext; + Assert.True(output.TryGetContextByKind(new ContextKind(AutoEnvContextDecorator.LdApplicationKind), + out outContext)); + + Assert.Equal("en-GB", outContext.GetValue("locale").AsString); + } + + [Fact] + public void DoesNotOverwriteCustomerDataTest() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + var output = decoratorUnderTest.DecorateContext(input); + + var expectedOutput = Context.MultiBuilder().Add(input).Build(); + + Assert.Equal(expectedOutput, output); + } + + [Fact] + public void DoesNotOverwriteCustomerDataMultiContextTest() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input1 = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdApplicationKind), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + var input2 = Context.Builder(ContextKind.Of(AutoEnvContextDecorator.LdDeviceKind), "anotherKey") + .Set("AndDontOverwriteThisEither", "bro").Build(); + var multiContextInput = Context.MultiBuilder().Add(input1).Add(input2).Build(); + var output = decoratorUnderTest.DecorateContext(multiContextInput); + + // input and output should be the same + Assert.Equal(multiContextInput, output); + } + + [Fact] + public void GeneratesConsistentKeysAcrossMultipleCalls() + { + var envReporter = new EnvironmentReporterBuilder().SetSdkLayer(SdkAttributes.Layer).Build(); + var store = MakeMockDataStoreWrapper(); + var decoratorUnderTest = MakeDecoratorWithPersistence(store, envReporter); + + var input = Context.Builder(ContextKind.Of("aKind"), "aKey") + .Set("dontOverwriteMeBro", "really bro").Build(); + + var output1 = decoratorUnderTest.DecorateContext(input); + output1.TryGetContextByKind(ContextKind.Of("ld_application"), out var appContext1); + var key1 = appContext1.Key; + + var output2 = decoratorUnderTest.DecorateContext(input); + output2.TryGetContextByKind(ContextKind.Of("ld_application"), out var appContext2); + var key2 = appContext2.Key; + + Assert.Equal(key1, key2); + } + + private PersistentDataStoreWrapper MakeMockDataStoreWrapper() + { + return new PersistentDataStoreWrapper(new MockPersistentDataStore(), BasicMobileKey, testLogger); + } + + private AutoEnvContextDecorator MakeDecoratorWithPersistence(PersistentDataStoreWrapper store, + IEnvironmentReporter reporter) + { + return new AutoEnvContextDecorator(store, reporter, testLogger); + } + } +} diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs b/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs index 1e9d6d75..a0e55dcd 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/Internal/Base64Test.cs @@ -10,5 +10,13 @@ public void TestUrlSafeBase64Encode() Assert.Equal("eyJrZXkiOiJmb28-YmFyX18_In0=", Base64.UrlSafeEncode(@"{""key"":""foo>bar__?""}")); } + + [Fact] + public void TestUrlSafeSha256Hash() + { + var input = "OhYeah?HashThis!!!"; // hash is KzDwVRpvTuf//jfMK27M4OMpIRTecNcJoaffvAEi+as= and it has a + and a / + var expectedOutput = "KzDwVRpvTuf__jfMK27M4OMpIRTecNcJoaffvAEi-as="; + Assert.Equal(expectedOutput, Base64.UrlSafeSha256Hash(input)); + } } } diff --git a/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs b/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs index 9bfa878f..27b02681 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/Internal/DataSources/FeatureFlagRequestorTests.cs @@ -5,7 +5,6 @@ using LaunchDarkly.TestHelpers.HttpTest; using Xunit; using Xunit.Abstractions; - using static LaunchDarkly.TestHelpers.JsonAssertions; namespace LaunchDarkly.Sdk.Client.Internal.DataSources @@ -16,7 +15,9 @@ namespace LaunchDarkly.Sdk.Client.Internal.DataSources public class FeatureFlagRequestorTests : BaseTest { - public FeatureFlagRequestorTests(ITestOutputHelper testOutput) : base(testOutput) { } + public FeatureFlagRequestorTests(ITestOutputHelper testOutput) : base(testOutput) + { + } private const string _mobileKey = "FAKE_KEY"; @@ -26,7 +27,9 @@ public FeatureFlagRequestorTests(ITestOutputHelper testOutput) : base(testOutput // Note that in a real use case, the user encoding may vary depending on the target platform, because the SDK adds custom // user attributes like "os". But the lower-level FeatureFlagRequestor component does not do that. - private const string _allDataJson = "{}"; // Note that in this implementation, unlike the .NET SDK, FeatureFlagRequestor does not unmarshal the response + private const string + _allDataJson = + "{}"; // Note that in this implementation, unlike the .NET SDK, FeatureFlagRequestor does not unmarshal the response [Theory] [InlineData("", false, "/msdk/evalx/contexts/", "")] @@ -40,20 +43,20 @@ public async Task GetFlagsUsesCorrectUriAndMethodInGetModeAsync( bool withReasons, string expectedPathWithoutUser, string expectedQuery - ) + ) { using (var server = HttpServer.Start(Handlers.BodyJson(_allDataJson))) { var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); - var config = Configuration.Default(_mobileKey); + var config = Configuration.Default(_mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled); using (var requestor = new FeatureFlagRequestor( - baseUri, - _context, - withReasons, - new LdClientContext(config, _context).Http, - testLogger)) + baseUri, + _context, + withReasons, + new LdClientContext(config, _context).Http, + testLogger)) { var resp = await requestor.FeatureFlagsAsync(); Assert.Equal(200, resp.statusCode); @@ -61,7 +64,8 @@ string expectedQuery var req = server.Recorder.RequireRequest(); Assert.Equal("GET", req.Method); - AssertHelpers.ContextsEqual(_context, TestUtil.Base64ContextFromUrlPath(req.Path, expectedPathWithoutUser)); + AssertHelpers.ContextsEqual(_context, + TestUtil.Base64ContextFromUrlPath(req.Path, expectedPathWithoutUser)); Assert.Equal(expectedQuery, req.Query); Assert.Equal(_mobileKey, req.Headers["Authorization"]); Assert.Equal("", req.Body); @@ -83,22 +87,22 @@ public async Task GetFlagsUsesCorrectUriAndMethodInReportModeAsync( bool withReasons, string expectedPath, string expectedQuery - ) + ) { using (var server = HttpServer.Start(Handlers.BodyJson(_allDataJson))) { var baseUri = new Uri(server.Uri.ToString().TrimEnd('/') + baseUriExtraPath); - var config = Configuration.Builder(_mobileKey) + var config = Configuration.Builder(_mobileKey, ConfigurationBuilder.AutoEnvAttributes.Disabled) .Http(Components.HttpConfiguration().UseReport(true)) .Build(); using (var requestor = new FeatureFlagRequestor( - baseUri, - _context, - withReasons, - new LdClientContext(config, _context).Http, - testLogger)) + baseUri, + _context, + withReasons, + new LdClientContext(config, _context).Http, + testLogger)) { var resp = await requestor.FeatureFlagsAsync(); Assert.Equal(200, resp.statusCode); diff --git a/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs b/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs index f627a400..d432c0ad 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/LdClientTests.cs @@ -9,11 +9,11 @@ namespace LaunchDarkly.Sdk.Client { public class LdClientTests : BaseTest { - private static readonly Context AnonUser = Context.Builder("anon-placeholder-key") - .Anonymous(true) - .Set("email", "example") - .Set("other", 3) - .Build(); + private static readonly Context AnonUser = Context.Builder("anon-placeholder-key") + .Anonymous(true) + .Set("email", "example") + .Set("other", 3) + .Build(); public LdClientTests(ITestOutputHelper testOutput) : base(testOutput) { } @@ -54,87 +54,87 @@ public async void InitPassesUserToDataSource() Assert.Equal(BasicUser.Key, actualUser.Key); Assert.Equal(actualUser, dataSourceConfig.ReceivedClientContext.CurrentContext); } - } - - [Fact] - public async Task InitWithAnonUserAddsRandomizedKey() - { - // Note, we don't care about polling mode vs. streaming mode for this functionality. - var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); - - string key1; - - using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) - { - key1 = client.Context.Key; - Assert.NotNull(key1); - Assert.NotEqual("", key1); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key1).Build(), - client.Context); - } - - // Starting again should generate a new key, since we've turned off persistence - using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) - { - var key2 = client.Context.Key; - Assert.NotNull(key2); - Assert.NotEqual("", key2); - Assert.NotEqual(key1, key2); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key2).Build(), - client.Context); - } - } - - [Fact] - public async Task InitWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() - { - // Note, we don't care about polling mode vs. streaming mode for this functionality. - var config = BasicConfig().Persistence(Components.NoPersistence).Build(); - - using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) - { - AssertHelpers.ContextsEqual(AnonUser, client.Context); - } - } - - [Fact] - public async Task InitWithAnonUserCanReusePreviousRandomizedKey() - { - // Note, we don't care about polling mode vs. streaming mode for this functionality. - var store = new MockPersistentDataStore(); - var config = BasicConfig().Persistence(Components.Persistence().Storage( - store.AsSingletonFactory())) - .GenerateAnonymousKeys(true).Build(); - - string key1; - - using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) - { - key1 = client.Context.Key; - Assert.NotNull(key1); - Assert.NotEqual("", key1); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key1).Build(), - client.Context); - } - - // Starting again should reuse the persisted key - using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) - { - Assert.Equal(key1, client.Context.Key); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key1).Build(), - client.Context); - } - } - + } + + [Fact] + public async Task InitWithAnonUserAddsRandomizedKey() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + key1 = client.Context.Key; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + // Starting again should generate a new key, since we've turned off persistence + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + var key2 = client.Context.Key; + Assert.NotNull(key2); + Assert.NotEqual("", key2); + Assert.NotEqual(key1, key2); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key2).Build(), + client.Context); + } + } + + [Fact] + public async Task InitWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var config = BasicConfig().Persistence(Components.NoPersistence).Build(); + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + AssertHelpers.ContextsEqual(AnonUser, client.Context); + } + } + + [Fact] + public async Task InitWithAnonUserCanReusePreviousRandomizedKey() + { + // Note, we don't care about polling mode vs. streaming mode for this functionality. + var store = new MockPersistentDataStore(); + var config = BasicConfig().Persistence(Components.Persistence().Storage( + store.AsSingletonFactory())) + .GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + key1 = client.Context.Key; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + // Starting again should reuse the persisted key + using (var client = await TestUtil.CreateClientAsync(config, AnonUser)) + { + Assert.Equal(key1, client.Context.Key); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + } + [Fact] public async void InitWithAnonUserPassesGeneratedUserToDataSource() { - MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); - + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); var config = BasicConfig() .DataSource(dataSourceConfig) @@ -146,8 +146,48 @@ public async void InitWithAnonUserPassesGeneratedUserToDataSource() var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; Assert.NotEqual(AnonUser, receivedContext); Assert.Equal(client.Context, receivedContext); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(receivedContext.Key).Build(), + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.Key).Build(), + receivedContext); + } + } + + [Fact] + public async void InitWithAutoEnvAttributesEnabledAddAppInfoContext() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Enabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, AnonUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.True(receivedContext.TryGetContextByKind(ContextKind.Of("ld_application"), out _)); + } + } + + [Fact] + public async void InitWithAutoEnvAttributesDisabledNoAddedContexts() + { + MockPollingProcessor stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Disabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, AnonUser)) + { + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.Key).Build(), receivedContext); } } @@ -255,95 +295,95 @@ public async void IdentifyPassesUserToDataSource() AssertHelpers.ContextsEqual(newUser, client.Context); Assert.Equal(client.Context, receivedContext); } - } - - [Fact] - public async Task IdentifyWithAnonUserAddsRandomizedKey() - { - var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); - - string key1; - - using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) - { - await client.IdentifyAsync(AnonUser); - - key1 = client.Context.Key; - Assert.NotNull(key1); - Assert.NotEqual("", key1); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key1).Build(), - client.Context); - - var anonUser2 = TestUtil.BuildAutoContext().Name("other").Build(); - await client.IdentifyAsync(anonUser2); - var key2 = client.Context.Key; - Assert.Equal(key1, key2); // Even though persistence is disabled, the key is stable during the lifetime of the SDK client. - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(anonUser2).Key(key2).Build(), - client.Context); - } - - using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) - { - await client.IdentifyAsync(AnonUser); - - var key3 = client.Context.Key; - Assert.NotNull(key3); - Assert.NotEqual("", key3); - Assert.NotEqual(key1, key3); // The previously generated key was discarded with the previous client. - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key3).Build(), - client.Context); - } - } - - [Fact] - public async Task IdentifyWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() - { - var config = BasicConfig().Persistence(Components.NoPersistence).Build(); - - using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) - { - await client.IdentifyAsync(AnonUser); - - AssertHelpers.ContextsEqual(AnonUser, client.Context); - } - } - - [Fact] - public async Task IdentifyWithAnonUserCanReusePersistedRandomizedKey() - { - var store = new MockPersistentDataStore(); - var config = BasicConfig().Persistence(Components.Persistence().Storage( - store.AsSingletonFactory())) - .GenerateAnonymousKeys(true).Build(); - - string key1; - - using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) - { - await client.IdentifyAsync(AnonUser); - - key1 = client.Context.Key; - Assert.NotNull(key1); - Assert.NotEqual("", key1); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key1).Build(), - client.Context); - } - - using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) - { - await client.IdentifyAsync(AnonUser); - - var key2 = client.Context.Key; - Assert.Equal(key1, key2); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(key2).Build(), - client.Context); - } - } + } + + [Fact] + public async Task IdentifyWithAnonUserAddsRandomizedKey() + { + var config = BasicConfig().Persistence(Components.NoPersistence).GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + key1 = client.Context.Key; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + + var anonUser2 = TestUtil.BuildAutoContext().Name("other").Build(); + await client.IdentifyAsync(anonUser2); + var key2 = client.Context.Key; + Assert.Equal(key1, key2); // Even though persistence is disabled, the key is stable during the lifetime of the SDK client. + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(anonUser2).Key(key2).Build(), + client.Context); + } + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var key3 = client.Context.Key; + Assert.NotNull(key3); + Assert.NotEqual("", key3); + Assert.NotEqual(key1, key3); // The previously generated key was discarded with the previous client. + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key3).Build(), + client.Context); + } + } + + [Fact] + public async Task IdentifyWithAnonUserDoesNotChangeKeyIfConfigOptionIsNotSet() + { + var config = BasicConfig().Persistence(Components.NoPersistence).Build(); + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + AssertHelpers.ContextsEqual(AnonUser, client.Context); + } + } + + [Fact] + public async Task IdentifyWithAnonUserCanReusePersistedRandomizedKey() + { + var store = new MockPersistentDataStore(); + var config = BasicConfig().Persistence(Components.Persistence().Storage( + store.AsSingletonFactory())) + .GenerateAnonymousKeys(true).Build(); + + string key1; + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + key1 = client.Context.Key; + Assert.NotNull(key1); + Assert.NotEqual("", key1); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key1).Build(), + client.Context); + } + + using (var client = await TestUtil.CreateClientAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var key2 = client.Context.Key; + Assert.Equal(key1, key2); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(key2).Build(), + client.Context); + } + } [Fact] public async void IdentifyWithAnonUserPassesGeneratedUserToDataSource() @@ -363,8 +403,52 @@ public async void IdentifyWithAnonUserPassesGeneratedUserToDataSource() var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; Assert.NotEqual(AnonUser, receivedContext); Assert.Equal(client.Context, receivedContext); - AssertHelpers.ContextsEqual( - Context.BuilderFromContext(AnonUser).Key(client.Context.Key).Build(), + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(client.Context.Key).Build(), + receivedContext); + } + } + + [Fact] + public async void IdentifyWithAutoEnvAttributesEnabledAddsAppInfoContext() + { + var stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Enabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.True(receivedContext.TryGetContextByKind(ContextKind.Of("ld_application"), out _)); + } + } + + [Fact] + public async void IdentifyWithAutoEnvAttributesDisabledNoAddedContexts() + { + var stub = new MockPollingProcessor(DataSetBuilder.Empty); + var dataSourceConfig = new CapturingComponentConfigurer(stub.AsSingletonFactory()); + var config = BasicConfig() + .AutoEnvironmentAttributes(ConfigurationBuilder.AutoEnvAttributes.Disabled) + .DataSource(dataSourceConfig) + .GenerateAnonymousKeys(true) + .Build(); + + using (var client = await LdClient.InitAsync(config, BasicUser)) + { + await client.IdentifyAsync(AnonUser); + + var receivedContext = dataSourceConfig.ReceivedClientContext.CurrentContext; + Assert.NotEqual(AnonUser, receivedContext); + Assert.Equal(client.Context, receivedContext); + AssertHelpers.ContextsEqual( + Context.BuilderFromContext(AnonUser).Key(receivedContext.Key).Build(), receivedContext); } } @@ -421,8 +505,8 @@ public void FlagsAreLoadedFromPersistentStorageByDefault() var config = BasicConfig() .Persistence(Components.Persistence().Storage(storage.AsSingletonFactory())) .Offline(true) - .Build(); - storage.SetupUserData(config.MobileKey, BasicUser.Key, data); + .Build(); + storage.SetupUserData(config.MobileKey, BasicUser.Key, data); using (var client = TestUtil.CreateClient(config, BasicUser)) { diff --git a/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs b/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs index e332b024..de00e482 100644 --- a/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs +++ b/tests/LaunchDarkly.ClientSdk.Tests/TestUtil.cs @@ -6,7 +6,6 @@ using LaunchDarkly.Sdk.Internal; using LaunchDarkly.Sdk.Json; using Xunit; - using static LaunchDarkly.Sdk.Client.DataModel; using static LaunchDarkly.Sdk.Client.Subsystems.DataStoreTypes; @@ -20,7 +19,8 @@ public static class TestUtil private static ThreadLocal InClientLock = new ThreadLocal(); - public static LdClientContext SimpleContext => new LdClientContext(Configuration.Default("key"), Context.New("userkey")); + public static LdClientContext SimpleContext => new LdClientContext( + Configuration.Default("key", ConfigurationBuilder.AutoEnvAttributes.Enabled), Context.New("userkey")); public static Context Base64ContextFromUrlPath(string path, string pathPrefix) { @@ -42,6 +42,7 @@ public static T WithClientLock(Func f) { return f.Invoke(); } + ClientInstanceLock.Wait(); try { @@ -62,6 +63,7 @@ public static void WithClientLock(Action a) a.Invoke(); return; } + ClientInstanceLock.Wait(); try { @@ -81,6 +83,7 @@ public static async Task WithClientLockAsync(Func> f) { return await f.Invoke(); } + await ClientInstanceLock.WaitAsync(); try { @@ -124,10 +127,7 @@ public static async Task CreateClientAsync(Configuration config, Conte public static void ClearClient() { - WithClientLock(() => - { - LdClient.Instance?.Dispose(); - }); + WithClientLock(() => { LdClient.Instance?.Dispose(); }); } internal static string MakeJsonData(FullDataSet data) @@ -143,6 +143,7 @@ internal static string MakeJsonData(FullDataSet data) FeatureFlagJsonConverter.WriteJsonValue(item.Value.Item, w); } } + w.WriteEndObject(); }); }