diff --git a/android/build.gradle b/android/build.gradle index a0078e33..424dfc3b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,6 +38,6 @@ android { } dependencies { - implementation 'ly.count.android:sdk:25.4.1' + implementation 'ly.count.android:sdk:25.4.2' implementation 'com.google.firebase:firebase-messaging:24.0.3' } diff --git a/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java b/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java index 7405a3a8..b9de3d41 100644 --- a/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java +++ b/android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java @@ -72,6 +72,7 @@ public class CountlyFlutterPlugin implements MethodCallHandler, FlutterPlugin, A private final String COUNTLY_FLUTTER_SDK_NAME_NO_PUSH = "dart-flutterbnp-android"; private final boolean BUILDING_WITH_PUSH_DISABLED = false; + private static final int DATA_SCHEMA_VERSIONS = 4; public void notifyPublicChannelRCDL(RequestResult downloadResult, String error, boolean fullValueUpdate, Map downloadedValues, Integer requestID) { Map data = new HashMap<>(); @@ -1408,6 +1409,26 @@ else if ("getRequestQueue".equals(call.method)) { CountlyStore countlyStore = new CountlyStore(context, new ModuleLog()); countlyStore.addRequest(args.getString(0), true); result.success("storeRequest: success"); + } else if ("setServerConfig".equals(call.method)) { + CountlyStore countlyStore = new CountlyStore(context, new ModuleLog()); + JSONObject jsonObject = args.getJSONObject(0); + countlyStore.setServerConfig(jsonObject.toString()); + // Why this added here, it is that because when it is set something in storage + // sdk assumes that it is an older version so it start migrations that needs to be done + // but in this case we only want to use setting server config and do not want migrations + // to mess up our process flow. So in here we are setting it to latest known to get away with it. + // Normally in a fresh install migrations are first to run and they run once. + countlyStore.setDataSchemaVersion(DATA_SCHEMA_VERSIONS); + result.success("setServerConfig: success"); + } else if ("getServerConfig".equals(call.method)) { + CountlyStore countlyStore = new CountlyStore(context, new ModuleLog()); + String sc = countlyStore.getServerConfig(); + Map serverConfigMap = new HashMap<>(); + try { + serverConfigMap = toMap(new JSONObject(sc)); + } catch (JSONException ignored) { + } + result.success(serverConfigMap); } else if ("addDirectRequest".equals(call.method)) { JSONObject jsonObject = args.getJSONObject(0); Map requestMap = new HashMap<>(); @@ -1420,7 +1441,10 @@ else if ("getRequestQueue".equals(call.method)) { } else if ("halt".equals(call.method)) { Countly.sharedInstance().halt(); result.success("halt: success"); - } else if ("enterContentZone".equals(call.method)) { + } + //------------------End------------------------------------ + + else if ("enterContentZone".equals(call.method)) { Countly.sharedInstance().contents().enterContentZone(); result.success(null); } else if ("exitContentZone".equals(call.method)) { @@ -1430,7 +1454,6 @@ else if ("getRequestQueue".equals(call.method)) { Countly.sharedInstance().contents().refreshContentZone(); result.success(null); } - //------------------End------------------------------------ else { result.notImplemented(); diff --git a/example/integration_test/sbs_tests/SBS_000_base_test.dart b/example/integration_test/sbs_tests/SBS_000_base_test.dart new file mode 100644 index 00000000..2c0033d5 --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_000_base_test.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +///This test calls all features possible +///It is base test, tries to show how features working without SBS and defaults +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_000_base', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, {}); + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + await Countly.initWithConfig(config); + + await callAllFeatures(); + + List RQ = await getRequestQueue(); + List EQ = await getEventQueue(); + expect(RQ.length, 0); + expect(EQ.length, 0); + validateRequestCounts({'events': 3, 'location': 1, 'crash': 2, 'begin_session': 1, 'consent': 0, 'end_session': 1, 'session_duration': 2, 'apm': 2, 'user_details': Platform.isIOS ? 2 : 1}, requestArray); + validateInternalEventCounts({'orientation': 1, 'view': 6, 'nps': 1}, requestArray); + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'ab': 1, 'ab_opt_out': 1, 'rc': 1}, requestArray); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_200A_test.dart b/example/integration_test/sbs_tests/SBS_200A_test.dart new file mode 100644 index 00000000..eada9b3f --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_200A_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_200A_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 5, 'lvs': 5, 'lsv': 5, 'lbc': 5, 'ltlpt': 5, 'ltl': 5, 'rcz': false, 'ecz': true, 'czi': 16, 'bom': false, 'dort': 1} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + storeRequest({'first': 'true', 'device_id': 'device_id_200C', 'app_key': APP_KEY, 'timestamp': DateTime.now().subtract(const Duration(minutes: 65)).millisecondsSinceEpoch.toString()}); + storeRequest({'second': 'true', 'device_id': 'device_id_200C', 'app_key': APP_KEY, 'timestamp': DateTime.now().subtract(const Duration(minutes: 45)).millisecondsSinceEpoch.toString()}); + + List>> RQ = await getRequestQueueParsed(); + validateRequestCounts({'first': 1, 'second': 1}, RQ); // validate that requests are stored correctly + + await callAllFeatures(disableEnterContent: true); + RQ = await getRequestQueueParsed(); + expect(RQ.length, 0); + + validateRequestCounts({'first': 0, 'second': 1, 'events': Platform.isAndroid ? 3 : 2, 'location': 1, 'crash': 2, 'begin_session': 1, 'end_session': 1, 'session_duration': 2, 'apm': 2, 'user_details': Platform.isIOS ? 2 : 1, 'consent': 0}, requestArray); + // validate that first request is deleted from the queue because of dort: 1 + validateInternalEventCounts({'orientation': 1, 'view': Platform.isAndroid ? 6 : 5, 'nps': 1}, requestArray); // 6 android + // enter content zone is not called, but a content zone request is sent it is because server config is set cz to true + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'ab': 1, 'ab_opt_out': 1, 'rc': 1}, requestArray); + + for (var queryParams in requestArray) { + if (queryParams.containsKey('method') || queryParams.containsKey('hc') || queryParams.containsKey('second')) { + continue; // skip immediate requests + } + testCommonRequestParams(queryParams); // checks general params + if (queryParams.containsKey('apm')) { + Map apm = json.decode(queryParams['apm']![0]); + expect(apm['name'].toString().length <= 5, isTrue); + } else if (queryParams.containsKey('crash')) { + Map crash = json.decode(queryParams['crash']![0]); + Map crashDetails = crash['_custom']; + expect(crashDetails.length <= 5, isTrue); + List logs = (crash['_logs'] as String).split('\n').where((line) => line.trim().isNotEmpty).toList(); + expect(logs.length <= 5, isTrue); + for (var log in logs) { + expect(log.length <= 5, isTrue); + } + // iOS crash limits are not applied to the stack trace + if (Platform.isAndroid) { + List stackTraces = crash['_error'].split('\n'); + for (var stackTrace in stackTraces) { + expect(stackTrace.length <= 5, isTrue); + } + } + + for (var key in crashDetails.keys) { + expect(key.length <= 5, isTrue); + expect(crashDetails[key].toString().length <= 5, isTrue); + } + } else if (queryParams.containsKey('events')) { + var eventRaw = json.decode(queryParams['events']![0]); + for (var event in eventRaw) { + validateInternalLimitsForEvents(event, 5, 5, 5); + } + } else if (queryParams.containsKey('user_details')) { + Map userDetails = json.decode(queryParams['user_details']![0]); + if (userDetails['custom'] != null && userDetails['custom'].length <= 2) { + // operators are not truncated with segmentation values limit + expect((userDetails['custom'].values.where((v) => v is! Map).length ?? 0) <= 5, isTrue); + expect(userDetails['custom']['speci'], 'somet'); + expect(userDetails['custom']['not_s'], 'somet'); + } + + // in iOS user data requests are formed in a different request + if (userDetails['custom'].length > 2) { + checkUnchangingUserData(userDetails, 5, 5); + } + + if (Platform.isAndroid || (Platform.isIOS && userDetails['custom'] == null)) { + checkUnchangingUserPropeties(userDetails, 5); + } + } + } + + await Countly.instance.content.refreshContentZone(); // this will not affect because refresh disabled + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'ab': 1, 'ab_opt_out': 1, 'rc': 1}, requestArray); + + await Countly.instance.content.exitContentZone(); + requestArray.clear(); + + sbsServerDelay = 11; + + await Countly.instance.sessions.beginSession(); + await Countly.instance.sessions.endSession(); // this will not be backed off because backoff disabled + await Future.delayed(const Duration(seconds: 10)); // wait for sdk to process and get the result from server + + await Countly.instance.attemptToSendStoredRequests(); // this will take affect and trigger sending the requests + await Future.delayed(const Duration(seconds: 2)); + + validateRequestCounts({'begin_session': 1, 'end_session': 1}, requestArray); + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 5, 'lvs': 5, 'lsv': 5, 'lbc': 5, 'ltlpt': 5, 'ltl': 5, 'rcz': false, 'ecz': true, 'czi': 16, 'bom': false, 'dort': 1} + }); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_200B_test.dart b/example/integration_test/sbs_tests/SBS_200B_test.dart new file mode 100644 index 00000000..7e1e499b --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_200B_test.dart @@ -0,0 +1,50 @@ +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +/// Currently it is not possible to test SCUI, we only test its value validations +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_200B_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'tracking': false, 'scui': 1} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await callAllFeatures(disableSend: true); + List RQ = await getRequestQueue(); + List EQ = await getEventQueue(); + expect(RQ.length, 0); + expect(EQ.length, 0); + + await Countly.instance.attemptToSendStoredRequests(); + // check queues are empty and all requests are sent + await Future.delayed(const Duration(seconds: 10)); + + validateRequestCounts({'events': 0, 'location': 0, 'crash': 0, 'begin_session': 0, 'end_session': 0, 'session_duration': 0, 'apm': 0, 'user_details': 0, 'consent': 0}, requestArray); + validateInternalEventCounts({}, requestArray); // 6 android + // enter content zone is not called, but a content zone request is sent it is because server config is set cz to true + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'rc': 1}, requestArray); // ab and ab_opt_out are not called because they are not immediate methods + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'tracking': false, 'scui': 1} + }); + + await Future.delayed(const Duration(seconds: 60)); + // wait one minute and ensure no sc requests sent + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 4, 'rc': 1}, requestArray); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_200C_test.dart b/example/integration_test/sbs_tests/SBS_200C_test.dart new file mode 100644 index 00000000..635d59a6 --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_200C_test.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +/// use auto sessions for showing session update +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_200C_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'networking': false, 'cr': true, 'rqs': 5, 'sui': 10} + }); + + setServerConfig({ + 'v': 1, + 't': 1750748806695, + 'c': {'networking': false, 'cr': true, 'rqs': 5, 'sui': 10} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).setLoggingEnabled(true).setDeviceId('device_id_200C'); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await callAllFeatures(disableConsentCall: true); + + // Validate that networking is disabled and no requests are sent + expect(requestArray.length, 1); // only SC request should be sent + validateImmediateCounts({'sc': 1}, requestArray); + requestArray.clear(); // clear requestArray to validate the next requests + + // Validate that consent is required and not given and all called features are not created a request + List>> rq = await getRequestQueueParsed(); + validateRequestCounts({'consent': 1, 'location': 1}, rq); + Map expectedConsent = {'push': false, 'views': false, 'attribution': false, 'content': false, 'users': false, 'feedback': false, 'apm': false, 'location': false, 'remote-config': false, 'sessions': false, 'crashes': false, 'events': false}; + + if (Platform.isAndroid) { + expectedConsent['scrolls'] = false; // Android has scrolls, content, star-rating, clicks consents extra + expectedConsent['content'] = false; + expectedConsent['star-rating'] = false; + expectedConsent['clicks'] = false; + } + + expect(jsonDecode(rq[0]['consent']![0]), expectedConsent); + expect(rq[1]['location']![0], ''); + expect(rq.length, 2); + + // Validate that session update occurs in every 10 seconds + await Countly.giveConsent(['sessions']); + // after giving this + // one consent, one begin session and two duration requests should be sent + // however this adds up to 6 request + // because our RQ limit is 5 the first consent request where all false is dropped + + await Future.delayed(const Duration(seconds: 25)); + rq = await getRequestQueueParsed(); + expect(rq.length, 5); // 5 request at max could be + expect(requestArray.length, 0); // none request sent after sc request + + validateRequestCounts({'begin_session': 1, 'session_duration': 2, 'consent': 1, 'location': 2}, rq); // one location is in begin_session + expect(rq[0]['location']![0], ''); // first request is location request from previous validations, it was consent request before but now location + + expect(rq[1]['begin_session']![0], '1'); // second request is begin session request from auto sessions + expect(rq[1]['location']![0], ''); // show location is disabled because no consent given with tied to session request + + expectedConsent['sessions'] = true; // now sessions consent is true + expect(jsonDecode(rq[2]['consent']![0]), expectedConsent); // second request is consent request + + expect(rq[3]['session_duration']![0], '5'); // fourth request is session duration request + expect(rq[4]['session_duration']![0], '10'); // fifth request is session duration request and it is 10 + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'networking': false, 'cr': true, 'rqs': 5, 'sui': 10} + }); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_200D_test.dart b/example/integration_test/sbs_tests/SBS_200D_test.dart new file mode 100644 index 00000000..dbdfdae4 --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_200D_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +///This test calls all features possible +///It is base test, tries to show how features working without SBS and defaults +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_200D_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'st': false, 'cet': false, 'vt': false, 'eqs': 5, 'lt': false, 'crt': false, 'bom_at': 5, 'bom_d': 30, 'bom_rqp': 0.001, 'bom_ra': 1} + }); + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + await Countly.initWithConfig(config); + + await callAllFeatures(); + + List RQ = await getRequestQueue(); + List EQ = await getEventQueue(); + expect(RQ.length, 0); + expect(EQ.length, 0); + validateRequestCounts({'events': 1, 'location': 1, 'crash': 0, 'begin_session': 0, 'consent': 0, 'end_session': 0, 'session_duration': 0, 'apm': 2, 'user_details': Platform.isIOS ? 2 : 1}, requestArray); + validateInternalEventCounts({'nps': 1}, requestArray); + validateImmediateCounts({'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'ab': 1, 'ab_opt_out': 1, 'rc': 1}, requestArray); + + recordReservedEvent('[CLY]_orientation', {'mode': 'portrait'}); + recordReservedEvent('[CLY]_orientation', {'mode': 'landscape'}); + recordReservedEvent('[CLY]_star_rating', {'platform': 'Web', 'app_version': '1.0', 'widget_id': 'starRatingID', 'closed': false, 'rating': 5, 'comment': 'Loved it!'}); + recordReservedEvent('[CLY]_star_rating', {'platform': 'Android', 'app_version': '1.0', 'widget_id': 'starRatingID', 'closed': false, 'rating': 3, 'comment': 'Meh'}); + EQ = await getEventQueue(); + expect(EQ.length, 4); + + recordReservedEvent('[CLY]_star_rating', {'platform': 'iOS', 'app_version': '1.0', 'widget_id': 'starRatingID', 'closed': false, 'rating': 1, 'comment': 'NO'}); + EQ = await getEventQueue(); + expect(EQ.length, 0); // validate that event queue is cleared when hit the limit and recording internal events are not affected by the custom event tracking disablement + await Future.delayed(const Duration(seconds: 2)); + + validateInternalEventCounts({'nps': 1, 'star_rating': 3, 'orientation': 2}, requestArray); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'st': false, 'cet': false, 'vt': false, 'eqs': 5, 'lt': false, 'crt': false, 'bom_at': 5, 'bom_d': 30, 'bom_rqp': 0.001, 'bom_ra': 1} + }); + + await Countly.instance.content.exitContentZone(); + requestArray.clear(); + + sbsServerDelay = 5; + storeRequest({'first': 'true', 'device_id': 'device_id_200C', 'app_key': APP_KEY, 'timestamp': DateTime.now().subtract(const Duration(minutes: 65)).millisecondsSinceEpoch.toString()}); // this will be not backed off because ra 1 + await Countly.recordNetworkTrace('Network Trace', 203, 123, 421, 542, 564); // this will be not backed off because rqp 0.001 + await Countly.recordNetworkTrace('Network Trace', 200, 500, 600, 100, 150); // backoff will trigger here + await Countly.recordNetworkTrace('Network Trace', 201, 350, 222, 333, 111); // this will be backed off for 30 seconds + await Countly.instance.attemptToSendStoredRequests(); + await Future.delayed(const Duration(seconds: 15)); + + validateRequestCounts({'apm': 2, 'first': 1}, requestArray); + await Countly.instance.attemptToSendStoredRequests(); // this will not take effect + await Future.delayed(const Duration(seconds: 5)); + validateRequestCounts({'apm': 2, 'first': 1}, requestArray); + + await Future.delayed(const Duration(seconds: 40)); + validateRequestCounts({'apm': 3, 'first': 1}, requestArray); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_202A_S_FS_test.dart b/example/integration_test/sbs_tests/SBS_202A_S_FS_test.dart new file mode 100644 index 00000000..4861d401 --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_202A_S_FS_test.dart @@ -0,0 +1,32 @@ +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_202A_S_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lvs': 'hoho', 'lsv': 'hehe', 'lbc': -5, 'ltlpt': 0, 'unkown': 'very_unkown', 'ltl': 0, 'rcz': 'no', 'ecz': 'no', 'czi': -16, 'bom': 'test', 'dort': false, 'tracking': 'no', 'scui': 0.1, 'networking': 'yes', 'cr': '', 'rqs': -5, 'sui': -10} + }); + + setServerConfig({ + 'v': 1, + 't': 1750748806695, + 'c': {'st': 'yes', 'cet': 'no', 'vt': 0, 'eqs': 0, 'lt': 1, 'unkown1': 'very_unkown1', 'crt': 'value', 'bom_at': -1, 'bom_d': -1, 'bom_rqp': 50, 'bom_ra': -1, 'lkl': 'test'} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + expect(await getServerConfig(), {'v': 1, 't': 1750748806695, 'c': {}}); + }); +} diff --git a/example/integration_test/sbs_tests/SBS_202B_P_FS_test.dart b/example/integration_test/sbs_tests/SBS_202B_P_FS_test.dart new file mode 100644 index 00000000..3e3043ff --- /dev/null +++ b/example/integration_test/sbs_tests/SBS_202B_P_FS_test.dart @@ -0,0 +1,27 @@ +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../utils.dart'; +import 'sbs_utils.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_202B_P_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': -1, + 't': -1750748806695, + 'c': {'lvs': 'hoho', 'lsv': 'hehe', 'lbc': -5, 'ltlpt': 0, 'unkown': 'very_unkown', 'ltl': 0, 'rcz': 'no', 'ecz': 'no', 'czi': -16, 'bom': 'test', 'dort': false, 'tracking': 'no', 'scui': 0.1, 'networking': 'yes', 'cr': '', 'rqs': -5, 'sui': -10} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + config.setSDKBehaviorSettings('{"c":{"st":"yes","cet":"no","vt":0,"eqs":0,"lt":1,"unkown1": "very_unkown1","crt":"value","bom_at":-1,"bom_d":-1,"bom_rqp":50,"bom_ra":-1,"lkl":"test"}}'); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + expect(await getServerConfig(), {'v': -1, 't': -1750748806695, 'c': {}}); + }); +} diff --git a/example/integration_test/sbs_tests/notes.md b/example/integration_test/sbs_tests/notes.md new file mode 100644 index 00000000..d43d0908 --- /dev/null +++ b/example/integration_test/sbs_tests/notes.md @@ -0,0 +1,170 @@ +SDK Behavior Settings tests + +And where to check values, which is affected: +from_server = FS +storage = S +provided = P +dev_provided = DP + +200X tests are feature validation tests +where +A = SDK internal limits + Content Zone + Content Zone Interval + Refresh Content Zone + Backoff Mechanism Enabled +B = Tracking + Server Config Update Interval - will not affect SC request +C = Networking + Consent + Request Queue + Session Update Interval + Drop old request - will not affect SC request +D = Session Tracking + Custom Event Tracking + Event Queue + View Tracking + Location Tracking + Crash Tracking + Backoff Configs + +Tests + +- A +Call all features +Provide SBS from server {'lkl': 5, 'lvs': 5, 'lsv': 5, 'lbc': 5, 'ltlpt': 5, 'ltl': 5, 'rcz': false, 'ecz': true, 'czi': 16, 'bom': false, 'dort': 1} +Change all SDK internal limits and validate that all are applied +Store couple of requests before starting the SDK and show that they are deleted by drop request age +Trigger two requests that their response duration is above 10 seconds +Validate that: +- content zone is called after init +- provided zone timer interval is not default one and 16 +- refresh content zone call is disabled +- backoff mechanism is disabled and two requests are passed +- validate the constraints for the backoff before sending requests + +- B +Call all features +Provide SBS from server {'tracking': false, 'scui': 1} +Validate that: +- No requests exist in the sent requestArray in mock server +- RQ is empty +- Only SBS requests are existing +- Validate that next SBS fetch called in 1 hours + +- C +Call all features +Provide SBS from storage {'networking': false, 'cr': true, 'rqs': 5, 'sui': 10} +Validate that: +- No requests exist in the sent requestArray in mock server +- RQ contains items +- Features that requires consent is not called and did not recorded things in RQ +- every 10 seconds session triggered + +- D +Call all features +Provide SBS from server {'st': false, 'cet': false, 'vt': false, 'eqs': 5, 'lt': false, 'crt': false, 'bom_at': 5, 'bom_d': 30, 'bom_rqp': 0.01, 'bom_ra': 1} +Validate that: +- Session, custom events, location, crashes, views are not recorded +- Internal events are recorded and clipped by EQ limit +- Views are not affected by custom event tracking +- Backoff mechanism configs are applied + +--------------------------------------------------------------------------------------------------------------------------------- + +201X tests order validation +where +- A +configure couple of internal limits in the CountlyConfig +Provide couple of internal limits in the provided SBS and return one internal limit from FS +Validate order is working and values applied +DP P FS Final +a a +b. b1 b1 +c. c1 c2 c2 + +- B +configure couple of internal limits in the CountlyConfig +Provide couple of internal limits in the stored SBS and return one internal limit from FS +Validate order is working and values applied +DP S FS Final +a a +b. b1 b1 +c. c1 c2 c2 + +- C +configure couple of internal limits in the CountlyConfig +Provide couple of internal limits in the provided SBS, stored SBS and return one internal limit from FS +Validate provided SBS is not applied because stored existing +Validate order is working and values applied +DP P S FS Final +a a +b. b1 b +c. c1 c2 c2 +d. d1 d2 d3. d3 + +- D +configure couple of internal limits in the CountlyConfig and enable temporary id mode +Provide couple of internal limits in the provided SBS, stored SBS and return one internal limit from FS +Validate provided SBS is not applied because stored existing +Validate order is working and values applied +Also validate FS not fetched because temporary id mode +DP P S FS Final +a a +b. b1 b +c. c1 c2 c2 +d. d1 d2 d3. d2 + +- E +configure couple of internal limits in the CountlyConfig and enable temporary id mode +Provide couple of internal limits in the provided SBS and return one internal limit from FS +Validate order is working and values applied +Validate FS not fetched because temporary id mode +DP P FS Final +a a +b. b1 b1 +c. c1 c2 c1 + +tests are: +- 201A_DP_P_FS +- 201B_DP_S_FS +- 201C_DP_P_S_FS +- 201D_DP_P_S_FS_temp_id +- 201E_DP_P_FS_temp_id + +--------------------------------------------------------------------------------------------------------------------------------- + +202X tests value validation where: +Provide SBS from server: +```json +{ + 'v': 1, + 't': 1750748806695, + 'c': {'lvs': 'hoho', 'lsv': 'hehe', 'lbc': -5, 'ltlpt': 0, 'unkown': 'very_unkown', 'ltl': 0, 'rcz': 'no', 'ecz': 'no', 'czi': -16, 'bom': 'test', 'dort': false, 'tracking': 'no', 'scui': 0.1, 'networking': 'yes', 'cr': '', 'rqs': -5, 'sui': -10} + } +``` + +- A +Store SBS: +```json +{ + 'v': 1, + 't': 1750748806695, + 'c': {'st': 'yes', 'cet': 'no', 'vt': 0, 'eqs': 0, 'unkown1': 'very_unkown1', 'lt': 1, 'crt': 'value', 'bom_at': -1, 'bom_d': -1, 'bom_rqp': 50, 'bom_ra': -1, 'lkl': 'test'} + } +``` +Validate that: +- Stored SBS at the end does not have any config values, only version and timestamp there. + +- B +Provide SBS through configuration: +```json +{ + 'v': 1, + 't': 1750748806695, + 'c': {'st': 'yes', 'cet': 'no', 'vt': 0, 'eqs': 0, 'unkown1': 'very_unkown1', 'lt': 1, 'crt': 'value', 'bom_at': -1, 'bom_d': -1, 'bom_rqp': 50, 'bom_ra': -1, 'lkl': 'test'} + } +``` +Validate that: +- Stored SBS at the end does not have any config values, only version and timestamp there. + +tests are: +- 202A_S_FS +- 202B_P_FS + +--------------------------------------------------------------------------------------------------------------------------------- +Notes iOS: +In the base test iOS required more time then Android at the end +Because there is a probability for iOS to duplicate requests, checking request counts were not good +getAvaliableFeedbackWidgets= if no consent it broken iOS +iOS crash limits not applied to the stack traces +because health checks one of the earlier ones, in 200C if it was FS heath checks was sent because it runs before we fetch SBS. +Android has scrolls, content, star-rating, clicks consents extra +iOS reports all widget events directly but not android, android does not send rating report event immediately + +validation things with base test \ No newline at end of file diff --git a/example/integration_test/sbs_tests/order_tests/SBS_201A_DP_P_FS_test.dart b/example/integration_test/sbs_tests/order_tests/SBS_201A_DP_P_FS_test.dart new file mode 100644 index 00000000..1af901a1 --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/SBS_201A_DP_P_FS_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../event_tests/event_utils.dart'; +import '../../utils.dart'; +import '../sbs_utils.dart'; + +/// Test records an event with a key and segmentation values that exceeds the maximum key length set by the SDK's internal limits server SBS limit. +/// - Key length limit is 3, value size is 5 and segmentation values 2 on DP +/// - key length 8, segmentation values 4 in P +/// - Only key length in FS is 8 +/// - The event is recorded with the key truncated to 8 #FS +/// - Values in segmentation are truncated to 5 characters #DP +/// - Segmentation values are truncated to 4 characters #P +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_201A_DP_P_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + config.setSDKBehaviorSettings('{"v":1,"t":1750748806695,"c":{"lkl":6, "lsv": 4}}'); + config.sdkInternalLimits.setMaxKeyLength(3).setMaxValueSize(5).setMaxSegmentationValues(2); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await Countly.instance.events.recordEvent('ThisWillCLIPPED_BY_FS', {'no1': 'valueCLIPPED_BY_DP', 'no2': 'valueCLIPPED_BY_DP', 'no3': 'valueCLIPPED_BY_DP', 'no4': 'valueCLIPPED_BY_DP', 'no5': 'valueCLIPPED_BY_DP'}); + List rq = await getRequestQueue(); + List eq = await getEventQueue(); + expect(rq.length, 0); + expect(eq.length, 1); + + validateEvent(event: jsonDecode(eq.first), key: 'ThisWill', segmentation: {'no1': 'value', 'no4': 'value', 'no3': 'value', 'no5': 'value'}); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8, 'lsv': 4} + }); + }); +} diff --git a/example/integration_test/sbs_tests/order_tests/SBS_201B_DP_S_FS_test.dart b/example/integration_test/sbs_tests/order_tests/SBS_201B_DP_S_FS_test.dart new file mode 100644 index 00000000..1adb19ff --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/SBS_201B_DP_S_FS_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../event_tests/event_utils.dart'; +import '../../utils.dart'; +import '../sbs_utils.dart'; + +/// Test records an event with a key and segmentation values that exceeds the maximum key length set by the SDK's internal limits server SBS limit. +/// - Key length limit is 3, value size is 5 and segmentation values 2 on DP +/// - key length 8, segmentation values 4 in S +/// - Only key length in FS is 8 +/// - The event is recorded with the key truncated to 8 #FS +/// - Values in segmentation are truncated to 5 characters #DP +/// - Segmentation values are truncated to 4 characters #S +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_201B_DP_S_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8} + }); + + setServerConfig({ + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 6, 'lsv': 4} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + config.sdkInternalLimits.setMaxKeyLength(3).setMaxValueSize(5).setMaxSegmentationValues(2); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await Countly.instance.events.recordEvent('ThisWillCLIPPED_BY_FS', {'no1': 'valueCLIPPED_BY_DP', 'no2': 'valueCLIPPED_BY_DP', 'no3': 'valueCLIPPED_BY_DP', 'no4': 'valueCLIPPED_BY_DP', 'no5': 'valueCLIPPED_BY_DP'}); + List rq = await getRequestQueue(); + List eq = await getEventQueue(); + expect(rq.length, 0); + expect(eq.length, 1); + + validateEvent(event: jsonDecode(eq.first), key: 'ThisWill', segmentation: {'no1': 'value', 'no4': 'value', 'no3': 'value', 'no5': 'value'}); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8, 'lsv': 4} + }); + }); +} diff --git a/example/integration_test/sbs_tests/order_tests/SBS_201C_DP_S_P_FS_test.dart b/example/integration_test/sbs_tests/order_tests/SBS_201C_DP_S_P_FS_test.dart new file mode 100644 index 00000000..31d95225 --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/SBS_201C_DP_S_P_FS_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../event_tests/event_utils.dart'; +import '../../utils.dart'; +import '../sbs_utils.dart'; + +/// Test records an event with a key and segmentation values that exceeds the maximum key length set by the SDK's internal limits server SBS limit. +/// - Key length limit is 3, value size is 5 and segmentation values 2 on DP +/// - key length 8, segmentation values 4 in S +/// - Only key length in FS is 8 +/// - Key length 10, value size 56 and breadcrumb count 99 in P +/// - The event is recorded with the key truncated to 8 #FS +/// - Values in segmentation are truncated to 5 characters #DP +/// - Segmentation values are truncated to 4 characters #S +/// - Provided SBS is omitted because already stored SBS +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_201C_DP_S_P_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8} + }); + + setServerConfig({ + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 6, 'lsv': 4} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true); + config.setSDKBehaviorSettings('{"v":1,"t":1750748806695,"c":{"lkl":10, "lsv": 56, "lbc": 99}}'); + config.sdkInternalLimits.setMaxKeyLength(3).setMaxValueSize(5).setMaxSegmentationValues(2); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await Countly.instance.events.recordEvent('ThisWillCLIPPED_BY_FS', {'no1': 'valueCLIPPED_BY_DP', 'no2': 'valueCLIPPED_BY_DP', 'no3': 'valueCLIPPED_BY_DP', 'no4': 'valueCLIPPED_BY_DP', 'no5': 'valueCLIPPED_BY_DP'}); + List rq = await getRequestQueue(); + List eq = await getEventQueue(); + expect(rq.length, 0); + expect(eq.length, 1); + + validateEvent(event: jsonDecode(eq.first), key: 'ThisWill', segmentation: {'no1': 'value', 'no4': 'value', 'no3': 'value', 'no5': 'value'}); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8, 'lsv': 4} + }); + }); +} diff --git a/example/integration_test/sbs_tests/order_tests/SBS_201D_DP_S_P_FS_temp_id_test.dart b/example/integration_test/sbs_tests/order_tests/SBS_201D_DP_S_P_FS_temp_id_test.dart new file mode 100644 index 00000000..3017c80b --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/SBS_201D_DP_S_P_FS_temp_id_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../event_tests/event_utils.dart'; +import '../../utils.dart'; +import '../sbs_utils.dart'; + +/// Test records an event with a key and segmentation values that exceeds the maximum key length set by the SDK's internal limits server SBS limit. +/// - Key length limit is 3, value size is 5 and segmentation values 2 on DP +/// - key length 8, segmentation values 4 in S +/// - Only key length in FS is 8 +/// - Key length 10, value size 56 and breadcrumb count 99 in P +/// - The event is recorded with the key truncated to 6 #S because of temporary id, sbs fetch did not sent +/// - Values in segmentation are truncated to 5 characters #DP +/// - Segmentation values are truncated to 4 characters #S +/// - Provided SBS is omitted because already stored SBS +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_201D_DP_S_P_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8} + }); + + setServerConfig({ + 'v': 1, + 't': 1750748806691, + 'c': {'lkl': 6, 'lsv': 4} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true).enableTemporaryDeviceIDMode(); + config.setSDKBehaviorSettings('{"v":1,"t":1750748806695,"c":{"lkl":10, "lsv": 56, "lbc": 99}}'); + config.sdkInternalLimits.setMaxKeyLength(3).setMaxValueSize(5).setMaxSegmentationValues(2); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await Countly.instance.events.recordEvent('ThisWillCLIPPED_BY_FS', {'no1': 'valueCLIPPED_BY_DP', 'no2': 'valueCLIPPED_BY_DP', 'no3': 'valueCLIPPED_BY_DP', 'no4': 'valueCLIPPED_BY_DP', 'no5': 'valueCLIPPED_BY_DP'}); + List rq = await getRequestQueue(); + List eq = await getEventQueue(); + expect(rq.length, 0); + expect(eq.length, 1); + + validateEvent(event: jsonDecode(eq.first), key: 'ThisWi', segmentation: {'no1': 'value', 'no4': 'value', 'no3': 'value', 'no5': 'value'}); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806691, + 'c': {'lkl': 6, 'lsv': 4} + }); + }); +} diff --git a/example/integration_test/sbs_tests/order_tests/SBS_201E_DP_P_FS_temp_id_test.dart b/example/integration_test/sbs_tests/order_tests/SBS_201E_DP_P_FS_temp_id_test.dart new file mode 100644 index 00000000..c97443f9 --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/SBS_201E_DP_P_FS_temp_id_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../../event_tests/event_utils.dart'; +import '../../utils.dart'; +import '../sbs_utils.dart'; + +/// Test records an event with a key and segmentation values that exceeds the maximum key length set by the SDK's internal limits server SBS limit. +/// - Key length limit is 3, value size is 5 and segmentation values 2 on DP +/// - Only key length in FS is 8 +/// - Key length 6, value size 4 and breadcrumb count 99 in P +/// - The event is recorded with the key truncated to 6 #P because of temporary id, sbs fetch did not sent +/// - Values in segmentation are truncated to 5 characters #DP +/// - Segmentation values are truncated to 4 characters #P +/// - Provided SBS is omitted because already stored SBS +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('SBS_201E_DP_P_FS_test', (WidgetTester tester) async { + List>> requestArray = >>[]; + createServerWithConfig(requestArray, { + 'v': 1, + 't': 1750748806695, + 'c': {'lkl': 8} + }); + + // Initialize the SDK + CountlyConfig config = CountlyConfig('http://0.0.0.0:8080', APP_KEY).enableManualSessionHandling().setLoggingEnabled(true).enableTemporaryDeviceIDMode(); + config.setSDKBehaviorSettings('{"v":1,"t":1750748806691,"c":{"lkl":6, "lsv": 4, "lbc": 99}}'); + config.sdkInternalLimits.setMaxKeyLength(3).setMaxValueSize(5).setMaxSegmentationValues(2); + + await Countly.initWithConfig(config); + await Future.delayed(const Duration(seconds: 2)); + + await Countly.instance.events.recordEvent('ThisWillCLIPPED_BY_FS', {'no1': 'valueCLIPPED_BY_DP', 'no2': 'valueCLIPPED_BY_DP', 'no3': 'valueCLIPPED_BY_DP', 'no4': 'valueCLIPPED_BY_DP', 'no5': 'valueCLIPPED_BY_DP'}); + List rq = await getRequestQueue(); + List eq = await getEventQueue(); + expect(rq.length, 0); + expect(eq.length, 1); + + validateEvent(event: jsonDecode(eq.first), key: 'ThisWi', segmentation: {'no1': 'value', 'no4': 'value', 'no3': 'value', 'no5': 'value'}); + + expect(await getServerConfig(), { + 'v': 1, + 't': 1750748806691, + 'c': {'lkl': 6, 'lsv': 4, 'lbc': 99} + }); + }); +} diff --git a/example/integration_test/sbs_tests/order_tests/notes.md b/example/integration_test/sbs_tests/order_tests/notes.md new file mode 100644 index 00000000..3375b0f1 --- /dev/null +++ b/example/integration_test/sbs_tests/order_tests/notes.md @@ -0,0 +1,20 @@ +Init time SBS + +| Set Config | Provided SBS | Stored SBS | Temp ID | Initial Behavior | +|------------|--------------|------------|---------|------------------| +| ● | | | | Dev Set Config | 201A +| ● | ● | | | Provided SBS | 201A +| ● | | ● | | Stored SBS | 201B +| ● | ● | ● | | Stored SBS | 201C +| ● | ● | ● | ● | Stored SBS | 201D +| ● | | ● | ● | Stored SBS | 201D +| ● | | | ● | Dev Set Config | 201D +| ● | ● | | ● | Provided SBS | 201E +| | ● | | | Provided SBS | 201A +| | ● | ● | | Stored SBS | 201C +| | ● | | ● | Provided SBS | 201E +| | ● | ● | ● | Stored SBS | 201D +| | | ● | ● | Stored SBS | 201D +| | | | ● | SDK Defaults | --- +| | | ● | | Stored SBS | 201B +| | | | | SDK Defaults | 000 diff --git a/example/integration_test/sbs_tests/sbs_utils.dart b/example/integration_test/sbs_tests/sbs_utils.dart new file mode 100644 index 00000000..e6c21a46 --- /dev/null +++ b/example/integration_test/sbs_tests/sbs_utils.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:countly_flutter/countly_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../event_tests/event_utils.dart'; +import '../utils.dart'; + +/// internal event key: [reserved segmentation keys : is it truncable] +/// For example mode in orientation is not truncable, but name in view is truncable +Map> reservedSegmentationKeys = { + '[CLY]_view': {'name': true, 'visit': false, 'start': false, 'segment': false}, + '[CLY]_orientation': {'mode': false}, + '[CLY]_nps': {'platform': false, 'app_version': false, 'widget_id': false, 'closed': false, 'rating': false, 'comment': false}, + '[CLY]_survey': {'platform': false, 'app_version': false, 'widget_id': false, 'closed': false}, + '[CLY]_star_rating': {'platform': false, 'app_version': false, 'widget_id': false, 'closed': false, 'rating': false, 'comment': false}, + '[CLY]_push_action': {'p': false, 'i': false, 'b': false}, + // '[CLY]_action': {} this is in android but not used, iOS does not have this +}; + +/// Validates the immediate counts in the request array. +/// This function checks the number of immediate methods recorded in the request array +/// and compares them with the expected counts provided in the `immediates` map. +/// It expects the keys in the `immediates` map to be the method names (like 'hc', 'sc', 'feedback', etc.) +/// and the values to be the expected counts of those methods. +/// @param immediates A map where keys are the names of the immediate methods and values are the expected counts. like {'hc': 1, 'sc': 1, 'feedback': 1, 'queue': 2, 'ab': 1, 'ab_opt_out': 1, 'rc': 1} +/// @param requestArray The array of requests to validate against. +void validateImmediateCounts(Map immediates, List>> requestArray) { + Map actualImmediates = {}; + + // key is method values are the switch cases + for (var request in requestArray) { + if (request.containsKey('method')) { + String method = request['method']![0]; + actualImmediates[method] = (actualImmediates[method] ?? 0) + 1; + } else if (request.containsKey('hc')) { + actualImmediates['hc'] = (actualImmediates['hc'] ?? 0) + 1; + } + } + + expect(actualImmediates.length, immediates.length, reason: 'Mismatch in number of immediate methods'); + // Validate the counts + for (var entry in immediates.entries) { + expect(actualImmediates[entry.key], entry.value, reason: 'Mismatch for method ${entry.key}'); + } +} + +/// Validates the internal event counts in the request array. +/// This function checks the number of internal events recorded in the request array +/// and compares them with the expected counts provided in the `internalEventsCounts` map. +/// It expects the keys in the `internalEventsCounts` map to be not prefixed with '[CLY]_'. +/// function also checks all internal events existence, so if it not exist it checks that given array length matches extracted internal event counts +/// The function will throw an error if the counts do not match. +/// @param internalEventsCounts A map where keys are the names of the internal events (without '[CLY]_') and values are the expected counts. like {'orientation': 1, 'view': 6} +/// @param requestArray The array of requests to validate against. +/// +void validateInternalEventCounts(Map internalEventsCounts, List>> requestArray) { + Map actualCounts = {}; + + // key is method values are the switch cases + for (var request in requestArray) { + if (request.containsKey('events')) { + List> events = (jsonDecode(request['events']![0]) as List).cast>(); + for (var event in events) { + if (event['key'].toString().startsWith('[CLY]')) { + actualCounts[event['key']] = (actualCounts[event['key']] ?? 0) + 1; + } + } + } + } + + expect(actualCounts.length, internalEventsCounts.length, reason: 'Mismatch in number of internal event methods actual: $actualCounts, expected: $internalEventsCounts'); + // Validate the counts + for (var entry in internalEventsCounts.entries) { + expect(actualCounts['[CLY]_${entry.key}'], entry.value, reason: 'Mismatch for method ${entry.key}'); + } +} + +/// Calls all features of Countly SDK to ensure they are working correctly. +/// This includes events, views, sessions, user location, user profile, crash, feedback widgets, remote config, A/B testing, consent, and content zone. +/// It also includes the things that are affected by the SDK internal limits, such as truncable events +/// This function is used in integration tests to validate the functionality of the Countly SDK with the SBS +/// At the end of the function, it triggers sending requests to the queue and waits for 10 seconds to ensure all requests are sent and queues are empty +Future callAllFeatures({bool disableEnterContent = false, bool disableSend = false, bool disableConsentCall = false}) async { + if (!disableConsentCall) { + await Countly.giveAllConsent(); + } + await Countly.getAvailableFeedbackWidgets(); + await Countly.instance.sessions.beginSession(); + await Countly.addCrashLog('First Breadcrumb'); // breadcrumb + await Countly.addCrashLog('Launched app'); // breadcrumb + await Countly.addCrashLog('Came to end'); // breadcrumb + await Countly.addCrashLog('Not done yet'); // breadcrumb + await Countly.addCrashLog('Will enter soon'); // breadcrumb + await createTruncableEvents(); + await generateEvents(); + await Countly.setUserLocation(countryCode: 'TR', city: 'Istanbul', gpsCoordinates: '41.0082,28.9784', ipAddress: '10.2.33.12'); + await Countly.instance.events.recordEvent('Event With Sum And Segment', {'Country': 'Turkey', 'Age': 28884}, 1, 0.99); // not legacy code + Map segmentation = { + 'country': 'Germany', + 'app_version': '1.0', + 'rating': 10, + 'precision': 324.54678, + 'timestamp': 1234567890, + 'clicked': false, + 'languages': ['en', 'de', 'fr'], + 'sub_names': ['John', 'Doe', 'Jane'] + }; + await Countly.instance.views.startView('Dashboard', segmentation); + + // IMMEDIATE CALLS + if (!disableEnterContent) { + await Countly.instance.content.enterContentZone(); + } + await Countly.instance.remoteConfig.downloadAllKeys((rResult, error, fullValueUpdate, downloadedValues) { + if (rResult == RequestResult.success) { + // do sth + } else { + // do sth + } + }); + + await Countly.instance.remoteConfig.enrollIntoABTestsForKeys(['key1', 'key2']); + await Countly.instance.remoteConfig.exitABTestsForKeys(['key1', 'key2']); + + // END IMMEDIATE CALLS + await Countly.reportFeedbackWidgetManually(CountlyPresentableFeedback('npsID', 'nps', 'NPS Feedback'), {}, {'rating': 5, 'comment': 'Great app!'}); + + await Future.delayed(const Duration(seconds: 2)); + await Countly.instance.sessions.updateSession(); + await Countly.instance.views.stopViewWithName('Dashboard'); + await Countly.instance.content.refreshContentZone(); + + await Future.delayed(const Duration(seconds: 2)); + await Countly.instance.sessions.endSession(); + if (disableSend) { + // if send is disabled, we will not send the requests to the server + return; + } + await Countly.instance.attemptToSendStoredRequests(); + // check queues are empty and all requests are sent + await Future.delayed(const Duration(seconds: 10)); +} + +/// Validates the request counts in the request array. +/// This function checks the number of requests for each method recorded in the request array +/// and compares them with the expected counts provided in the `requests` map. +/// It expects the keys in the `requests` map to be the method names (like 'events', 'location', 'crash', etc.) +/// and the values to be the expected counts of those methods. +/// @param requests A map where keys are the names of the request methods and values are the expected counts. like {'events': 2, 'location': 1, 'crash': 2, 'begin_session': 1, 'end_session': 1, 'session_duration': 2, 'apm': 2, 'user_details': 1} +/// @param requestArray The array of requests to validate against. +void validateRequestCounts(Map requests, List>> requestArray) { + Map actualRequests = {}; + + // key is method values are the switch cases + for (var request in requestArray) { + for (var entry in requests.entries) { + if (request.containsKey(entry.key)) { + actualRequests[entry.key] = (actualRequests[entry.key] ?? 0) + 1; + } + } + } + for (var entry in requests.entries) { + expect(actualRequests[entry.key] ?? 0, entry.value, reason: 'Mismatch for method ${entry.key}'); + } +} + +int sbsServerDelay = 0; + +/// Creates a server with a custom handler that responds to requests based on the provided configuration. +/// The server will respond with a JSON object containing the result of the request. +void createServerWithConfig(List>> requestArray, Map serverConfig) { + createServer(requestArray, customHandler: (request, queryParams, response) async { + Map responseJson = {'result': 'Success'}; + if (queryParams.containsKey('method')) { + if (queryParams['method']!.first == 'sc') { + responseJson = serverConfig; + } else if (queryParams['method']!.first == 'feedback') { + responseJson = { + 'result': [ + {'_id': 'npsID', 'type': 'nps', 'name': 'NPS Feedback'}, + {'_id': 'surveyID', 'type': 'survey', 'name': 'Survey Feedback'}, + {'_id': 'starID', 'type': 'rating', 'name': 'Star Rating Feedback'}, + ] + }; + } + } + + if (sbsServerDelay > 0 && !queryParams.containsKey('events')) { + await Future.delayed(Duration(seconds: sbsServerDelay)); + } + + response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..headers.set('Access-Control-Allow-Origin', '*') + ..write(jsonEncode(responseJson)); + }); +} + +/// Validates the internal limits for events based on the provided event data. +/// This function checks the key length, value size, and segmentation values +void validateInternalLimitsForEvents(Map event, int maxKeyLength, int maxValueSize, int maxSegmentationValues) { + // Validate key length + Map validationSetForKey = reservedSegmentationKeys[event['key']] ?? {}; + bool isReservedKey = validationSetForKey.isNotEmpty; + + if (!isReservedKey) { + // internal keys like '[CLY]_view' are not truncated + expect(event['key'].toString().length <= maxKeyLength, isTrue); + } + + if (event['segmentation'] != null) { + // Validate segmentation keys and values + Map segmentation = event['segmentation']; + expect(segmentation.length <= maxSegmentationValues + validationSetForKey.length, isTrue); + for (var key in segmentation.keys) { + bool checkValueSizeLimit = validationSetForKey[key] ?? true; + if (validationSetForKey[key] == null) { + expect(key.length <= maxKeyLength, isTrue); + } + + if (checkValueSizeLimit && segmentation[key] is String) { + expect(segmentation[key].toString().length <= maxValueSize, isTrue); + } + } + } +} + +/// Retrieves the request queue from the server. +/// And parses it into a list of maps with query parameters. +Future>>> getRequestQueueParsed() async { + List>> requestArray = >>[]; + List rq = await getRequestQueue(); + if (rq.isNotEmpty) { + requestArray = rq.map((item) { + Uri parsed = Uri.parse('https://count.ly?' + item); + return parsed.queryParametersAll; + }).toList(); + } + return requestArray; +} diff --git a/example/integration_test/utils.dart b/example/integration_test/utils.dart index e49b39f5..cf187712 100644 --- a/example/integration_test/utils.dart +++ b/example/integration_test/utils.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:countly_flutter/countly_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -28,11 +29,40 @@ Future> getEventQueue() async { /// Add request to native sides void storeRequest(Map request) async { - await _channelTest.invokeMethod('storeRequest', {'data': json.encode([Uri(queryParameters: request).query])}); + await _channelTest.invokeMethod('storeRequest', { + 'data': json.encode([Uri(queryParameters: request).query]) + }); } void addDirectRequest(Map request) async { - await _channelTest.invokeMethod('addDirectRequest', {'data': json.encode([request])}); + await _channelTest.invokeMethod('addDirectRequest', { + 'data': json.encode([request]) + }); +} + +void setServerConfig(Map serverConfig) async { + await _channelTest.invokeMethod('setServerConfig', { + 'data': json.encode([serverConfig]) + }); +} + +/// Retrieve the server configuration from the native side +Future> getServerConfig() async { + final Map sc = await _channelTest.invokeMethod('getServerConfig'); + return Map.from(sc); +} + +Future recordReservedEvent(String key, Map? segmentation) async { + if (Platform.isIOS) { + // iOS uses a different method for reserved events + List args = []; + + args.add(key); + args.add(segmentation); + await _channelTest.invokeMethod('recordReservedEvent', {'data': json.encode(args.where((item) => item != null).toList())}); + } else { + await Countly.instance.events.recordEvent(key, segmentation); + } } /// Verify the common request queue parameters @@ -123,8 +153,8 @@ var _serverDelay = 0; /// Start a server to receive the requests from the SDK and store them in a provided List. /// Use http://0.0.0.0:8080 as the server url. /// You can specify a delay in seconds for the server response. -/// You can also provide a custom handler for the server response. -void createServer(List>> requestArray, {int delay = 0, Future Function(HttpRequest, HttpResponse)? customHandler}) async { +/// You can also provide a custom handler for the server response. It takes the HttpRequest, query parameters, and HttpResponse as arguments. +void createServer(List>> requestArray, {int delay = 0, Future Function(HttpRequest, Map>, HttpResponse)? customHandler}) async { var server = await HttpServer.bind(InternetAddress.anyIPv4, 8080); print('[Test Server]Server running on http://${server.address.address}:${server.port}'); _serverDelay = delay; @@ -132,14 +162,19 @@ void createServer(List>> requestArray, {int delay = 0, final requestTime = DateTime.now(); print('[Test Server][${requestTime.toIso8601String()}] Request received: ${request.method} ${request.uri}'); - final queryParams = request.uri.queryParametersAll; - print(queryParams.toString()); + var queryParams = request.uri.queryParametersAll; + + if (request.method == 'POST') { + final body = await utf8.decodeStream(request); + queryParams = Uri.parse('?$body').queryParametersAll; // Update queryParams with POST body + } + print('[Test Server] queryParams: ${queryParams.toString()}'); // Store the request parameters for later verification requestArray.add(queryParams); if (customHandler != null) { - await customHandler(request, request.response); + await customHandler(request, queryParams, request.response); } else { if (_serverDelay > 0) { print('[Test Server][${DateTime.now().toIso8601String()}] Applying delay of ${_serverDelay} seconds'); @@ -376,9 +411,10 @@ void checkUnchangingUserData(userDetails, MAX_KEY_LENGTH, MAX_VALUE_SIZE) { } /// Truncate a string to a given limit -String truncate(string, limit) { - var length = string.length; +String truncate(String string, int limit) { + int length = string.length; limit = limit != null ? limit : length; + limit = min(length, limit); return string.substring(0, limit); } diff --git a/ios/Classes/CountlyFlutterPlugin.m b/ios/Classes/CountlyFlutterPlugin.m index f2788107..7da2b371 100644 --- a/ios/Classes/CountlyFlutterPlugin.m +++ b/ios/Classes/CountlyFlutterPlugin.m @@ -144,6 +144,42 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [Countly.sharedInstance addDirectRequest:requestMap]; result(@"added request to queue"); }); + } else if ([@"setServerConfig" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableDictionary *serverConfig = [command objectAtIndex:0]; + [NSUserDefaults.standardUserDefaults setObject:serverConfig forKey:@"kCountlyServerConfigPersistencyKey"]; + [NSUserDefaults.standardUserDefaults synchronize]; + result(@"setServerConfig: success"); + }); + }else if ([@"getServerConfig" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSDictionary *storedConfig = [NSUserDefaults.standardUserDefaults objectForKey:@"kCountlyServerConfigPersistencyKey"]; + + NSMutableDictionary *serverConfig = nil; + if ([storedConfig isKindOfClass:[NSDictionary class]]) { + serverConfig = [storedConfig mutableCopy]; + } else { + serverConfig = [NSMutableDictionary new]; + } + + result(serverConfig); + }); + } else if ([@"recordReservedEvent" isEqualToString:call.method]) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *key = [command objectAtIndex:0]; + NSDictionary *segmentation; + if ((int)command.count > 1) { + segmentation = [command objectAtIndex:1]; + } else { + segmentation = nil; + } + + [[Countly sharedInstance] recordReservedEvent:key segmentation:segmentation]; + + NSString *resultString = @"recordReservedEvent for: "; + resultString = [resultString stringByAppendingString:key]; + result(resultString); + }); } else if ([@"recordEvent" isEqualToString:call.method]) { dispatch_async(dispatch_get_main_queue(), ^{ NSString *key = [command objectAtIndex:0]; diff --git a/ios/Classes/CountlyiOS/CHANGELOG.md b/ios/Classes/CountlyiOS/CHANGELOG.md index e89b332d..40951ae7 100644 --- a/ios/Classes/CountlyiOS/CHANGELOG.md +++ b/ios/Classes/CountlyiOS/CHANGELOG.md @@ -1,3 +1,10 @@ +## XX.XX.XX +* Mitigated an issue where the SDK didn't apply the stored SBS while in temporary ID mode. + +## 25.4.3 +* Mitigated an issue where SDK behavior settings were set to default when fetching for new config. +* Mitigated an issue where latest fetched behavior settings were replacing the current settings instead of merging. + ## 25.4.2 * Added fullscreen support for feedback widgets. * Added "disableSDKBehaviorSettingsUpdates" init config parameter to disable server config updates. diff --git a/ios/Classes/CountlyiOS/Countly-PL.podspec b/ios/Classes/CountlyiOS/Countly-PL.podspec index e3822374..990bea43 100644 --- a/ios/Classes/CountlyiOS/Countly-PL.podspec +++ b/ios/Classes/CountlyiOS/Countly-PL.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly-PL' - s.version = '25.4.2' + s.version = '25.4.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/Classes/CountlyiOS/Countly.m b/ios/Classes/CountlyiOS/Countly.m index dda8eda8..b8987bc1 100644 --- a/ios/Classes/CountlyiOS/Countly.m +++ b/ios/Classes/CountlyiOS/Countly.m @@ -95,7 +95,7 @@ - (void)startWithConfig:(CountlyConfig *)config if (config.disableSDKBehaviorSettingsUpdates) { [CountlyServerConfig.sharedInstance disableSDKBehaviourSettings]; } - [CountlyServerConfig.sharedInstance retrieveServerConfigFromStorage:config.sdkBehaviorSettings]; + [CountlyServerConfig.sharedInstance retrieveServerConfigFromStorage:config]; CountlyCommon.sharedInstance.maxKeyLength = config.sdkInternalLimits.getMaxKeyLength; CountlyCommon.sharedInstance.maxValueLength = config.sdkInternalLimits.getMaxValueSize; diff --git a/ios/Classes/CountlyiOS/Countly.podspec b/ios/Classes/CountlyiOS/Countly.podspec index 17bd75f0..9def31c1 100644 --- a/ios/Classes/CountlyiOS/Countly.podspec +++ b/ios/Classes/CountlyiOS/Countly.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Countly' - s.version = '25.4.2' + s.version = '25.4.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.' s.homepage = 'https://github.com/Countly/countly-sdk-ios' diff --git a/ios/Classes/CountlyiOS/CountlyCommon.m b/ios/Classes/CountlyiOS/CountlyCommon.m index e9f8bd45..63c99d42 100644 --- a/ios/Classes/CountlyiOS/CountlyCommon.m +++ b/ios/Classes/CountlyiOS/CountlyCommon.m @@ -29,7 +29,7 @@ @interface CountlyCommon () #endif @end -NSString* const kCountlySDKVersion = @"25.4.2"; +NSString* const kCountlySDKVersion = @"25.4.3"; NSString* const kCountlySDKName = @"objc-native-ios"; NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain"; diff --git a/ios/Classes/CountlyiOS/CountlyFeedbacksInternal.m b/ios/Classes/CountlyiOS/CountlyFeedbacksInternal.m index 6b320811..71a9e46a 100644 --- a/ios/Classes/CountlyiOS/CountlyFeedbacksInternal.m +++ b/ios/Classes/CountlyiOS/CountlyFeedbacksInternal.m @@ -416,14 +416,26 @@ - (void)getFeedbackWidgets:(void (^)(NSArray *feedback if (!CountlyServerConfig.sharedInstance.networkingEnabled) { CLY_LOG_D(@"'getFeedbackWidgets' is aborted: SDK Networking is disabled from server config!"); + if(completionHandler){ + completionHandler(nil, nil); + } return; } - if (!CountlyConsentManager.sharedInstance.consentForFeedback) + if (!CountlyConsentManager.sharedInstance.consentForFeedback) { + if(completionHandler){ + completionHandler(nil, nil); + } return; + } + - if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) + if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { + if(completionHandler){ + completionHandler(nil, nil); + } return; + } NSURLSessionTask* task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self feedbacksRequest] completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) { diff --git a/ios/Classes/CountlyiOS/CountlyHealthTracker.m b/ios/Classes/CountlyiOS/CountlyHealthTracker.m index 1ee68a53..89fc18b8 100644 --- a/ios/Classes/CountlyiOS/CountlyHealthTracker.m +++ b/ios/Classes/CountlyiOS/CountlyHealthTracker.m @@ -146,6 +146,12 @@ - (void)sendHealthCheck { if (CountlyDeviceInfo.sharedInstance.isDeviceIDTemporary) { CLY_LOG_W(@"%s, currently in temporary id mode, omitting", __FUNCTION__); } + + if (!CountlyServerConfig.sharedInstance.networkingEnabled) + { + CLY_LOG_D(@"'sendHealthCheck' is aborted: SDK Networking is disabled from server config!"); + return; + } if (!_healthCheckEnabled || _healthCheckSent) { CLY_LOG_D(@"%s, health check status, sent: %d, not_enabled: %d", __FUNCTION__, _healthCheckSent, _healthCheckEnabled); diff --git a/ios/Classes/CountlyiOS/CountlyPersistency.h b/ios/Classes/CountlyiOS/CountlyPersistency.h index 675e87fc..4e17aaa5 100644 --- a/ios/Classes/CountlyiOS/CountlyPersistency.h +++ b/ios/Classes/CountlyiOS/CountlyPersistency.h @@ -58,8 +58,8 @@ - (NSDictionary *)retrieveRemoteConfig; - (void)storeRemoteConfig:(NSDictionary *)remoteConfig; -- (NSDictionary *)retrieveServerConfig; -- (void)storeServerConfig:(NSDictionary *)serverConfig; +- (NSMutableDictionary *)retrieveServerConfig; +- (void)storeServerConfig:(NSMutableDictionary *)serverConfig; - (NSDictionary *)retrieveHealthCheckTrackerState; - (void)storeHealthCheckTrackerState:(NSDictionary *)healthCheckTrackerState; diff --git a/ios/Classes/CountlyiOS/CountlyPersistency.m b/ios/Classes/CountlyiOS/CountlyPersistency.m index 52f0b6ee..0f04d476 100644 --- a/ios/Classes/CountlyiOS/CountlyPersistency.m +++ b/ios/Classes/CountlyiOS/CountlyPersistency.m @@ -593,16 +593,17 @@ - (void)storeRemoteConfig:(NSDictionary *)remoteConfig [NSUserDefaults.standardUserDefaults synchronize]; } -- (NSDictionary *)retrieveServerConfig +- (NSMutableDictionary *)retrieveServerConfig { NSDictionary* serverConfig = [NSUserDefaults.standardUserDefaults objectForKey:kCountlyServerConfigPersistencyKey]; - if (!serverConfig) - serverConfig = NSDictionary.new; - - return serverConfig; + if ([serverConfig isKindOfClass:[NSDictionary class]]) { + return [serverConfig mutableCopy]; + } + + return [NSMutableDictionary new]; } -- (void)storeServerConfig:(NSDictionary *)serverConfig +- (void)storeServerConfig:(NSMutableDictionary *)serverConfig { [NSUserDefaults.standardUserDefaults setObject:serverConfig forKey:kCountlyServerConfigPersistencyKey]; [NSUserDefaults.standardUserDefaults synchronize]; diff --git a/ios/Classes/CountlyiOS/CountlyServerConfig.h b/ios/Classes/CountlyiOS/CountlyServerConfig.h index d81fc83a..030a72a2 100644 --- a/ios/Classes/CountlyiOS/CountlyServerConfig.h +++ b/ios/Classes/CountlyiOS/CountlyServerConfig.h @@ -13,7 +13,7 @@ extern NSString* const kCountlySCKeySC; + (instancetype)sharedInstance; - (void)fetchServerConfig:(CountlyConfig *)config; -- (void)retrieveServerConfigFromStorage:(NSString*) sdkBehaviorSettings; +- (void)retrieveServerConfigFromStorage:(CountlyConfig *)config; - (void)fetchServerConfigIfTimeIsUp; - (void)disableSDKBehaviourSettings; diff --git a/ios/Classes/CountlyiOS/CountlyServerConfig.m b/ios/Classes/CountlyiOS/CountlyServerConfig.m index ea3129c7..520e3c64 100644 --- a/ios/Classes/CountlyiOS/CountlyServerConfig.m +++ b/ios/Classes/CountlyiOS/CountlyServerConfig.m @@ -114,18 +114,68 @@ - (instancetype)init return self; } -- (void)retrieveServerConfigFromStorage:(NSString *)sdkBehaviorSettings +- (void)retrieveServerConfigFromStorage:(CountlyConfig *)config { - NSError *error = nil; - NSDictionary *serverConfigObject = [CountlyPersistency.sharedInstance retrieveServerConfig]; - if (serverConfigObject.count == 0 && sdkBehaviorSettings) + NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; + if (persistentBehaviorSettings.count == 0 && config.sdkBehaviorSettings) { - serverConfigObject = [NSJSONSerialization JSONObjectWithData:[sdkBehaviorSettings cly_dataUTF8] options:0 error:&error]; + NSError *error = nil; + id parsed = [NSJSONSerialization JSONObjectWithData:[config.sdkBehaviorSettings cly_dataUTF8] options:0 error:&error]; + + if ([parsed isKindOfClass:[NSDictionary class]]) { + persistentBehaviorSettings = [(NSDictionary *)parsed mutableCopy]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; + } else { + CLY_LOG_W(@"%s, Failed to parse sdkBehaviorSettings or not a dictionary: %@", __FUNCTION__, error); + } } - if (serverConfigObject.count > 0 && !error) + [self populateServerConfig:persistentBehaviorSettings withConfig:config]; +} + +- (void)mergeBehaviorSettings:(NSMutableDictionary *)baseConfig + withConfig:(NSDictionary *)newConfig +{ + // c, t and v paramters must exist + if(newConfig.count != 3 || !newConfig[kRConfig]) { + CLY_LOG_D(@"%s, missing entries for a behavior settings omitting", __FUNCTION__); + return; + } + + if (!newConfig[kRVersion] || !newConfig[kRTimestamp]) { - [self populateServerConfig:serverConfigObject]; + CLY_LOG_D(@"%s, version or timestamp is missing in the behavior settings omitting", __FUNCTION__); + return; + } + + if(!([newConfig[kRConfig] isKindOfClass:[NSDictionary class]]) || ((NSDictionary *)newConfig[kRConfig]).count == 0){ + CLY_LOG_D(@"%s, invalid behavior settings omitting", __FUNCTION__); + return; + } + + id timestamp = newConfig[kRTimestamp]; + if (timestamp) { + baseConfig[kRTimestamp] = timestamp; + } + + id version = newConfig[kRVersion]; + if (version) { + baseConfig[kRVersion] = version; + } + + NSDictionary *cBase = baseConfig[kRConfig] ?: NSMutableDictionary.new; + NSDictionary *cNew = newConfig[kRConfig]; + + if ([cBase isKindOfClass:[NSDictionary class]] || [cNew isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *cMerged = [cBase mutableCopy]; + + [cNew enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if (obj != nil && obj != [NSNull null]) { + cMerged[key] = obj; + } + }]; + + baseConfig[kRConfig] = cMerged; } } @@ -159,27 +209,27 @@ - (void)setDoubleProperty:(double *)property fromDictionary:(NSDictionary *)dict } } -- (void)populateServerConfig:(NSDictionary *)serverConfig +- (void)populateServerConfig:(NSDictionary *)serverConfig withConfig:(CountlyConfig *)config { if (!serverConfig[kRConfig]) { CLY_LOG_D(@"%s, config key is missing in the server configuration omitting", __FUNCTION__); return; } - + NSDictionary *dictionary = serverConfig[kRConfig]; - + if (!serverConfig[kRVersion] || !serverConfig[kRTimestamp]) { CLY_LOG_D(@"%s, version or timestamp is missing in the server configuration omitting", __FUNCTION__); return; } - + _version = [serverConfig[kRVersion] integerValue]; _timestamp = [serverConfig[kRTimestamp] longLongValue]; - + NSMutableString *logString = [NSMutableString stringWithString:@"Server Config: "]; - + [self setBoolProperty:&_trackingEnabled fromDictionary:dictionary key:kTracking logString:logString]; [self setBoolProperty:&_networkingEnabled fromDictionary:dictionary key:kNetworking logString:logString]; [self setIntegerProperty:&_sessionInterval fromDictionary:dictionary key:kRSessionUpdateInterval logString:logString]; @@ -208,6 +258,11 @@ - (void)populateServerConfig:(NSDictionary *)serverConfig [self setDoubleProperty:&_bomRQPercentage fromDictionary:dictionary key:kRBOMRQPercentage logString:logString]; [self setIntegerProperty:&_bomRequestAge fromDictionary:dictionary key:kRBOMRequestAge logString:logString]; [self setIntegerProperty:&_bomDuration fromDictionary:dictionary key:kRBOMDuration logString:logString]; + + if(![logString isEqualToString: @"Server Config: "]){ + // means new config gotten, if that is the case notify SDK + [self notifySdkConfigChange: config]; + } CLY_LOG_D(@"%s, version:[%li], timestamp:[%lli], %@", __FUNCTION__, _version, _timestamp, logString); } @@ -249,13 +304,6 @@ - (void)notifySdkConfigChange:(CountlyConfig *)config CountlyCommon.sharedInstance.maxValueLength = config.sdkInternalLimits.getMaxValueSize; CountlyCommon.sharedInstance.maxSegmentationValues = config.sdkInternalLimits.getMaxSegmentationValues; - config.requiresConsent = _consentRequired ?: config.requiresConsent; - CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; - if (_consentRequired) - { - [CountlyConsentManager.sharedInstance cancelConsentForAllFeatures]; - } - config.eventSendThreshold = _eventQueueSize ?: config.eventSendThreshold; config.requestDropAgeHours = _dropOldRequestTime ?: config.requestDropAgeHours; config.storedRequestsLimit = _requestQueueSize ?: config.storedRequestsLimit; @@ -266,6 +314,9 @@ - (void)notifySdkConfigChange:(CountlyConfig *)config config.updateSessionPeriod = _sessionInterval ?: config.updateSessionPeriod; _sessionInterval = config.updateSessionPeriod; + config.requiresConsent = _consentRequired ?: config.requiresConsent; + CountlyConsentManager.sharedInstance.requiresConsent = config.requiresConsent; + #if (TARGET_OS_IOS) [config.content setZoneTimerInterval:_contentZoneInterval ?: config.content.getZoneTimerInterval]; if (config.content.getZoneTimerInterval) @@ -379,12 +430,11 @@ - (void)fetchServerConfig:(CountlyConfig *)config if (serverConfigResponse[kRConfig] != nil) { - [CountlyPersistency.sharedInstance storeServerConfig:serverConfigResponse]; - [self setDefaultValues]; - [self populateServerConfig:serverConfigResponse]; + NSMutableDictionary *persistentBehaviorSettings = [CountlyPersistency.sharedInstance retrieveServerConfig]; + [self mergeBehaviorSettings:persistentBehaviorSettings withConfig:serverConfigResponse]; + [CountlyPersistency.sharedInstance storeServerConfig:persistentBehaviorSettings]; + [self populateServerConfig:persistentBehaviorSettings withConfig:config]; } - - [self notifySdkConfigChange:config]; // if no config let stored ones to be set }; // Set default values NSURLSessionTask *task = [CountlyCommon.sharedInstance.URLSession dataTaskWithRequest:[self serverConfigRequest] completionHandler:handler];