0

I have an inactivity timer that keeps checking for inactivity throughout the app. The problem is, when message box is open, the timer is not ticking and checking for inactivity as long as the MessageBox stays open. When it is closed, it works fine.

The code used here is taken from: How to detect a Winforms app has been idle for certain amount of time

UIInactivity.cs (Track inactivity)

public class UIInactivity
    {
        public static Timer IdleTimer;
        private readonly int _timeoutDuration;
        private InactivityState _inactivityState;
        public event Action<InactivityState> NotifyInActivity;

        public UIInactivity(int timeoutDurationInMinutes, InactivityState inactivityState)
        {
            _timeoutDuration = (int)TimeSpan.FromMinutes(timeoutDurationInMinutes).TotalMilliseconds;
            _inactivityState = inactivityState;
            InitInactivity();
        }

        private void InitInactivity()
        {
            //Application.EnableVisualStyles(); //Uncomment this line for new look and feel.
            IdleTimer = new Timer();
            LeaveIdleMessageFilter limf = new LeaveIdleMessageFilter();
            Application.AddMessageFilter(limf);
            IdleTimer.Interval = _timeoutDuration;
            IdleTimer.Tick += TimeDone;
            IdleTimer.Enabled = false;
        }

        private void Application_Idle(object sender, EventArgs e)
        {
            if (!IdleTimer.Enabled)
            {
                IdleTimer.Start();
            }
        }

        private void TimeDone(object sender, EventArgs e)
        {
            StopTimer();
            NotifyInActivity?.Invoke(_inactivityState);
        }

        public void StopTimer()
        {
            IdleTimer.Stop();
            IdleTimer.Enabled = false;
            Application.Idle -= new EventHandler(Application_Idle);
        }
            
        public void StartTimer(int minutes)
        {
            if (IdleTimer != null && !IdleTimer.Enabled)
            {
                if (minutes > 0)
                {
                    IdleTimer.Interval = (int)TimeSpan.FromMinutes(minutes).TotalMilliseconds;
                }
                else
                {
                    throw new Exception("Inavlid timeout duration for Activity Logger");
                }
                IdleTimer.Enabled = true;
                IdleTimer.Start();
                Application.Idle += new EventHandler(Application_Idle);
            }
        }
    }

    [SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermissionFlag.UnmanagedCode)]
    public class LeaveIdleMessageFilter : IMessageFilter
    {
        const int WM_NCLBUTTONDOWN = 0x00A1;
        const int WM_NCLBUTTONUP = 0x00A2;
        const int WM_NCRBUTTONDOWN = 0x00A4;
        const int WM_NCRBUTTONUP = 0x00A5;
        const int WM_NCMBUTTONDOWN = 0x00A7;
        const int WM_NCMBUTTONUP = 0x00A8;
        const int WM_NCXBUTTONDOWN = 0x00AB;
        const int WM_NCXBUTTONUP = 0x00AC;
        const int WM_KEYDOWN = 0x0100;
        const int WM_KEYUP = 0x0101;
        const int WM_MOUSEMOVE = 0x0200;
        const int WM_LBUTTONDOWN = 0x0201;
        const int WM_LBUTTONUP = 0x0202;
        const int WM_RBUTTONDOWN = 0x0204;
        const int WM_RBUTTONUP = 0x0205;
        const int WM_MBUTTONDOWN = 0x0207;
        const int WM_MBUTTONUP = 0x0208;
        const int WM_XBUTTONDOWN = 0x020B;
        const int WM_XBUTTONUP = 0x020C;

        // The Messages array must be sorted due to use of Array.BinarySearch
        static int[] Messages = new int[] {WM_NCLBUTTONDOWN,
            WM_NCLBUTTONUP, WM_NCRBUTTONDOWN, WM_NCRBUTTONUP, WM_NCMBUTTONDOWN,
            WM_NCMBUTTONUP, WM_NCXBUTTONDOWN, WM_NCXBUTTONUP, WM_KEYDOWN, WM_KEYUP,
            WM_LBUTTONDOWN, WM_LBUTTONUP, WM_RBUTTONDOWN, WM_RBUTTONUP,
            WM_MBUTTONDOWN, WM_MBUTTONUP, WM_XBUTTONDOWN, WM_XBUTTONUP};

        public bool PreFilterMessage(ref Message m)
        {
            if (m.Msg == WM_MOUSEMOVE)  // mouse move is high volume
                return false;
            if (!UIInactivity.IdleTimer.Enabled)     // idling?
                return false;           // No
            if (Array.BinarySearch(Messages, m.Msg) >= 0)
                UIInactivity.IdleTimer.Stop();
            return false;
        }
    }

