Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file not shown.
87 changes: 87 additions & 0 deletions sample/lib/Features/account_security_form_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'masked_form_helpers.dart';

class AccountSecurityFormScreen extends StatefulWidget {
const AccountSecurityFormScreen({super.key});

@override
State<AccountSecurityFormScreen> createState() =>
_AccountSecurityFormScreenState();
}

class _AccountSecurityFormScreenState extends State<AccountSecurityFormScreen>
with MaskedFormTransitionMixin {
final _formKey = GlobalKey<FormState>();
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',
),
],
),
),
);
}
}
161 changes: 161 additions & 0 deletions sample/lib/Features/masked_form_helpers.dart
Original file line number Diff line number Diff line change
@@ -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<MaskedFormFlowStep> 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<void> 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<T extends StatefulWidget> on State<T> {
@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<FormState> formKey,
String? savedMessage,
}) {
final nextPath = maskedFormNextPath(currentPath);
final isLastStep = nextPath == null;

Future<void> 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),
);
Loading
Loading