SankethBK / local_session_timeout

Redirect user to authentication page if the application doesn't receive any user interaction, or been running in the background for "x" duration.
https://pub.dev/packages/local_session_timeout
BSD 3-Clause "New" or "Revised" License
12 stars 19 forks source link

ForeGround Timer Not Working Properly #36

Closed affan3699 closed 3 weeks ago

affan3699 commented 1 month ago

Once the application goes in background and resumed. the timer of appFocusTimeout i think didn't reset and it timeout after given specified time.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);
  await DeviceDetails.instance.initialize();

  if (Platform.isAndroid) {
    AndroidGoogleMapsFlutter.useAndroidViewSurface = true;
  }
  await Future.delayed(const Duration(milliseconds: 150));
  final sessionStateStream = StreamController<SessionState>();

  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider<LoginProvider>(
            create: (_) => LoginProvider(sessionStateStream))),
      ],
      child: MyApp(sessionStateStream: sessionStateStream),
    ),
  );
}

class MyApp extends StatelessWidget {
  final sessionStateStream;

  MyApp({super.key, this.sessionStateStream});

  var logger = Logger(printer: PrettyPrinter());

  final _navigatorKey = GlobalKey<NavigatorState>();

  NavigatorState get _navigator => _navigatorKey.currentState!;

  /// Make this stream available throughout the widget tree with with any state management library
  /// like bloc, provider, GetX, ..
  ///

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    final loginProvider = Provider.of<LoginProvider>(context);

    final sessionConfig = SessionConfig(
        invalidateSessionForAppLostFocus: const Duration(minutes: 10),
        invalidateSessionForUserInactivity: const Duration(minutes: 10));

    sessionConfig.stream.listen((SessionTimeoutState timeoutEvent) {
      // stop listening, as user will already be in auth page
      loginProvider.sessionStateStream.add(SessionState.stopListening);
      if (timeoutEvent == SessionTimeoutState.userInactivityTimeout) {
        //MyToast.showToast('Session Timed Out');
        logger.i("Logged out because of user inactivity");
        Provider.of<LoginProvider>(context, listen: false).clear();
        // handle user  inactive timeout
        ModalUtil.showErrorModal(
          context: _navigatorKey.currentState!.overlay!.context,
          title: 'Session Timed Out',
          firstLineText: "Logged out because of user inactivity,\n",
          secondLineText: 'dubara koshish karain!',
          responseCode: '108',
        );
        // _navigator.pushAndRemoveUntil(
        //   MaterialPageRoute(
        //     builder: (context) => const WelcomeScreen(),
        //   ),
        //   (Route<dynamic> route) => false,
        // );
      } else if (timeoutEvent == SessionTimeoutState.appFocusTimeout) {
        //MyToast.showToast('Session Timed Out');
        logger.i("Logged out because of user inactivity ForeGround");
        // handle user  app lost focus timeout
        ModalUtil.showErrorModal(
          context: _navigatorKey.currentState!.overlay!.context,
          title: 'Session Timed Out',
          firstLineText: "Logged out because of user inactivity,\n",
          secondLineText: 'dubara koshish karain!',
          responseCode: '108',
        );

        // _navigator.pushAndRemoveUntil(
        //   MaterialPageRoute(
        //     builder: (context) => const WelcomeScreen(),
        //   ),
        //   (Route<dynamic> route) => false,
        //);
      }
    });

    return ScreenUtilInit(
      designSize: Size(390, 844),
      minTextAdapt: true,
      splitScreenMode: true,
      // Use builder only if you need to use library outside ScreenUtilInit context
      builder: (_, child) {
        return SessionTimeoutManager(
          sessionConfig: sessionConfig,
          sessionStateStream: loginProvider.sessionStateStream.stream,
          userActivityDebounceDuration: const Duration(seconds: 15),
          child: MaterialApp(
            navigatorKey: _navigatorKey,
            debugShowCheckedModeBanner: false,
            title: 'Hello',
            // You can use the library anywhere in the app even in theme
            theme: ThemeData(
                primarySwatch: Colors.blue,
                cardColor: appTheme.blackLight,
                colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
                textTheme:
                    Typography.englishLike2018.apply(fontSizeFactor: 1.sp),
                scaffoldBackgroundColor: Colors.white),
            home: child,
            routes: AppRoutes.routes,
          ),
        );
      },
      child: WelcomeScreen(),
    );
  }
}
SankethBK commented 1 month ago