Somewhere in the application a form opens a warning messagebox as follows when trying to save changes,

if (DialogResult.Yes == MessageBox.Show(m_ResMngr.GetString("ChanRangeWarn"),m_ResMngr.GetString("Warning"),MessageBoxButtons.YesNo,MessageBoxIcon.Warning))
{
   //Something
}

When this MessageBox is open, it's not checking for inactivity. How can I resolve this?

5
  • 1
    System.Windows.Forms.Timer is a wrong timer to use, try either of these. Commented Feb 19 at 12:51
  • Is the Application.Idle and/or LeaveIdleMessageFilter relevant for this problem? Is TimeDone not called, even when the timer is enabled and elapsed? I'm not really sure I understand how the example is supposed to work. Commented Feb 19 at 15:06
  • 3
    It is not the timer that's the problem, Application.AddMessageFilter() is the troublemaker. You can count on getting PreFilterMessage callbacks as long as the Winforms dispatcher is generating events. But MessageBox is a native dialog that doesn't know beans about Winforms. No simple fix for that, you'd have to use a windows hook to see input while the message box is active. Or more practically, do this. Commented Feb 19 at 15:33
  • If it's not async these days; it's "bad"; even if it's important ... it just stays "on top"; without shutting everything else down. "Graceful degradation". Commented Feb 19 at 16:58
  • 2
    When you take code from a StackOverflow post, you need to refer to it. I've edited your question to reflect that -- This is a quite convoluted way of detecting idle time. See the code in here: GetLastInputInfo not returning dwTime, it works fine when a modal Dialog is presented -- BTW, Hans is right, and the Timer of course is still ticking (or, it could be) Commented Feb 19 at 18:50

1 Answer 1

0

Jimi is right about Hans being right. So, the way I see it we need to put together four elements to solve this.

  1. A low-level hook that we can run during calls to MessageBox.Show(...).
  2. An IDisposable to wrap the hook with, that does reference counting and disposes the hook when it leaves the using block.
  3. A means so that calling any overload of MessageBox.Show(...) within in our scope calls our own method.
  4. A suitable WatchdogTimer that can be "kicked" (i.e. start-or-restart) when activity is sensed.

And while we're at it, we can optimize by putting messages in a HashSet<WindowsMessage> for rapid detection.


IDisposable Low-Level Hook

Let's knock out the first two requirements using P\Invoke along with the NuGet package shown (or something like it).

// <PackageReference Include="IVSoftware.Portable.Disposable" Version="1.2.0" />
static DisposableHost DHostHook
{
    get
    {
        if (_dhostHook is null)
        {
            _dhostHook = new DisposableHost();
            _dhostHook.BeginUsing += (sender, e) =>
            {
                _hookID = SetWindowsHookEx(
                    WH_GETMESSAGE,
                    _hookProc, 
                    IntPtr.Zero, 
                    GetCurrentThreadId());
            };
            _dhostHook.FinalDispose += (sender, e) =>
            {
                UnhookWindowsHookEx(_hookID);
            };
        }
        return _dhostHook;
    }
}
static DisposableHost? _dhostHook = default;
static IntPtr _hookID = IntPtr.Zero;
private static HookProc _hookProc = null!;

MessageBox Forwarder

Next, make a static class at local (or app) scope that behaves (in a sense) like an "impossible" extension for the static System.Windows.Forms.MessageBox class. This means it's "business as usual" when it come to invoking message boxes.

