diff --git a/CHANGELOG.md b/CHANGELOG.md index cebd822..df14793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.6] - 2026-06-24 + +### Changed +- [iOS] Improved memory usage during offline session replay uploads. +- Enhanced overall performance during screen navigations. + +### Fixed +- [Android] Fixed an issue related to rapid navigations. +- [Android] Fixed an ANR related to animated dialog captures. +- [Android] Fixed an issue related to keyboard scrolls on webviews. +- [Android] Fixed an issue related to dispatch window callback mutations. + +## [2.3.5] - 2026-06-02 + +### Added +- Optional prefilled message support the support chat input field. + +### Changed +- Improved privacy(masking) stability. +- Improved iOS Flutter lifecycle handling to prevent crashes when Flutter is unavailable during session replays in hybrid Flutter + native approach. + +### Fixed +- Fixed session recordings associating events with the wrong DevRev's different workspace. +- [iOS] Fixed crashes from thread-unsafe. + +## [2.3.4] - 2026-05-22 + +### Fixed +- [Android] Fixed the fractional masking delay during navigation. + ## [2.3.3] - 2026-05-12 ### Fixed diff --git a/README.md b/README.md index 59fe123..d04fa0d 100644 --- a/README.md +++ b/README.md @@ -712,10 +712,25 @@ DevRev.processPushNotification(payload); ##### iOS -On iOS devices, you must pass the received push notification payload to the DevRev SDK for processing. The SDK handles the notification and executes the necessary actions. +On iOS devices, you must update the `AppDelegate` to intercept notification clicks and forward the payload to the SDK. -```dart -DevRev.processPushNotification(payload); +In `didFinishLaunchingWithOptions`, set the `UNUserNotificationCenter` delegate: + +```swift +UNUserNotificationCenter.current().delegate = self +``` + +Implement `userNotificationCenter(_:didReceive:)` to pass the notification payload to the SDK: + +```swift +func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse +) async { + await DevRev.processPushNotification( + response.notification.request.content.userInfo + ) +} ``` ## Sample app diff --git a/devrev_sdk_flutter-2.3.3.zip b/devrev_sdk_flutter-2.3.6.zip similarity index 50% rename from devrev_sdk_flutter-2.3.3.zip rename to devrev_sdk_flutter-2.3.6.zip index 758345c..aae0e06 100644 Binary files a/devrev_sdk_flutter-2.3.3.zip and b/devrev_sdk_flutter-2.3.6.zip differ diff --git a/sample/lib/Features/account_security_form_screen.dart b/sample/lib/Features/account_security_form_screen.dart new file mode 100644 index 0000000..1820de1 --- /dev/null +++ b/sample/lib/Features/account_security_form_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'masked_form_helpers.dart'; + +class AccountSecurityFormScreen extends StatefulWidget { + const AccountSecurityFormScreen({super.key}); + + @override + State createState() => + _AccountSecurityFormScreenState(); +} + +class _AccountSecurityFormScreenState extends State + with MaskedFormTransitionMixin { + final _formKey = GlobalKey(); + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _recoveryEmailController = TextEditingController(); + final _twoFactorCodeController = TextEditingController(); + + @override + void dispose() { + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + _recoveryEmailController.dispose(); + _twoFactorCodeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const currentPath = '/masked-form/security'; + + return maskedFormScaffold( + title: 'Account Security', + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + maskedFormStepIndicator(currentPath), + maskedFormDescription, + const SizedBox(height: 16), + maskedTextField( + controller: _currentPasswordController, + label: 'Current Password', + obscureText: true, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _newPasswordController, + label: 'New Password', + obscureText: true, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _confirmPasswordController, + label: 'Confirm New Password', + obscureText: true, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _recoveryEmailController, + label: 'Recovery Email', + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _twoFactorCodeController, + label: 'Two-Factor Authentication Code', + keyboardType: TextInputType.number, + obscureText: true, + ), + const SizedBox(height: 24), + maskedFormFlowActions( + context: context, + currentPath: currentPath, + formKey: _formKey, + savedMessage: 'Security settings updated', + ), + ], + ), + ), + ); + } +} diff --git a/sample/lib/Features/masked_form_helpers.dart b/sample/lib/Features/masked_form_helpers.dart new file mode 100644 index 0000000..0061564 --- /dev/null +++ b/sample/lib/Features/masked_form_helpers.dart @@ -0,0 +1,161 @@ +import 'package:devrev_sdk_flutter/devrev.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +typedef MaskedFormFlowStep = ({String title, String path}); + +const List maskedFormFlowSteps = [ + (title: 'Personal Info', path: '/masked-form/personal'), + (title: 'Contact Info (Unmasked)', path: '/masked-form/contact'), + (title: 'Payment Details', path: '/masked-form/payment'), + (title: 'Account Security', path: '/masked-form/security'), +]; + +int maskedFormStepIndex(String path) => + maskedFormFlowSteps.indexWhere((step) => step.path == path); + +String? maskedFormNextPath(String path) { + final index = maskedFormStepIndex(path); + if (index < 0 || index >= maskedFormFlowSteps.length - 1) return null; + return maskedFormFlowSteps[index + 1].path; +} + +Future navigateToMaskedForm(BuildContext context, String path) async { + await DevRev.updateTransitioningState(true, isNavigation: true); + if (!context.mounted) return; + context.push(path); +} + +void resumeRecordingWhenRouteVisible(BuildContext context) { + final animation = ModalRoute.of(context)?.animation; + if (animation == null || + animation.status == AnimationStatus.completed || + animation.status == AnimationStatus.dismissed) { + DevRev.updateTransitioningState(false, isNavigation: true); + return; + } + + void onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + animation.removeStatusListener(onAnimationStatus); + DevRev.updateTransitioningState(false, isNavigation: true); + } + } + + animation.addStatusListener(onAnimationStatus); +} + +mixin MaskedFormTransitionMixin on State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + resumeRecordingWhenRouteVisible(context); + }); + } +} + +Widget maskedFormStepIndicator(String path) { + final step = maskedFormStepIndex(path); + if (step < 0) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + 'Step ${step + 1} of ${maskedFormFlowSteps.length}: ' + '${maskedFormFlowSteps[step].title}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ); +} + +Widget maskedFormFlowActions({ + required BuildContext context, + required String currentPath, + required GlobalKey formKey, + String? savedMessage, +}) { + final nextPath = maskedFormNextPath(currentPath); + final isLastStep = nextPath == null; + + Future onContinue() async { + if (!(formKey.currentState?.validate() ?? false)) return; + + if (savedMessage != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(savedMessage)), + ); + } + + if (isLastStep) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Onboarding complete')), + ); + context.go('/router-navigation'); + return; + } + + await navigateToMaskedForm(context, nextPath); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: onContinue, + child: Text(isLastStep ? 'Finish' : 'Continue'), + ), + if (Navigator.of(context).canPop()) ...[ + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => context.pop(), + child: const Text('Back'), + ), + ], + ], + ); +} + +Widget maskedTextField({ + required TextEditingController controller, + required String label, + TextInputType keyboardType = TextInputType.text, + bool obscureText = false, + int maxLines = 1, + String? Function(String?)? validator, +}) { + return DevRevMask( + child: TextFormField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + maxLines: maxLines, + validator: validator, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ), + ); +} + +Widget maskedFormScaffold({ + required String title, + required Widget body, +}) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: body, + ), + ); +} + +const maskedFormDescription = Text( + 'All fields below are wrapped with DevRevMask and will be ' + 'hidden in session recordings.', + style: TextStyle(color: Colors.grey), +); diff --git a/sample/lib/Features/masked_form_screen.dart b/sample/lib/Features/masked_form_screen.dart new file mode 100644 index 0000000..197630d --- /dev/null +++ b/sample/lib/Features/masked_form_screen.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'masked_form_helpers.dart'; + +class MaskedFormScreen extends StatefulWidget { + const MaskedFormScreen({super.key}); + + @override + State createState() => _MaskedFormScreenState(); +} + +class _MaskedFormScreenState extends State + with MaskedFormTransitionMixin { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); + final _addressController = TextEditingController(); + final _ssnController = TextEditingController(); + final _cardNumberController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _addressController.dispose(); + _ssnController.dispose(); + _cardNumberController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const currentPath = '/masked-form/personal'; + + return maskedFormScaffold( + title: 'Personal Info', + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + maskedFormStepIndicator(currentPath), + maskedFormDescription, + const SizedBox(height: 16), + maskedTextField( + controller: _nameController, + label: 'Full Name', + ), + const SizedBox(height: 12), + maskedTextField( + controller: _emailController, + label: 'Email', + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _phoneController, + label: 'Phone Number', + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _addressController, + label: 'Address', + ), + const SizedBox(height: 12), + maskedTextField( + controller: _ssnController, + label: 'Social Security Number', + keyboardType: TextInputType.number, + obscureText: true, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _cardNumberController, + label: 'Credit Card Number', + keyboardType: TextInputType.number, + obscureText: true, + ), + const SizedBox(height: 24), + maskedFormFlowActions( + context: context, + currentPath: currentPath, + formKey: _formKey, + savedMessage: 'Personal info saved', + ), + ], + ), + ), + ); + } +} diff --git a/sample/lib/Features/payment_details_form_screen.dart b/sample/lib/Features/payment_details_form_screen.dart new file mode 100644 index 0000000..56e8a8a --- /dev/null +++ b/sample/lib/Features/payment_details_form_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'masked_form_helpers.dart'; + +class PaymentDetailsFormScreen extends StatefulWidget { + const PaymentDetailsFormScreen({super.key}); + + @override + State createState() => + _PaymentDetailsFormScreenState(); +} + +class _PaymentDetailsFormScreenState extends State + with MaskedFormTransitionMixin { + final _formKey = GlobalKey(); + final _cardholderController = TextEditingController(); + final _cardNumberController = TextEditingController(); + final _expiryController = TextEditingController(); + final _cvvController = TextEditingController(); + final _billingZipController = TextEditingController(); + + @override + void dispose() { + _cardholderController.dispose(); + _cardNumberController.dispose(); + _expiryController.dispose(); + _cvvController.dispose(); + _billingZipController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const currentPath = '/masked-form/payment'; + + return maskedFormScaffold( + title: 'Payment Details', + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + maskedFormStepIndicator(currentPath), + maskedFormDescription, + const SizedBox(height: 16), + maskedTextField( + controller: _cardholderController, + label: 'Cardholder Name', + ), + const SizedBox(height: 12), + maskedTextField( + controller: _cardNumberController, + label: 'Card Number', + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _expiryController, + label: 'Expiry (MM/YY)', + keyboardType: TextInputType.datetime, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _cvvController, + label: 'CVV', + keyboardType: TextInputType.number, + obscureText: true, + ), + const SizedBox(height: 12), + maskedTextField( + controller: _billingZipController, + label: 'Billing ZIP Code', + keyboardType: TextInputType.number, + ), + const SizedBox(height: 24), + maskedFormFlowActions( + context: context, + currentPath: currentPath, + formKey: _formKey, + savedMessage: 'Payment details saved', + ), + ], + ), + ), + ); + } +} diff --git a/sample/lib/Features/router_navigation_screen.dart b/sample/lib/Features/router_navigation_screen.dart new file mode 100644 index 0000000..7dd4fb6 --- /dev/null +++ b/sample/lib/Features/router_navigation_screen.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'masked_form_helpers.dart'; + +class RouterNavigationScreen extends StatelessWidget { + const RouterNavigationScreen({super.key}); + + static const _entryPath = '/masked-form/personal'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Router Navigation')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Start the nested onboarding flow. Each screen pushes the next ' + 'on top of the stack, building a deep navigation chain.', + ), + const SizedBox(height: 16), + ...maskedFormFlowSteps.asMap().entries.map( + (entry) => ListTile( + leading: CircleAvatar(child: Text('${entry.key + 1}')), + title: Text(entry.value.title), + subtitle: Text(entry.value.path), + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => navigateToMaskedForm(context, _entryPath), + child: const Text('Start Onboarding Flow'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => context.pop(), + child: const Text('Go Back'), + ), + ], + ), + ); + } +} diff --git a/sample/lib/Features/unmasked_form_screen.dart b/sample/lib/Features/unmasked_form_screen.dart new file mode 100644 index 0000000..89adc25 --- /dev/null +++ b/sample/lib/Features/unmasked_form_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'masked_form_helpers.dart'; + +class UnmaskedFormScreen extends StatefulWidget { + const UnmaskedFormScreen({super.key}); + + @override + State createState() => _UnmaskedFormScreenState(); +} + +class _UnmaskedFormScreenState extends State + with MaskedFormTransitionMixin { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return maskedFormScaffold( + title: 'Contact Info', + body: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + maskedFormStepIndicator('/masked-form/contact'), + const Text( + 'No fields on this screen are masked. Everything here ' + 'should be visible in recordings.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Full Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email Address', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + maskedFormFlowActions( + context: context, + currentPath: '/masked-form/contact', + formKey: _formKey, + savedMessage: 'Contact info saved', + ), + ], + ), + ), + ); + } +} diff --git a/sample/lib/main.dart b/sample/lib/main.dart index 41dbea4..9c91ecb 100644 --- a/sample/lib/main.dart +++ b/sample/lib/main.dart @@ -6,12 +6,50 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:devrev_sdk_flutter/devrev.dart'; +import 'package:devrev_sdk_flutter/devrev_monitored_app.dart'; +import 'package:go_router/go_router.dart'; import 'Features/identification_screen.dart'; +import 'Features/masked_form_screen.dart'; +import 'Features/unmasked_form_screen.dart'; +import 'Features/payment_details_form_screen.dart'; +import 'Features/account_security_form_screen.dart'; import 'Features/pushnotifications_screen.dart'; +import 'Features/router_navigation_screen.dart'; import 'Features/session_analytics_screen.dart'; import 'Features/support_screen.dart'; import 'Components/status_list_item.dart'; +final _router = GoRouter( + initialLocation: '/main', + observers: [DevRevTransitionTrackingObserver()], + routes: [ + GoRoute( + path: '/main', + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: '/router-navigation', + builder: (context, state) => const RouterNavigationScreen(), + ), + GoRoute( + path: '/masked-form/personal', + builder: (context, state) => const MaskedFormScreen(), + ), + GoRoute( + path: '/masked-form/contact', + builder: (context, state) => const UnmaskedFormScreen(), + ), + GoRoute( + path: '/masked-form/payment', + builder: (context, state) => const PaymentDetailsFormScreen(), + ), + GoRoute( + path: '/masked-form/security', + builder: (context, state) => const AccountSecurityFormScreen(), + ), + ], +); + void main() async { WidgetsFlutterBinding.ensureInitialized(); if (Platform.isIOS) { @@ -41,11 +79,10 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return DevRevMonitoredApp( + return DevRevMonitoredApp.router( title: "DevRev SDK", theme: ThemeData(primarySwatch: Colors.blue), - initialRoute: "/main", - routes: {"/main": (context) => const HomeScreen()}, + routerConfig: _router, ); } } @@ -147,6 +184,10 @@ class _HomeScreenState extends State }, {"title": "Support", "route": const SupportScreen()}, {"title": "Session Analytics", "route": const SessionAnalyticsScreen()}, + { + "title": "Router Navigation", + "routePath": "/router-navigation", + }, ], if (Platform.isAndroid) "DEBUG": [ @@ -207,6 +248,12 @@ class _HomeScreenState extends State ); } else if (item["type"] == "animated_text") { return ListTile(title: item["widget"] as Widget); + } else if (item.containsKey("routePath")) { + return ListTile( + title: Text(item["title"]), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(item["routePath"] as String), + ); } else { return ListTile( title: Text(item["title"]), diff --git a/sample/pubspec.yaml b/sample/pubspec.yaml index c1e570e..527787c 100644 --- a/sample/pubspec.yaml +++ b/sample/pubspec.yaml @@ -8,7 +8,7 @@ environment: dependencies: flutter: sdk: flutter - devrev_sdk_flutter: ^2.3.3 + devrev_sdk_flutter: ^2.3.6 cupertino_icons: ^1.0.8 firebase_core: ^3.12.1 firebase_app_installations: ^0.3.2+4