Hi @affan3699 , make sure that SessionTimeoutManager is the root widget of your widget tree, it shouldn't be rebuilt conditionally, otherwise its state will be lost leading to unexpected behavior. It looks like every time provider triggers a rebuild SessionTimeoutManager widget will also be rebuilt which shouldn't happen in ideal scenario.

affan3699 commented 1 month ago

Its not working, even i have made it root widget, the Foreground timer is making issue here (invalidateSessionForAppLostFocus) . If you are saying bring the main.dart multiprovider under SessionTimeOut widget, then how will i access the provider or save the session state in provider

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);
  await DeviceDetails.instance.initialize();

  if (Platform.isAndroid) {
    AndroidGoogleMapsFlutter.useAndroidViewSurface = true;
  }
  await Future.delayed(const Duration(milliseconds: 150));
  final sessionStateStream = StreamController<SessionState>();

  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider<LoginProvider>(
            create: (_) => LoginProvider(sessionStateStream)),
      ],
      child: MyApp(sessionStateStream: sessionStateStream),
    ),
  );
}

class MyApp extends StatelessWidget {
  final sessionStateStream;

  MyApp({super.key, this.sessionStateStream});

  var logger = Logger(printer: PrettyPrinter());

  final _navigatorKey = GlobalKey<NavigatorState>();

  NavigatorState get _navigator => _navigatorKey.currentState!;

  /// Make this stream available throughout the widget tree with with any state management library
  /// like bloc, provider, GetX, ..
  ///

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    final loginProvider = Provider.of<LoginProvider>(context);

    final sessionConfig = SessionConfig(
        invalidateSessionForAppLostFocus: const Duration(minutes: 10),
        invalidateSessionForUserInactivity: const Duration(minutes: 10));

    sessionConfig.stream.listen((SessionTimeoutState timeoutEvent) {
      // stop listening, as user will already be in auth page
      loginProvider.sessionStateStream.add(SessionState.stopListening);
      if (timeoutEvent == SessionTimeoutState.userInactivityTimeout) {
        //MyToast.showToast('Session Timed Out');
        logger.i("Logged out because of user inactivity");
        Provider.of<LoginProvider>(context, listen: false).clear();
        // handle user  inactive timeout
        ModalUtil.showErrorModal(
          context: _navigatorKey.currentState!.overlay!.context,
          title: 'Session Timed Out',
          firstLineText: "Logged out because of user inactivity,\n",
          secondLineText: 'dubara koshish karain!',
          responseCode: '108',
        );
        // _navigator.pushAndRemoveUntil(
        //   MaterialPageRoute(
        //     builder: (context) => const WelcomeScreen(),
        //   ),
        //   (Route<dynamic> route) => false,
        // );
      } else if (timeoutEvent == SessionTimeoutState.appFocusTimeout) {
        //MyToast.showToast('Session Timed Out');
        logger.i("Logged out because of user inactivity ForeGround");
        // handle user  app lost focus timeout
        ModalUtil.showErrorModal(
          context: _navigatorKey.currentState!.overlay!.context,
          title: 'Session Timed Out',
          firstLineText: "Logged out because of user inactivity,\n",
          secondLineText: 'dubara koshish karain!',
          responseCode: '108',
        );

      }
    });

    OneSignal.Notifications.addForegroundWillDisplayListener((event) async {
      /// Display Notification, preventDefault to not display
      event.preventDefault();

      /// Do async work
      Provider.of<LoginProvider>(context, listen: false)
          .updateShouldFetchTransactionListData();
      print('Kaam hua');

      /// notification.display() to display after preventing default
      event.notification.display();
    });