{
    .
    .
    .
    // MessageBox wrapper static class nested in MainForm
    private static class MessageBox
    {
        private static readonly Dictionary<int, MethodInfo> _showMethodLookup;
        static MessageBox()
        {
            _showMethodLookup = typeof(System.Windows.Forms.MessageBox)
                .GetMethods(BindingFlags.Static | BindingFlags.Public)
                .Where(_ => _.Name == "Show")
                .ToDictionary(
                    _ => _.GetParameters()
                            .Select(p => p.ParameterType)
                            .Aggregate(17, (hash, type) => hash * 31 + (type?.GetHashCode() ?? 0)),
                    _ => _
                );
        }
        public static DialogResult Show(params object[] args)
        {
            // Increment the ref count prior to calling native MessageBox
            using (DHostHook.GetToken())
            {
                int argHash = args
                    .Select(_ => _?.GetType() ?? typeof(object))
                    .Aggregate(17, (hash, type) => hash * 31 + (type?.GetHashCode() ?? 0));

                if (_showMethodLookup.TryGetValue(argHash, out var bestMatch) && bestMatch is not null)
                {
                    return bestMatch.Invoke(null, args) is DialogResult dialogResult
                        ? dialogResult
                        : DialogResult.None;
                }
                return DialogResult.None;
            }
        }
    }
    .
    .
    .
}

WatchdogTimer

Meet requirement #4 using the NuGet package shown (or something like it).


We'll monitor its status in the Title Bar of the main window as either "Idle" or "Running".


// <PackageReference Include = "IVSoftware.Portable.WatchdogTimer" Version="1.2.1" />
public WatchdogTimer InactivityWatchdog
{
    get
    {
        if (_InactivityWatchdog is null)
        {
            _InactivityWatchdog = new WatchdogTimer 
            { 
                Interval = TimeSpan.FromSeconds(2),
            };
            _InactivityWatchdog.RanToCompletion += (sender, e) =>
            {
                Text = "Idle";
            };
        }
        return _InactivityWatchdog;
    }
}
WatchdogTimer? _InactivityWatchdog = default;

Minimal MainForm Example

public MainForm()
{
    InitializeComponent();
    Application.AddMessageFilter(this);
    Disposed += (sender, e) => Application.RemoveMessageFilter(this);
    _hookProc = HookCallback;

    // Button for test
    buttonMsg.Click += (sender, e) =>
    {
        MessageBox.Show("✨ Testing the Hook!");
    };
}

public bool PreFilterMessage(ref Message m)
{
    CheckForActivity((WindowsMessage)m.Msg);
    return false;
}

// Threadsafe Text Setter
public new string Text
{
    get => _threadsafeText;
    set
    {
        lock (_lock)
        {
            if (!Equals(_threadsafeText, value))
            {
                lock (_lock)
                {
                    _threadsafeText = value;
                }
                if (InvokeRequired) BeginInvoke(() => base.Text = _threadsafeText);
                else base.Text = _threadsafeText;
            }
        }
    }
}
string _threadsafeText = string.Empty;
object _lock = new object();

private void CheckForActivity(WindowsMessage wm_msg)
{
    switch (wm_msg)
    {
        case WindowsMessage.WM_MOUSEMOVE: // Prioritize
        case WindowsMessage when _rapidMessageLookup.Contains(wm_msg):
            InactivityWatchdog.StartOrRestart();
            if(Text != "Running")
            {
                Text = "Running";
            }
            break;
    }
}

readonly HashSet<WindowsMessage> _rapidMessageLookup = 
    new (Enum.GetValues<WindowsMessage>());

private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
    if (nCode >= 0)
    {
        MSG msg = Marshal.PtrToStructure<MSG>(lParam);
        Debug.WriteLine($"Msg: {(WindowsMessage)msg.message} ({msg.message:X}), hWnd: {msg.hwnd}");
        Debug.WriteLine($"{(WindowsMessage)msg.message} {_rapidMessageLookup.Contains((WindowsMessage)msg.message)}");
        CheckForActivity((WindowsMessage)msg.message);
    }
    return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
Sign up to request clarification or add additional context in comments.

1 Comment

Here's how I tested this You can try it out on your end.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.