Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions packages/core/lib/analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -353,18 +353,37 @@ class Analytics with ClientMethods {
}

Future _fetchSettings() async {
// Try to fetch settings from network
final settings =
await httpClient.settingsFor(state.configuration.state.writeKey);
if (settings == null) {
log("""Could not receive settings from Segment. ${state.configuration.state.defaultIntegrationSettings != null ? 'Will use the default settings.' : 'Device mode destinations will be ignored unless you specify default settings in the client config.'}""",
kind: LogFilterKind.warning);

state.integrations.state =
state.configuration.state.defaultIntegrationSettings ?? {};
} else {
if (settings != null) {
// Priority 1: Newly fetched settings from network
final integrations = settings.integrations;
log("Received settings from Segment succesfully.");
log("Received settings from Segment successfully.");
state.integrations.state = integrations;
return;
}

// Network fetch failed, check for cached settings
final cachedSettings = await state.integrations.loadCachedSettings();

if (cachedSettings.isNotEmpty) {
// Priority 2: Use cached settings if available
Comment on lines +371 to +372
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check cachedSettings.isNotEmpty is insufficient to determine if cached settings were loaded. The loadCachedSettings() method returns an empty map {} when no cached settings exist, and {}.isNotEmpty is false. However, if cached settings exist but are an empty map (e.g., {}), they would also fail this check. Consider checking if cachedSettings came from the store vs. the fallback, or use a nullable return type to distinguish between "no cache" and "empty cache".

Suggested change
if (cachedSettings.isNotEmpty) {
// Priority 2: Use cached settings if available
if (cachedSettings != null) {
// Priority 2: Use cached settings if available (even if empty)

Copilot uses AI. Check for mistakes.
log("Could not receive settings from Segment. Using cached settings.",
kind: LogFilterKind.warning);
// No need to set state.integrations.state as loadCachedSettings already updated it
} else if (state.configuration.state.defaultIntegrationSettings != null) {
// Priority 3: Fall back to default settings if no cache
log("Could not receive settings from Segment. Using default settings.",
kind: LogFilterKind.warning);
state.integrations.state =
state.configuration.state.defaultIntegrationSettings!;
} else {
// Priority 4: Last resort - empty map
log("Could not receive settings from Segment. Device mode destinations will not be used unless you specify default settings in the client config.",
kind: LogFilterKind.warning);
state.integrations.state = {};
}
}

Expand Down
40 changes: 36 additions & 4 deletions packages/core/lib/state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,19 @@ class StateManager {
deepLinkData.init(errorHandler, storageJson);
userInfo.init(errorHandler, storageJson);
context.init(errorHandler, storageJson);
integrations.init(errorHandler, storageJson);
}

StateManager(Store store, System system, Configuration configuration)
: system = SystemState(system),
configuration = ConfigurationState(configuration),
integrations = IntegrationsState({}),
integrations = IntegrationsState(store),
filters = FiltersState(store),
deepLinkData = DeepLinkDataState(store),
userInfo = UserInfoState(store),
context = ContextState(store, configuration) {
_ready = Future.wait<void>(
[filters.ready, deepLinkData.ready, userInfo.ready, context.ready])
[filters.ready, deepLinkData.ready, userInfo.ready, context.ready, integrations.ready])
.then((_) => _isReady = true);
}
}
Expand Down Expand Up @@ -500,19 +501,50 @@ class TransformerConfigMap {
}

class IntegrationsState extends StateNotifier<Map<String, dynamic>> {
IntegrationsState(super.integrations);
final Store _store;
final String _key = "integrations";
Map<String, dynamic>? _cachedState;

IntegrationsState(this._store) : super({});

@override
Map<String, dynamic> get state => super.state;

@override
set state(Map<String, dynamic> state) => super.state = state;
set state(Map<String, dynamic> newState) {
super.state = newState;
// Persist to store when state is updated
_store.setPersisted(_key, newState);
Comment on lines +514 to +517
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setPersisted call is not awaited, which could lead to race conditions. If the state is updated multiple times in quick succession, writes to the store could be lost or happen out of order. Consider either awaiting the persistence call or queuing writes to ensure data integrity. This is especially important since setPersisted returns a Future.

Copilot uses AI. Check for mistakes.
}

void addIntegration(String key, Map<String, dynamic> settings) {
final integrations = state;
integrations[key] = settings;
state = integrations;
Comment on lines 521 to 523
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The addIntegration method directly modifies the map reference obtained from state getter. This mutates the map without creating a new instance, which can lead to unexpected behavior with state notifications. Consider creating a new map: state = {...integrations, key: settings}; to ensure proper state change detection.

Suggested change
final integrations = state;
integrations[key] = settings;
state = integrations;
state = {
...state,
key: settings,
};

Copilot uses AI. Check for mistakes.
}

// Load cached settings from store
Future<Map<String, dynamic>> loadCachedSettings() async {
if (_cachedState != null) {
return _cachedState!;
}

final cachedSettings = await _store.getPersisted(_key);
if (cachedSettings != null) {
_cachedState = cachedSettings;
// Update in-memory state with cached settings
super.state = cachedSettings;
return cachedSettings;
}

return {};
}
Comment on lines +527 to +541
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadCachedSettings method has a race condition. If called concurrently before _cachedState is set, multiple callers will all fetch from the store and could set different values. Consider using a Future to ensure only one load operation happens at a time, similar to how PersistedState handles this with _getCompleter.

Copilot uses AI. Check for mistakes.

// For compatibility with the ready mechanism
Future<void> get ready => Future.value();
void init(ErrorHandler errorHandler, bool storageJson) {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The init method ignores the errorHandler and storageJson parameters. If these parameters are not needed, consider removing them from the signature, or document why they're unused. For consistency with other state classes, this method should either use these parameters or have a different signature.

Suggested change
void init(ErrorHandler errorHandler, bool storageJson) {
void init() {

Copilot uses AI. Check for mistakes.
loadCachedSettings();
Comment on lines +544 to +546
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadCachedSettings() call is not awaited in the init method. This means initialization will complete before cached settings are loaded, potentially causing a race condition where settings might not be available when expected. Consider making init async and awaiting the call, or ensure the ready mechanism properly waits for this operation.

Suggested change
Future<void> get ready => Future.value();
void init(ErrorHandler errorHandler, bool storageJson) {
loadCachedSettings();
Future<void> get ready => loadCachedSettings();
Future<void> init(ErrorHandler errorHandler, bool storageJson) async {
await loadCachedSettings();

Copilot uses AI. Check for mistakes.
}
}

class ConfigurationState extends StateNotifier<Configuration> {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/test/analytics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:shared_preferences/shared_preferences.dart';

import 'mocks/mocks.dart';
import 'mocks/mocks.mocks.dart';
import 'mocks/mock_store.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();
Expand Down Expand Up @@ -137,6 +138,125 @@ void main() {
);
expect(analytics, isA<Analytics>());
});

group("Integration Settings Persistence", () {
test("settings loading and fallback behavior", () async {
// Setup
final testSettings = {"test_integration": {"setting1": "value1"}};
final defaultSettings = {"default_integration": {"enabled": true}};
AnalyticsPlatform.instance = MockPlatform();
LogFactory.logger = Mocks.logTarget();
SharedPreferences.setMockInitialValues({});

// Test Case 1: First initialization with successful network fetch
// ---------------------------------------------------------------
// Create an in-memory store for testing
final mockStore1 = InMemoryStore(storageJson: true);

final httpClient1 = Mocks.httpClient();
when(httpClient1.settingsFor(any))
.thenAnswer((_) => Future.value(SegmentAPISettings(testSettings)));

// Initialize analytics
final analytics1 = Analytics(
Configuration("test_key"),
mockStore1,
httpClient: (_) => httpClient1,
);
await analytics1.init();

// Verify network settings are used
final stateSettings1 = await analytics1.state.integrations.state;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect use of await on a synchronous getter. analytics1.state.integrations.state returns a Map<String, dynamic> directly, not a Future. Remove the await keyword: final stateSettings1 = analytics1.state.integrations.state;

Suggested change
final stateSettings1 = await analytics1.state.integrations.state;
final stateSettings1 = analytics1.state.integrations.state;

Copilot uses AI. Check for mistakes.
expect(stateSettings1, equals(testSettings),
reason: "Should use settings from network");

// Test Case 2: Network failure with default settings
// --------------------------------------------------
// Create an in-memory store for testing
final mockStore2 = InMemoryStore(storageJson: true);

final failingHttpClient = Mocks.httpClient();
when(failingHttpClient.settingsFor(any))
.thenAnswer((_) => Future.value(null));

// Initialize analytics with default settings
final analytics2 = Analytics(
Configuration("test_key",
defaultIntegrationSettings: defaultSettings
),
mockStore2,
httpClient: (_) => failingHttpClient,
);
await analytics2.init();

// Verify default settings are used
final stateSettings2 = await analytics2.state.integrations.state;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect use of await on a synchronous getter. analytics2.state.integrations.state returns a Map<String, dynamic> directly, not a Future. Remove the await keyword: final stateSettings2 = analytics2.state.integrations.state;

Copilot uses AI. Check for mistakes.
expect(stateSettings2, equals(defaultSettings),
reason: "Should fall back to default settings when network fails");

// Test Case 3: Loading cached settings and refreshing from network
// ---------------------------------------------------------------
// Create an in-memory store with cached settings
final cachedSettings = {"cached_integration": {"setting1": "cached"}};
final mockStore3 = InMemoryStore(storageJson: true);

// Seed the store with cached settings
await mockStore3.setPersisted("integrations", cachedSettings);

// Create HTTP client that returns new settings
final newNetworkSettings = {"network_integration": {"setting1": "new"}};
final httpClient3 = Mocks.httpClient();
when(httpClient3.settingsFor(any))
.thenAnswer((_) => Future.value(SegmentAPISettings(newNetworkSettings)));

// Initialize analytics
final analytics3 = Analytics(
Configuration("test_key"),
mockStore3,
httpClient: (_) => httpClient3,
);

// Before initialization, state would be empty
expect(analytics3.state.integrations.hasListeners, isFalse);

// Initialize analytics
await analytics3.init();

// After initialization with cached settings + network fetch, we should have the network settings
final stateSettings3 = await analytics3.state.integrations.state;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect use of await on a synchronous getter. analytics3.state.integrations.state returns a Map<String, dynamic> directly, not a Future. Remove the await keyword: final stateSettings3 = analytics3.state.integrations.state;

Copilot uses AI. Check for mistakes.
expect(stateSettings3, equals(newNetworkSettings),
reason: "Should update cached settings with network settings");

// Test Case 4: Network failure with cached settings
// ------------------------------------------------
// Create an in-memory store with cached settings
final cachedSettings2 = {"cached_integration2": {"setting1": "cached2"}};
final mockStore4 = InMemoryStore(storageJson: true);

// Seed the store with cached settings
await mockStore4.setPersisted("integrations", cachedSettings2);

// Create failing HTTP client
final httpClient4 = Mocks.httpClient();
when(httpClient4.settingsFor(any))
.thenAnswer((_) => Future.value(null));

// Initialize analytics with default settings
final analytics4 = Analytics(
Configuration("test_key",
defaultIntegrationSettings: defaultSettings
),
mockStore4,
httpClient: (_) => httpClient4,
);
await analytics4.init();

// After initialization with network failure, we should have cached settings
final stateSettings4 = await analytics4.state.integrations.state;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect use of await on a synchronous getter. analytics4.state.integrations.state returns a Map<String, dynamic> directly, not a Future. Remove the await keyword: final stateSettings4 = analytics4.state.integrations.state;

Suggested change
final stateSettings4 = await analytics4.state.integrations.state;
final stateSettings4 = analytics4.state.integrations.state;

Copilot uses AI. Check for mistakes.
expect(stateSettings4, equals(cachedSettings2),
reason: "Should use cached settings when network fails, even with default settings");
});
});
});
}

Expand Down
140 changes: 140 additions & 0 deletions packages/core/test/cached_settings_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:segment_analytics/analytics.dart';
import 'package:segment_analytics/state.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/widgets.dart';
import 'package:segment_analytics/analytics_platform_interface.dart';
import 'package:segment_analytics/logger.dart';

import 'mocks/mocks.dart';
import 'mocks/mocks.mocks.dart';
import 'mocks/mock_store.dart';

// Test that verifies the priority order of settings:
// 1. Network settings
// 2. Cached settings
// 3. Default settings
// 4. Empty map
void main() {
WidgetsFlutterBinding.ensureInitialized();

group("Integration Settings Priority Test", () {
test("Settings priority order: network > cache > default > empty", () async {
// Setup
final networkSettings = {"network_integration": {"setting1": "network_value"}};
final cachedSettings = {"cached_integration": {"setting1": "cached_value"}};
final defaultSettings = {"default_integration": {"setting1": "default_value"}};

AnalyticsPlatform.instance = MockPlatform();
LogFactory.logger = Mocks.logTarget();
SharedPreferences.setMockInitialValues({});

// Test Case 1: First Initialization with Network Success
// ---------------------------------------------------------------
// Create an in-memory store with no cached settings
final mockStore1 = InMemoryStore(storageJson: true);

// Note: settings will be stored directly in the InMemoryStore

// Create HTTP client that returns network settings
final httpClient1 = Mocks.httpClient();
when(httpClient1.settingsFor(any))
.thenAnswer((_) => Future.value(SegmentAPISettings(networkSettings)));

// Initialize analytics
final analytics1 = Analytics(
Configuration("test_key",
defaultIntegrationSettings: defaultSettings
),
mockStore1,
httpClient: (_) => httpClient1,
);
await analytics1.init();

// Verify network settings are used in this session
final stateSettings1 = analytics1.state.integrations.state;
expect(stateSettings1, equals(networkSettings),
reason: "Should use settings from network on first run");

// Verify settings were persisted to storage
final storedSettings = await mockStore1.getPersisted("integrations");
expect(storedSettings, equals(networkSettings),
reason: "Network settings should be stored to cache");
Comment on lines +61 to +63
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test may be flaky due to the race condition in the IntegrationsState.state setter. The setPersisted call is not awaited, so the settings might not be persisted to storage yet when this assertion runs. Consider adding a small delay or awaiting the persistence to ensure the test is deterministic.

Copilot uses AI. Check for mistakes.

// Test Case 2: Network Failure with Cached Settings
// ---------------------------------------------------------------
// Create an in-memory store with cached settings
final mockStore2 = InMemoryStore(storageJson: true);

// Seed the store with cached settings
await mockStore2.setPersisted("integrations", networkSettings);

// Create HTTP client that fails
final failingHttpClient = Mocks.httpClient();
when(failingHttpClient.settingsFor(any))
.thenAnswer((_) => Future.value(null));

// Initialize analytics
final analytics2 = Analytics(
Configuration("test_key",
defaultIntegrationSettings: defaultSettings
),
mockStore2,
httpClient: (_) => failingHttpClient,
);
await analytics2.init();

// Verify cached settings are used when network fails
final stateSettings2 = analytics2.state.integrations.state;
expect(stateSettings2, equals(networkSettings),
reason: "Should use cached settings when network fails");

// Test Case 3: No Network, No Cache - Uses Default Settings
// ---------------------------------------------------------------
final mockStore3 = InMemoryStore(storageJson: true);

// No need to add any cached settings as the store starts empty

final failingHttpClient2 = Mocks.httpClient();
when(failingHttpClient2.settingsFor(any))
.thenAnswer((_) => Future.value(null));

final analytics3 = Analytics(
Configuration("test_key",
defaultIntegrationSettings: defaultSettings
),
mockStore3,
httpClient: (_) => failingHttpClient2,
);
await analytics3.init();

// Verify default settings are used when no network and no cache
final stateSettings3 = analytics3.state.integrations.state;
expect(stateSettings3, equals(defaultSettings),
reason: "Should use default settings when network fails and no cache");

// Test Case 4: No Network, No Cache, No Default - Uses Empty Map
// ---------------------------------------------------------------
final mockStore4 = InMemoryStore(storageJson: true);

// No need to add any cached settings as the store starts empty

final failingHttpClient3 = Mocks.httpClient();
when(failingHttpClient3.settingsFor(any))
.thenAnswer((_) => Future.value(null));

final analytics4 = Analytics(
Configuration("test_key"), // No default settings
mockStore4,
httpClient: (_) => failingHttpClient3,
);
await analytics4.init();

// Verify empty map is used as last resort
final stateSettings4 = analytics4.state.integrations.state;
expect(stateSettings4, equals({}),
reason: "Should use empty map when all else fails");
});
});
}
Loading
Loading