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
174 changes: 171 additions & 3 deletions lib/src/internet_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ typedef ConnectivityCheckCallback = Future<InternetCheckResult> Function(
/// will prevent memory leaks and free up resources.
///
/// ```dart
/// listener.cancel();
/// await listener.cancel();
/// ```
class InternetConnection {
/// Returns the singleton instance of [InternetConnection].
Expand Down Expand Up @@ -90,12 +90,52 @@ class InternetConnection {
this.enableStrictCheck = false,
this.customConnectivityCheck,
this.triggerStream,
this.useExponentialBackoff = false,
Duration? backoffInitialDelay,
Duration backoffMaxDelay = const Duration(seconds: 60),
double backoffMultiplier = 2.0,
}) : _checkInterval = checkInterval ?? _defaultCheckInterval,
_backoffInitialDelayExplicit = backoffInitialDelay != null,
_backoffInitialDelay =
_resolveInitialDelay(backoffInitialDelay, checkInterval),
_backoffMaxDelay = backoffMaxDelay,
_backoffMultiplier = backoffMultiplier,
_currentBackoffDelay =
_resolveInitialDelay(backoffInitialDelay, checkInterval),
assert(
useDefaultOptions || customCheckOptions?.isNotEmpty == true,
'You must provide a list of options if you are not using the '
'default ones.',
) {
Comment thread
mem-5514-tahara marked this conversation as resolved.
if (useExponentialBackoff) {
if (backoffMultiplier < 1.0) {
throw ArgumentError.value(
backoffMultiplier,
'backoffMultiplier',
'Must be >= 1.0 to prevent shrinking intervals.',
);
}
if (backoffMaxDelay <= Duration.zero) {
throw ArgumentError.value(
backoffMaxDelay,
'backoffMaxDelay',
'Must be greater than zero.',
);
}
if (_backoffInitialDelay <= Duration.zero) {
throw ArgumentError.value(
_backoffInitialDelay,
'backoffInitialDelay',
'backoffInitialDelay (or checkInterval if implicitly used) must be greater than zero.',
);
}
if (_backoffInitialDelay > _backoffMaxDelay) {
throw ArgumentError(
'backoffInitialDelay (or checkInterval if implicitly used) '
'must be less than or equal to backoffMaxDelay.',
);
}
}
_internetCheckOptions = List.unmodifiable([
if (useDefaultOptions) ..._defaultCheckOptions,
if (customCheckOptions != null) ...customCheckOptions,
Expand All @@ -111,6 +151,12 @@ class InternetConnection {
/// The default check interval duration.
static const _defaultCheckInterval = Duration(seconds: 10);

static Duration _resolveInitialDelay(
Duration? backoffInitialDelay,
Duration? checkInterval,
) =>
backoffInitialDelay ?? checkInterval ?? _defaultCheckInterval;

/// The default list of [Uri]s used for checking internet reachability.
static final _defaultCheckOptions = List<InternetCheckOption>.unmodifiable([
InternetCheckOption(uri: Uri.parse('https://one.one.one.one')),
Expand Down Expand Up @@ -162,12 +208,71 @@ class InternetConnection {
/// whenever it emits an event.
final Stream? triggerStream;

/// Whether exponential backoff is enabled for the polling interval.
///
/// When `true`, the polling interval grows on consecutive failures and resets
/// to [checkInterval] when the connection is restored.
///
/// Defaults to `false`.
final bool useExponentialBackoff;

/// Whether [_backoffInitialDelay] was explicitly provided by the caller.
///
/// When `false`, [_backoffInitialDelay] tracks [_checkInterval] so that
/// a [setIntervalAndResetTimer] call keeps both values in sync.
final bool _backoffInitialDelayExplicit;

/// The initial delay used on the first failure when backoff is enabled.
///
/// Defaults to [checkInterval]. Updated by [setIntervalAndResetTimer] when
/// no explicit value was provided at construction time.
Duration _backoffInitialDelay;

/// The upper bound on the backoff delay.
///
/// Defaults to 60 seconds.
final Duration _backoffMaxDelay;

/// The multiplicative factor applied to the delay on each consecutive failure.
///
/// Defaults to 2.0.
final double _backoffMultiplier;

/// Whether the backoff state was forcefully reset by an interval change.
bool _backoffNeedsReset = false;

/// The live backoff delay, updated each polling cycle when backoff is enabled.
///
/// Resets to [_backoffInitialDelay] on reconnect or subscription cancel.
Duration _currentBackoffDelay;

/// The last known internet connection status result.
InternetStatus? _lastStatus;

/// The handle for the timer used for periodic status checks.
Timer? _timerHandle;

/// Monotonically increasing counter bumped whenever an in-flight
/// [_maybeEmitStatusUpdate] must be invalidated: on [setIntervalAndResetTimer]
/// and on [_handleStatusChangeCancel].
///
/// Each [_maybeEmitStatusUpdate] invocation captures this value on entry.
/// Before mutating shared backoff state or scheduling the next timer it
/// checks that the value has not changed. A mismatch means this invocation
/// is stale — another caller already rescheduled and owns the next cycle.
int _generation = 0;

/// Monotonically increasing counter bumped only on [_handleStatusChangeCancel].
///
/// Captured before the [await internetStatus] gap and compared before
/// emitting. A mismatch means a cancel+resubscribe cycle happened while
/// the check was in-flight: the result belongs to the old subscription context
/// and must not be emitted to the new subscriber.
///
/// Unlike [_generation], this is NOT bumped by [setIntervalAndResetTimer],
/// because interval changes do not affect which subscriber owns the result.
int _cancelGeneration = 0;

/// Checks if the [Uri] specified in [option] is reachable.
///
/// Returns a [Future] that completes with an [InternetCheckResult] indicating
Expand Down Expand Up @@ -200,6 +305,14 @@ class InternetConnection {
/// resets the connection checking timer.
void setIntervalAndResetTimer(Duration duration) {
_checkInterval = duration;
if (useExponentialBackoff) {
// Keep _backoffInitialDelay in sync with the new checkInterval when the
// caller never provided an explicit backoffInitialDelay.
if (!_backoffInitialDelayExplicit) _backoffInitialDelay = duration;
_currentBackoffDelay = _backoffInitialDelay;
_backoffNeedsReset = true;
}
Comment thread
mem-5514-tahara marked this conversation as resolved.
_generation++;
_timerHandle?.cancel();
_timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate);
}
Comment thread
mem-5514-tahara marked this conversation as resolved.
Expand Down Expand Up @@ -258,28 +371,83 @@ class InternetConnection {
/// Updates the status and emits it if there are listeners.
Future<void> _maybeEmitStatusUpdate() async {
_timerHandle?.cancel();
final generation = _generation;
final cancelGeneration = _cancelGeneration;

if (!_statusController.hasListener) return;

// Snapshot before possible mutation below — needed to detect first-failure
// vs. ongoing-failure for backoff calculation.
final previousStatus = _lastStatus;

final currentStatus = await internetStatus;

if (_lastStatus != currentStatus && _statusController.hasListener) {
// Only emit if this result still belongs to the current subscription
// context. A cancel+resubscribe while we were awaiting bumps
// _cancelGeneration; the new subscriber owns its own fresh check.
if (_cancelGeneration == cancelGeneration &&
_lastStatus != currentStatus &&
_statusController.hasListener) {
_lastStatus = currentStatus;
_statusController.add(currentStatus);
}

_timerHandle = Timer(_checkInterval, _maybeEmitStatusUpdate);
if (!_statusController.hasListener) return;
// Guard before mutating shared backoff state: a setIntervalAndResetTimer
// call that arrived while we were awaiting internetStatus has already
// bumped _generation and scheduled its own timer. Mutating
// _currentBackoffDelay / _backoffNeedsReset here would silently overwrite
// the reset that setIntervalAndResetTimer applied.
if (_generation != generation) return;

Duration nextDelay;
if (useExponentialBackoff) {
if (currentStatus == InternetStatus.connected) {
_currentBackoffDelay = _backoffInitialDelay;
nextDelay = _checkInterval;
} else if (previousStatus != InternetStatus.disconnected ||
_backoffNeedsReset) {
// First failure: previousStatus is either null (first ever poll) or
// connected — both mean we have not yet been in a backoff streak.
// Also, if _backoffNeedsReset is true, we treat this as a first failure to
// reset the backoff delay, even if the previous status was already disconnected.
_backoffNeedsReset = false;
_currentBackoffDelay = _backoffInitialDelay > _backoffMaxDelay
? _backoffMaxDelay
: _backoffInitialDelay;
nextDelay = _currentBackoffDelay;
} else {
// Ongoing failure: grow the delay.
final ms =
(_currentBackoffDelay.inMilliseconds * _backoffMultiplier).round();
_currentBackoffDelay = Duration(
milliseconds: ms.clamp(0, _backoffMaxDelay.inMilliseconds).toInt(),
);
nextDelay = _currentBackoffDelay;
}
} else {
nextDelay = _checkInterval;
}

_timerHandle = Timer(nextDelay, _maybeEmitStatusUpdate);
}

/// Handles cancellation of status change events.
///
/// Cancels the timer and resets the last status.
void _handleStatusChangeCancel() {
// Not awaited: _handleStatusChangeCancel is a synchronous onCancel
// callback. Any event that fires before cancel propagates will see
// hasListener == false and return early from _maybeEmitStatusUpdate.
_triggerSubscription?.cancel();
_triggerSubscription = null;
_cancelGeneration++;
_generation++;
_timerHandle?.cancel();
_timerHandle = null;
_lastStatus = null;
_backoffNeedsReset = false;
if (useExponentialBackoff) _currentBackoffDelay = _backoffInitialDelay;
}

/// The result of the last attempt to check the internet status.
Expand Down
Loading