518 lines
18 KiB
Dart
518 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
|
import 'download_manager_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/platform_detector.dart';
|
|
|
|
/// Why background downloads are expected to fail on this device.
|
|
///
|
|
/// Ordered by how actionable the remedy is; [BackgroundWorkStatus.reasons]
|
|
/// preserves the order the native classifier emits.
|
|
enum BackgroundWorkReason {
|
|
/// AOSP per-app "Restricted" background usage. Samsung's "Background usage
|
|
/// limits" (Sleeping apps / Deep sleeping apps) lands here too.
|
|
backgroundRestricted('background_restricted'),
|
|
|
|
/// App standby bucket is RESTRICTED or NEVER — deferred indefinitely.
|
|
standbyRestricted('standby_restricted'),
|
|
|
|
/// The downloader's notification channel is muted, which removes the
|
|
/// foreground-service anchor keeping downloads alive across screen-off.
|
|
downloadChannelBlocked('download_channel_blocked'),
|
|
|
|
/// Notifications are off app-wide.
|
|
notificationsDisabled('notifications_disabled'),
|
|
|
|
/// Data Saver blocks metered background traffic.
|
|
dataSaver('data_saver'),
|
|
|
|
/// No OS-visible restriction, but downloads empirically stop making progress
|
|
/// whenever the app is backgrounded. Covers OEMs that block background work
|
|
/// while reporting a clean bucket.
|
|
oemUnknown('oem_unknown');
|
|
|
|
const BackgroundWorkReason(this.id);
|
|
|
|
final String id;
|
|
|
|
static BackgroundWorkReason? fromId(String id) {
|
|
for (final reason in values) {
|
|
if (reason.id == id) return reason;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Severity of the detected restrictions.
|
|
enum BackgroundWorkVerdict {
|
|
/// Nothing detected, or the platform cannot tell us.
|
|
ok,
|
|
|
|
/// Downloads will run, but not under every network condition.
|
|
degraded,
|
|
|
|
/// Downloads are expected to stall the moment the app leaves the foreground.
|
|
blocked,
|
|
}
|
|
|
|
/// Settings screen a remedy button routes to. Mirrors `BackgroundSettingsTarget`.
|
|
enum BackgroundSettingsTarget {
|
|
appDetails('app_details'),
|
|
appNotifications('app_notifications'),
|
|
notificationChannel('notification_channel');
|
|
|
|
const BackgroundSettingsTarget(this.id);
|
|
|
|
final String id;
|
|
}
|
|
|
|
/// Immutable result of one probe.
|
|
@immutable
|
|
class BackgroundWorkStatus {
|
|
const BackgroundWorkStatus({
|
|
this.verdict = BackgroundWorkVerdict.ok,
|
|
this.reasons = const [],
|
|
this.standbyBucket,
|
|
this.ignoringBatteryOptimizations,
|
|
this.probed = false,
|
|
});
|
|
|
|
final BackgroundWorkVerdict verdict;
|
|
final List<BackgroundWorkReason> reasons;
|
|
final int? standbyBucket;
|
|
final bool? ignoringBatteryOptimizations;
|
|
|
|
/// False before the first successful native probe (and on every non-Android
|
|
/// platform), so callers can distinguish "healthy" from "never asked".
|
|
final bool probed;
|
|
|
|
bool get isBlocked => verdict == BackgroundWorkVerdict.blocked;
|
|
bool get isHealthy => verdict == BackgroundWorkVerdict.ok;
|
|
|
|
/// The reason a single-line summary should lead with, if any.
|
|
BackgroundWorkReason? get primaryReason => reasons.isEmpty ? null : reasons.first;
|
|
|
|
/// Which Settings screen best addresses [primaryReason].
|
|
BackgroundSettingsTarget get remedyTarget => switch (primaryReason) {
|
|
BackgroundWorkReason.notificationsDisabled => BackgroundSettingsTarget.appNotifications,
|
|
BackgroundWorkReason.downloadChannelBlocked => BackgroundSettingsTarget.notificationChannel,
|
|
_ => BackgroundSettingsTarget.appDetails,
|
|
};
|
|
|
|
BackgroundWorkStatus withReason(BackgroundWorkReason reason) {
|
|
if (reasons.contains(reason)) return this;
|
|
return BackgroundWorkStatus(
|
|
verdict: BackgroundWorkVerdict.blocked,
|
|
reasons: [...reasons, reason],
|
|
standbyBucket: standbyBucket,
|
|
ignoringBatteryOptimizations: ignoringBatteryOptimizations,
|
|
probed: probed,
|
|
);
|
|
}
|
|
|
|
/// Compact form for the log upload header — the whole point of Phase 2 is
|
|
/// that a support thread can be answered from one uploaded log.
|
|
String describe() {
|
|
if (!probed) return 'unknown';
|
|
final parts = <String>[
|
|
verdict.name,
|
|
if (reasons.isNotEmpty) reasons.map((r) => r.id).join('+'),
|
|
if (standbyBucket != null) 'bucket:$standbyBucket',
|
|
if (ignoringBatteryOptimizations != null) 'batteryWhitelist:$ignoringBatteryOptimizations',
|
|
];
|
|
return parts.join(' ');
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is BackgroundWorkStatus &&
|
|
other.verdict == verdict &&
|
|
listEquals(other.reasons, reasons) &&
|
|
other.standbyBucket == standbyBucket &&
|
|
other.ignoringBatteryOptimizations == ignoringBatteryOptimizations &&
|
|
other.probed == probed;
|
|
|
|
@override
|
|
int get hashCode =>
|
|
Object.hash(verdict, Object.hashAll(reasons), standbyBucket, ignoringBatteryOptimizations, probed);
|
|
}
|
|
|
|
/// Aggregate process-wide download activity at a single instant.
|
|
typedef DownloadActivitySnapshot = ({
|
|
int activeTasks,
|
|
int completedTasks,
|
|
int failedTasks,
|
|
int downloadedBytes,
|
|
int progressUnits,
|
|
bool? networkAvailable,
|
|
int networkStateGeneration,
|
|
});
|
|
|
|
typedef DownloadStallObservation = ({
|
|
DownloadActivitySnapshot paused,
|
|
DownloadActivitySnapshot resumed,
|
|
Duration backgroundGap,
|
|
});
|
|
|
|
/// Detects OEMs that silently kill background work while reporting a clean
|
|
/// standby bucket (Xiaomi, Oppo, Vivo, Huawei), by watching whether downloads
|
|
/// actually advance while the app sits in the background.
|
|
///
|
|
/// Pure and clock-injected so the whole state machine is unit-testable.
|
|
class DownloadStallDetector {
|
|
DownloadStallDetector({
|
|
this.minimumBackgroundGap = const Duration(seconds: 60),
|
|
this.minimumProgressBytes = 64 * 1024,
|
|
this.observationsToConfirm = 2,
|
|
});
|
|
|
|
/// Shorter background trips prove nothing — the OS grants a grace period
|
|
/// before it starts deferring work.
|
|
final Duration minimumBackgroundGap;
|
|
|
|
/// Below this, "progress" is indistinguishable from a buffer flush.
|
|
final int minimumProgressBytes;
|
|
|
|
/// Two independent observations before accusing the OEM, so one flaky
|
|
/// network blip cannot produce a false warning.
|
|
final int observationsToConfirm;
|
|
|
|
DownloadActivitySnapshot? _pausedSnapshot;
|
|
DateTime? _pausedAt;
|
|
int _consecutiveStalls = 0;
|
|
|
|
int get consecutiveStalls => _consecutiveStalls;
|
|
|
|
bool get isStalled => _consecutiveStalls >= observationsToConfirm;
|
|
|
|
/// Arms the detector. Only meaningful while something is actually downloading.
|
|
void onPaused(DownloadActivitySnapshot snapshot, DateTime at) {
|
|
if (snapshot.activeTasks <= 0) {
|
|
_clearArmedSnapshot();
|
|
return;
|
|
}
|
|
_pausedSnapshot = snapshot;
|
|
_pausedAt = at;
|
|
}
|
|
|
|
/// Captures the resume state synchronously, before any awaited platform or
|
|
/// settings work can allow later progress to leak into this observation.
|
|
DownloadStallObservation? captureResumed(DownloadActivitySnapshot snapshot, DateTime at) {
|
|
final pausedSnapshot = _pausedSnapshot;
|
|
final pausedAt = _pausedAt;
|
|
_clearArmedSnapshot();
|
|
if (pausedSnapshot == null || pausedAt == null) return null;
|
|
return (paused: pausedSnapshot, resumed: snapshot, backgroundGap: at.difference(pausedAt));
|
|
}
|
|
|
|
/// Scores a captured background trip. Returns true only when this trip has
|
|
/// just confirmed a repeated OEM stall.
|
|
bool evaluate(DownloadStallObservation? observation, {required bool networkAvailable}) {
|
|
if (observation == null) return false;
|
|
final pausedSnapshot = observation.paused;
|
|
final snapshot = observation.resumed;
|
|
|
|
final completedInBackground = snapshot.completedTasks > pausedSnapshot.completedTasks;
|
|
final byteDelta = snapshot.downloadedBytes - pausedSnapshot.downloadedBytes;
|
|
final progressDelta = snapshot.progressUnits - pausedSnapshot.progressUnits;
|
|
|
|
// Real transfer progress clears prior evidence even when the network or
|
|
// policy check below is inconclusive. A terminal event may omit byte data,
|
|
// so completion and percentage progress are independent evidence.
|
|
if (completedInBackground || byteDelta >= minimumProgressBytes || progressDelta > 0) {
|
|
_consecutiveStalls = 0;
|
|
return false;
|
|
}
|
|
|
|
// Removed rows and other backwards counters are not evidence of a stall.
|
|
if (byteDelta < 0 || progressDelta < 0) return false;
|
|
if (observation.backgroundGap < minimumBackgroundGap) return false;
|
|
|
|
// Require known-good endpoints, no network/server transition anywhere in
|
|
// the background window, and an eligible download policy at resume.
|
|
if (!networkAvailable ||
|
|
pausedSnapshot.networkAvailable != true ||
|
|
snapshot.networkAvailable != true ||
|
|
snapshot.networkStateGeneration != pausedSnapshot.networkStateGeneration) {
|
|
return false;
|
|
}
|
|
|
|
final activeTasksDropped = snapshot.activeTasks < pausedSnapshot.activeTasks;
|
|
final taskFailed = snapshot.failedTasks > pausedSnapshot.failedTasks;
|
|
if (activeTasksDropped && !taskFailed) {
|
|
// A paused/cancelled/removed task is not evidence of an OEM restriction.
|
|
return false;
|
|
}
|
|
|
|
final wasConfirmed = isStalled;
|
|
_consecutiveStalls++;
|
|
return !wasConfirmed && isStalled;
|
|
}
|
|
|
|
/// Convenience wrapper for direct callers and unit tests.
|
|
bool onResumed(DownloadActivitySnapshot snapshot, DateTime at, {required bool networkAvailable}) {
|
|
return evaluate(captureResumed(snapshot, at), networkAvailable: networkAvailable);
|
|
}
|
|
|
|
void reset() {
|
|
_clearArmedSnapshot();
|
|
_consecutiveStalls = 0;
|
|
}
|
|
|
|
void _clearArmedSnapshot() {
|
|
_pausedSnapshot = null;
|
|
_pausedAt = null;
|
|
}
|
|
}
|
|
|
|
/// Probes the OS for reasons background downloads will not run, and watches for
|
|
/// the symptom on OEMs that will not admit to it.
|
|
///
|
|
/// Every failure path resolves to [BackgroundWorkVerdict.ok]: a warning we
|
|
/// cannot substantiate trains users to ignore the ones we can.
|
|
class BackgroundWorkDiagnosticsService extends ChangeNotifier
|
|
with WidgetsBindingObserver, DisposableChangeNotifierMixin {
|
|
BackgroundWorkDiagnosticsService._()
|
|
: _channelOverride = null,
|
|
_clockOverride = null,
|
|
_networkProbeOverride = null,
|
|
_supportedOverride = null;
|
|
|
|
@visibleForTesting
|
|
BackgroundWorkDiagnosticsService.forTesting({
|
|
MethodChannel? channel,
|
|
DownloadStallDetector? stallDetector,
|
|
DateTime Function()? clock,
|
|
Future<bool> Function()? networkProbe,
|
|
bool supported = true,
|
|
}) : _channelOverride = channel,
|
|
_stallDetector = stallDetector ?? DownloadStallDetector(),
|
|
_clockOverride = clock,
|
|
_networkProbeOverride = networkProbe,
|
|
_supportedOverride = supported;
|
|
|
|
static final BackgroundWorkDiagnosticsService instance = BackgroundWorkDiagnosticsService._();
|
|
|
|
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
|
|
|
final MethodChannel? _channelOverride;
|
|
final DateTime Function()? _clockOverride;
|
|
final Future<bool> Function()? _networkProbeOverride;
|
|
final bool? _supportedOverride;
|
|
|
|
DownloadStallDetector _stallDetector = DownloadStallDetector();
|
|
DownloadActivitySnapshot Function()? _activitySource;
|
|
BackgroundWorkStatus _status = const BackgroundWorkStatus();
|
|
BackgroundWorkStatus _lastProbedStatus = const BackgroundWorkStatus();
|
|
Future<void>? _inFlightRefresh;
|
|
bool _observing = false;
|
|
bool _channelMissing = false;
|
|
|
|
MethodChannel get _channel => _channelOverride ?? _deviceChannel;
|
|
|
|
DateTime _now() => (_clockOverride ?? DateTime.now)();
|
|
|
|
/// Android phone/tablet only. TV boxes are mains-powered, and surfacing
|
|
/// mobile battery/data restrictions there is noise.
|
|
bool get isSupported => _supportedOverride ?? (Platform.isAndroid && !PlatformDetector.isTV());
|
|
|
|
BackgroundWorkStatus get status => _status;
|
|
|
|
/// One-line summary for the log upload header.
|
|
String describeSync() => isSupported ? _status.describe() : 'n/a';
|
|
|
|
/// Lets the download layer publish aggregate progress without this service
|
|
/// reaching into providers.
|
|
void bindActivitySource(DownloadActivitySnapshot Function() source) {
|
|
_activitySource = source;
|
|
_startObserving();
|
|
// Populate the banner, Settings tile, and log header on a cold start.
|
|
// Concurrent profile binds coalesce through [refresh].
|
|
unawaited(refresh());
|
|
}
|
|
|
|
void unbindActivitySource(DownloadActivitySnapshot Function() source) {
|
|
// Compared by `==` rather than `identical` so an instance-method tear-off
|
|
// from the same provider matches on every SDK.
|
|
if (_activitySource != source) return;
|
|
_activitySource = null;
|
|
}
|
|
|
|
void _startObserving() {
|
|
if (_observing || !isSupported) return;
|
|
final binding = WidgetsBinding.instance;
|
|
_observing = true;
|
|
binding.addObserver(this);
|
|
}
|
|
|
|
/// Re-probes the OS. Coalesces concurrent calls — the bucket is cheap to read
|
|
/// but there is no reason to read it three times on a single resume.
|
|
Future<BackgroundWorkStatus> refresh() async {
|
|
if (!isSupported || _channelMissing || isDisposed) return _status;
|
|
final inFlight = _inFlightRefresh;
|
|
if (inFlight != null) {
|
|
await inFlight;
|
|
return _status;
|
|
}
|
|
final refresh = _refresh();
|
|
_inFlightRefresh = refresh;
|
|
try {
|
|
await refresh;
|
|
} finally {
|
|
if (identical(_inFlightRefresh, refresh)) _inFlightRefresh = null;
|
|
}
|
|
return _status;
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
try {
|
|
final raw = await _channel.invokeMapMethod<String, Object?>('getBackgroundWorkSignals');
|
|
if (isDisposed) return;
|
|
if (raw == null) return;
|
|
_applyStatus(_parse(raw));
|
|
} on MissingPluginException {
|
|
// Stale native build — never warn on a signal we could not read.
|
|
_channelMissing = true;
|
|
} catch (e) {
|
|
appLogger.d('Background work probe failed: $e');
|
|
}
|
|
}
|
|
|
|
BackgroundWorkStatus _parse(Map<String, Object?> raw) {
|
|
final verdict = switch (raw['verdict']) {
|
|
'blocked' => BackgroundWorkVerdict.blocked,
|
|
'degraded' => BackgroundWorkVerdict.degraded,
|
|
_ => BackgroundWorkVerdict.ok,
|
|
};
|
|
final reasons = <BackgroundWorkReason>[];
|
|
final rawReasons = raw['reasons'];
|
|
if (rawReasons is List) {
|
|
for (final entry in rawReasons) {
|
|
if (entry is! String) continue;
|
|
final reason = BackgroundWorkReason.fromId(entry);
|
|
// oem_unknown is ours, never the platform's — refuse it over the wire.
|
|
if (reason != null && reason != BackgroundWorkReason.oemUnknown) reasons.add(reason);
|
|
}
|
|
}
|
|
final bucket = raw['standbyBucket'];
|
|
final whitelisted = raw['ignoringBatteryOptimizations'];
|
|
return BackgroundWorkStatus(
|
|
// Trust our own reason parse over the reported verdict: an unknown reason
|
|
// id from a newer native build must not silently downgrade to blocked.
|
|
verdict: reasons.isEmpty ? BackgroundWorkVerdict.ok : verdict,
|
|
reasons: reasons,
|
|
standbyBucket: bucket is int ? bucket : null,
|
|
ignoringBatteryOptimizations: whitelisted is bool ? whitelisted : null,
|
|
probed: true,
|
|
);
|
|
}
|
|
|
|
void _applyStatus(BackgroundWorkStatus probed) {
|
|
if (isDisposed) return;
|
|
_lastProbedStatus = probed;
|
|
_applyCurrentStatus();
|
|
}
|
|
|
|
void _applyCurrentStatus() {
|
|
// A confirmed empirical stall outlives any single probe: the OS keeps
|
|
// reporting healthy on exactly the devices this catches.
|
|
final next = _stallDetector.isStalled
|
|
? _lastProbedStatus.withReason(BackgroundWorkReason.oemUnknown)
|
|
: _lastProbedStatus;
|
|
if (next == _status) return;
|
|
final previous = _status;
|
|
_status = next;
|
|
if (previous.verdict != next.verdict || !listEquals(previous.reasons, next.reasons)) {
|
|
appLogger.i('Background work status: ${next.describe()}');
|
|
}
|
|
safeNotifyListeners();
|
|
}
|
|
|
|
/// Opens the Settings screen that addresses [target]. Returns false when the
|
|
/// device has no such screen, so the caller can fall back to written steps.
|
|
Future<bool> openSettings(BackgroundSettingsTarget target) async {
|
|
if (!isSupported) return false;
|
|
try {
|
|
return await _channel.invokeMethod<bool>('openBackgroundSettings', target.id) ?? false;
|
|
} catch (e) {
|
|
appLogger.w('Opening background settings failed', error: e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Clears an empirical stall verdict after the user reports having fixed it.
|
|
Future<void> clearStallEvidence() async {
|
|
if (isDisposed) return;
|
|
_stallDetector.reset();
|
|
_applyCurrentStatus();
|
|
await refresh();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final source = _activitySource;
|
|
switch (state) {
|
|
case AppLifecycleState.paused:
|
|
case AppLifecycleState.detached:
|
|
if (source != null) _stallDetector.onPaused(source(), _now());
|
|
case AppLifecycleState.resumed:
|
|
final snapshot = source?.call();
|
|
final observation = snapshot == null ? null : _stallDetector.captureResumed(snapshot, _now());
|
|
unawaited(_onResumed(observation));
|
|
case AppLifecycleState.inactive:
|
|
case AppLifecycleState.hidden:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _onResumed(DownloadStallObservation? observation) async {
|
|
try {
|
|
if (observation != null) {
|
|
final networkAvailable = await _networkAllowsStallScoring();
|
|
if (isDisposed) return;
|
|
final wasStalled = _stallDetector.isStalled;
|
|
final confirmed = _stallDetector.evaluate(observation, networkAvailable: networkAvailable);
|
|
if (wasStalled && !_stallDetector.isStalled) _applyCurrentStatus();
|
|
if (confirmed) {
|
|
appLogger.w(
|
|
'Background downloads made no progress across ${_stallDetector.observationsToConfirm} '
|
|
'backgrounded periods — treating as an OEM background restriction',
|
|
);
|
|
}
|
|
}
|
|
await refresh();
|
|
} catch (e) {
|
|
appLogger.d('Background work resume check failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<bool> _networkAllowsStallScoring() async {
|
|
final probe = _networkProbeOverride;
|
|
if (probe != null) return probe();
|
|
try {
|
|
final connectivity = await Connectivity().checkConnectivity();
|
|
if (connectivity.isEmpty || connectivity.contains(ConnectivityResult.none)) return false;
|
|
return !await DownloadManagerService.shouldBlockDownloadOnCellularWith(connectivity);
|
|
} catch (_) {
|
|
// Unknown connectivity or policy is inconclusive, not proof of a stall.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_observing) {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_observing = false;
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|