    return SessionTimeoutManager(
      sessionConfig: sessionConfig,
      sessionStateStream: loginProvider.sessionStateStream.stream,
      userActivityDebounceDuration: const Duration(seconds: 15),
      child: ScreenUtilInit(
        designSize: Size(390, 844),
        minTextAdapt: true,
        splitScreenMode: true,
        // Use builder only if you need to use library outside ScreenUtilInit context
        builder: (_, child) {
          return MaterialApp(
            navigatorKey: _navigatorKey,
            debugShowCheckedModeBanner: false,
            // You can use the library anywhere in the app even in theme
            theme: ThemeData(
                primarySwatch: Colors.blue,
                cardColor: appTheme.blackLight,
                colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
                textTheme:
                    Typography.englishLike2018.apply(fontSizeFactor: 1.sp),
                scaffoldBackgroundColor: Colors.white),
            home: child,
            routes: AppRoutes.routes,
          );
        },
        child: WelcomeScreen(),
      ),
    );
  }
}
SankethBK commented 1 month ago

@affan3699 I have added dispose to delete timers if the widget is removed. Can you reference this commit id 83695865167abc5b48f88e9ca15532443a534f5c in your code and check if it solves the issue?

affan3699 commented 1 month ago

No its not working. The foreground timer doesn't reset which is why it timeout. The UserInactivity is working perfectly fine. The appFocusTimeout is not working as expected.

affan3699 commented 1 month ago

@SankethBK Any solution for it? because my app will soon go Live.

SankethBK commented 1 month ago

@affan3699 if you can reproduce the same issue in the example app or any simple executable app, it will help me to find the cause

affan3699 commented 1 month ago

https://github.com/user-attachments/assets/52ca7ece-22c0-465a-a044-824eb383a48b

As you can see i have used the example app with latest flutter version. the config is: invalidateSessionForAppLostFocus: const Duration(minutes: 1), invalidateSessionForUserInactivity: const Duration(minutes: 5),

As you can see i have set invalidateSessionForAppLostFocus 1 minute, in the video above, my app only lost focus for 1 or 2 second. The timer does not reset and in the end when i again lost focus it times out. The example app has only 1 or 2 pages, but in real world app has many pages due to which even my actual app does not lost focus for even 1 time, it times out due to lost focus reason.

Note:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:local_session_timeout/local_session_timeout.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final _navigatorKey = GlobalKey<NavigatorState>();
  NavigatorState get _navigator => _navigatorKey.currentState!;

  /// Make this stream available throughout the widget tree with with any state management library
  /// like bloc, provider, GetX, ..
  final sessionStateStream = StreamController<SessionState>();

  @override
  Widget build(BuildContext context) {
    final sessionConfig = SessionConfig(
      invalidateSessionForAppLostFocus: const Duration(minutes: 1),
      invalidateSessionForUserInactivity: const Duration(minutes: 5),
    );
    sessionConfig.stream.listen((SessionTimeoutState timeoutEvent) {
      // stop listening, as user will already be in auth page
      sessionStateStream.add(SessionState.stopListening);
      if (timeoutEvent == SessionTimeoutState.userInactivityTimeout) {
        print('logged out because of user inactivity');
        // handle user  inactive timeout
        _navigator.push(MaterialPageRoute(
          builder: (_) => AuthPage(
              sessionStateStream: sessionStateStream,
              loggedOutReason: "Logged out because of user inactivity"),
        ));
      } else if (timeoutEvent == SessionTimeoutState.appFocusTimeout) {
        print('Logged out because app lost focus');
        // handle user  app lost focus timeout
        _navigator.push(MaterialPageRoute(
          builder: (_) => AuthPage(
              sessionStateStream: sessionStateStream,
              loggedOutReason: "Logged out because app lost focus"),
        ));
      }
    });
    return SessionTimeoutManager(
      userActivityDebounceDuration: const Duration(seconds: 1),
      sessionConfig: sessionConfig,
      sessionStateStream: sessionStateStream.stream,
      child: MaterialApp(
        navigatorKey: _navigatorKey,
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: AuthPage(
          sessionStateStream: sessionStateStream,
        ),
      ),
    );
  }
}

// ignore: must_be_immutable
class AuthPage extends StatelessWidget {
  AuthPage({
    required this.sessionStateStream,
    this.loggedOutReason = "",
    super.key,
  });

