Skip to content

Commit

Permalink
Prevent Android timer from adding multiple callbacks;
Browse files Browse the repository at this point in the history
Fixes #10257
  • Loading branch information
hartez committed Jul 20, 2023
1 parent fe4bc5b commit 6620b53
Show file tree
Hide file tree
Showing 2 changed files with 94 additions and 3 deletions.
58 changes: 55 additions & 3 deletions src/Core/src/Dispatching/Dispatcher.Android.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using Android.OS;
using Android.Systems;
using Java.Lang;

namespace Microsoft.Maui.Dispatching
{
Expand Down Expand Up @@ -34,13 +36,20 @@ IDispatcherTimer CreateTimerImplementation()
partial class DispatcherTimer : IDispatcherTimer
{
readonly Handler _handler;
readonly IRunnable _runnable;

// For API versions after 29 and later, we can query the Handler directly to ask if callbacks
// are posted for our IRunnable. For API level before that, we'll need to manually track whether
// we've posted a callback to the queue.
bool _hasCallbacks;

/// <summary>
/// Initializes a new instance of the <see cref="DispatcherTimer"/> class.
/// </summary>
/// <param name="handler">The handler for this dispatcher to use.</param>
public DispatcherTimer(Handler handler)
{
_runnable = new Runnable(OnTimerTick);
_handler = handler;
}

Expand All @@ -64,7 +73,7 @@ public void Start()

IsRunning = true;

_handler.PostDelayed(OnTimerTick, (long)Interval.TotalMilliseconds);
Post();
}

/// <inheritdoc/>
Expand All @@ -75,20 +84,63 @@ public void Stop()

IsRunning = false;

_handler.RemoveCallbacks(OnTimerTick);
_handler.RemoveCallbacks(_runnable);

SetHasCallbacks(false);
}

void OnTimerTick()
{
if (!IsRunning)
return;

SetHasCallbacks(false);

Tick?.Invoke(this, EventArgs.Empty);

if (IsRepeating)
_handler.PostDelayed(OnTimerTick, (long)Interval.TotalMilliseconds);
{
Post();
}
else
{
Stop();
}
}

void Post()
{
if (IsCallbackPosted())
{
return;
}

_handler.PostDelayed(_runnable, (long)Interval.TotalMilliseconds);

SetHasCallbacks(true);
}

bool IsCallbackPosted()
{
if (OperatingSystem.IsAndroidVersionAtLeast(29))
{
return _handler.HasCallbacks(_runnable);
}

// Below API 29 we'll manually track whether there's a posted callback
return _hasCallbacks;
}

void SetHasCallbacks(bool hasCallbacks)
{
if (OperatingSystem.IsAndroidVersionAtLeast(29))
{
return;
}

// We only need to worry about tracking this if we're below API 29; after that,
// we can just ask the Handler with the HasCallBacks() method.
_hasCallbacks = hasCallbacks;
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/Core/tests/DeviceTests/Services/Dispatching/DispatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,45 @@ await InvokeOnMainThreadAsync(async () =>
});
}

[Fact]
public async Task TimerCannotDoubleItself()
{
// This is a test specifically for the situation in https://github.com/dotnet/maui/issues/10257
// where the user is calling the timer's Stop/Start methods from the Tick handler, and thus
// exponentially increasing the number of handlers.

await InvokeOnMainThreadAsync(async () =>
{
var dispatcher = Dispatcher.GetForCurrentThread();
var ticks = 0;
var timer = dispatcher.CreateTimer();
using var disposer = new TimerDisposer(timer);
Assert.False(timer.IsRunning);
timer.Interval = TimeSpan.FromMilliseconds(200);
timer.IsRepeating = true;
timer.Tick += (_, _) =>
{
ticks += 1;
timer.Stop();
timer.Start();
};
timer.Start();
await Task.Delay(TimeSpan.FromSeconds(1.1));
// The actual number may vary a bit depending on timing, but
// if the bug is present then ticks will be roughly 2^5, rather than
// the expected value of about 5
Assert.True(10 > ticks);
});
}

class TimerDisposer : IDisposable
{
IDispatcherTimer _timer;
Expand Down

0 comments on commit 6620b53

Please sign in to comment.