  final StreamController<SessionState> sessionStateStream;
  late String loggedOutReason;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              if (loggedOutReason != "")
                Container(
                  padding: const EdgeInsets.symmetric(
                    vertical: 10,
                    horizontal: 15,
                  ),
                  child: Text(loggedOutReason),
                ),
              const SizedBox(
                height: 20,
              ),
              ElevatedButton(
                onPressed: () async {
                  // start listening only after user logs in
                  sessionStateStream.add(SessionState.startListening);
                  loggedOutReason = await Navigator.of(context).push(
                    MaterialPageRoute(
                      builder: (_) => MyHomePage(
                        sessionStateStream: sessionStateStream,
                      ),
                    ),
                  );
                },
                child: const Text("Login"),
              ),
            ],
          ),
        ));
  }
}

class MyHomePage extends StatelessWidget {
  final StreamController<SessionState> sessionStateStream;

  const MyHomePage({
    required this.sessionStateStream,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text("Home page"),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: ()  {

              },
              child: const Text("Touch"),
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: () async {
                // stop listening only after user goes to this page

                //! Its better to handle listening logic in state management
                //! libraries rather than writing them at random places in your app

                sessionStateStream.add(SessionState.stopListening);
                await Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => const ReadingPage(),
                  ),
                );

                //! after user returns from that page start listening again
                sessionStateStream.add(SessionState.startListening);
              },
              child: const Text("Reading page"),
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => WritingPage(
                      sessionStream: sessionStateStream,
                    ),
                  ),
                );
              },
              child: const Text("Writing Page"),
            ),
          ],
        ),
      ),
    );
  }
}

// This [age can text content, which user might be reading without any user activity
// If you want to disable the session timeout listeners when user enters such pages
// follow the below code
class ReadingPage extends StatefulWidget {
  const ReadingPage({super.key});

  @override
  State<ReadingPage> createState() => _ReadingPageState();
}

class _ReadingPageState extends State<ReadingPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const Center(
        child: Padding(
          padding: EdgeInsets.all(16.0),
          child: Text(
              "This page can have lot of extent content, and user might be reading this without performing any user activity. So you might want to disable sesison timeout listeners only for this page"),
        ),
      ),
    );
  }
}

// If the user is typing into the textbox, you may want to disable the session
// timeout listeners because typing events aren't captured by session timeout
// manager and it may conclude that user is inactive
class WritingPage extends StatefulWidget {
  final StreamController<SessionState> sessionStream;
  const WritingPage({
    required this.sessionStream,
    super.key,
  });

  @override
  State<WritingPage> createState() => _WritingPageState();
}

class _WritingPageState extends State<WritingPage> {
  @override
  Widget build(BuildContext context) {
    if (MediaQuery.of(context).viewInsets.bottom > 0) {
      // softkeyboard is open
      widget.sessionStream.add(SessionState.stopListening);
    } else {
      // keyboard is closed
      widget.sessionStream.add(SessionState.startListening);
    }
    return Scaffold(
      appBar: AppBar(),
      body: const Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
                "If the user is typing into the textbox, you may want to disable the session timeout listeners because typing events aren't captured by session timeout manager and it may conclude that user is inactive"),
            SizedBox(
              height: 20,
            ),
            TextField(
              decoration: InputDecoration(
                border: OutlineInputBorder(
                    borderSide:
                    BorderSide(color: Color.fromARGB(255, 16, 17, 17))),
                hintText: 'Start typing here',
                helperText: 'when keyboard is open, session won"t expire',
                prefixText: ' ',
                suffixText: 'USD',
                suffixStyle: TextStyle(
                  color: Color.fromARGB(255, 14, 14, 14),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
SankethBK commented 1 month ago

Hi @affan3699 , I got the issue. I have raised a fix in this commit 57023c451170904f7759cf7439d0b47a86d27120, can you try referencing this commit in your pubspec and verify.

affan3699 commented 3 weeks ago

Yes its ok now, i tested it. Thanks for your help. Will inform, if any other issue arise in future.