updated the sheet
This commit is contained in:
@@ -11,7 +11,7 @@ import 'package:miler/data/miler_api.dart';
|
||||
///
|
||||
/// A `_buildSslBypassClient()` that returned an `IOClient` whose
|
||||
/// `badCertificateCallback` returned `true` for every certificate, pinned a
|
||||
/// hardcoded IP for `queue.workolik.com`, and hand-rolled its own
|
||||
/// hardcoded IP for the retired backend's host, and hand-rolled its own
|
||||
/// `SecureSocket` upgrade. It existed to work around a carrier's broken DNS on
|
||||
/// a backend that is no longer deployed — and it meant every request it carried
|
||||
/// could be read and rewritten by anything on the path, including a rider's GPS
|
||||
@@ -33,6 +33,132 @@ String _s(dynamic v) => v == null ? '' : v.toString();
|
||||
|
||||
double? _d(dynamic v) => double.tryParse(_s(v));
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHY THE CONSOLE'S DEVICE PANEL WAS EMPTY
|
||||
///
|
||||
/// Not the telemetry. [DeviceTelemetry] reads the handset correctly, its
|
||||
/// payload carries every field the console draws, and every caller spreads it
|
||||
/// into the heartbeat. The reading was right and it never left the phone.
|
||||
///
|
||||
/// [_heartbeat] is the only thing that posts `POST /miler/logs`, and it is also
|
||||
/// the only thing that writes `PUT /miler/location`. Both were unreachable,
|
||||
/// because the two entry points below gate on one locally cached number:
|
||||
///
|
||||
/// ```
|
||||
/// if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
|
||||
/// return _heartbeat(data);
|
||||
/// ```
|
||||
///
|
||||
/// `dutylogid` is written in exactly one place — a successful
|
||||
/// `POST /miler/duty/start` — and it is zeroed on going off duty. So there are
|
||||
/// two ordinary ways to end up on duty with a zero, and both are permanent:
|
||||
///
|
||||
/// • **The server already has the rider on duty.** `duty/start` answers 400,
|
||||
/// which [_startDuty] correctly treats as a reconciliation rather than a
|
||||
/// failure and delegates to [_currentDuty] — which read `dutylogid` out of
|
||||
/// the response and wrote it to `logid` and `logId` **and not to
|
||||
/// `dutylogid`**. The gate stayed shut. Next tick: `duty/start` again, 400
|
||||
/// again, reconcile again, still shut. Forever, and silently, because
|
||||
/// nothing in that loop is an error. It happens after a reinstall, after
|
||||
/// cleared storage, after an `endDuty` that never landed, and on any fresh
|
||||
/// login for a rider the backend still has on shift.
|
||||
///
|
||||
/// • **The response spells the id differently.** [_startDuty] read one key on
|
||||
/// one level. Anything else — `logid`, `id`, a top-level field rather than
|
||||
/// one under `data` — parsed to zero and was *stored* as zero, which puts a
|
||||
/// rider into the same permanent loop on his very first shift. This codebase
|
||||
/// has been bitten by exactly this before; see `AuthProvider._tokenIn`,
|
||||
/// which looks in nine places for the bearer for precisely this reason.
|
||||
///
|
||||
/// ── The design error underneath both ──
|
||||
///
|
||||
/// `POST /miler/logs` does not take a duty log id. Its contract is
|
||||
/// `{logdate, latitude, longitude, battery, is_charging, connection,
|
||||
/// location_service, accuracy, is_background}` — nothing about duty. Gating
|
||||
/// telemetry on an identifier the endpoint does not want, that the backend is
|
||||
/// not obliged to return, is what turned one unmapped field into total
|
||||
/// silence on both writes.
|
||||
///
|
||||
/// So the gate is now *"has duty been established"* — a fact — rather than
|
||||
/// *"do we happen to hold a non-zero id"*, which is an artefact. The id is
|
||||
/// still stored when it arrives, because [_dutyEnvelope] and the legacy duty
|
||||
/// readers want it; it simply no longer decides whether a rider reports.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Set once duty is known to be on, and cleared when it goes off.
|
||||
///
|
||||
/// Deliberately separate from `dutylogid`: this answers "should the heartbeat
|
||||
/// run", which is a question about duty, and the id answers "which duty log
|
||||
/// row", which is a question about the backend's bookkeeping. Conflating them
|
||||
/// is the bug above.
|
||||
const String _kDutyEstablished = 'duty_established';
|
||||
|
||||
/// Every spelling and nesting the duty endpoints have used for the log id.
|
||||
///
|
||||
/// Ordered by how the current contract answers, then by what deployments have
|
||||
/// been seen to send. A miss is no longer fatal — see [_dutyEstablished] — but
|
||||
/// finding the id keeps the legacy `logid` readers correct.
|
||||
const List<String> _dutyLogIdKeys = [
|
||||
'dutylogid',
|
||||
'dutyLogId',
|
||||
'duty_log_id',
|
||||
'logid',
|
||||
'logId',
|
||||
'log_id',
|
||||
'id',
|
||||
];
|
||||
|
||||
/// The duty log id anywhere in [res], or `0`.
|
||||
///
|
||||
/// Looks in the envelope's `data` and in the undecorated body, because a
|
||||
/// handler that returns `{success, dutylogid}` and one that returns
|
||||
/// `{success, data:{dutylogid}}` are both shapes this contract has worn.
|
||||
int _dutyLogIdIn(ApiResult res) {
|
||||
final sources = <Map>[res.map, if (res.raw is Map) res.raw as Map];
|
||||
for (final source in sources) {
|
||||
for (final key in _dutyLogIdKeys) {
|
||||
final v = source[key];
|
||||
if (v == null) continue;
|
||||
final n = int.tryParse(v.toString().trim());
|
||||
if (n != null && n > 0) return n;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Records that the rider is on duty, and the id if the backend sent one.
|
||||
///
|
||||
/// The flag is set whether or not there is an id, which is the whole fix: a
|
||||
/// rider whose duty the server has confirmed reports, even when the response
|
||||
/// did not name the row.
|
||||
Future<void> _rememberDuty(int dutyLogId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (dutyLogId > 0) {
|
||||
await prefs.setInt('logid', dutyLogId);
|
||||
await prefs.setInt('logId', dutyLogId);
|
||||
await prefs.setInt('dutylogid', dutyLogId);
|
||||
}
|
||||
await prefs.setBool(_kDutyEstablished, true);
|
||||
await prefs.setInt('onduty', 1);
|
||||
}
|
||||
|
||||
/// Forgets duty, on the way off shift.
|
||||
Future<void> _forgetDuty() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('dutylogid', 0);
|
||||
await prefs.setBool(_kDutyEstablished, false);
|
||||
}
|
||||
|
||||
/// Whether duty has been established, so the heartbeat should run.
|
||||
Future<bool> _dutyEstablished() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_kDutyEstablished) == true) return true;
|
||||
// An install that predates the flag: a stored id means duty was started by
|
||||
// an earlier build, and that rider must not have to go off and on again to
|
||||
// start reporting.
|
||||
return (prefs.getInt('dutylogid') ?? 0) > 0;
|
||||
}
|
||||
|
||||
/// Legacy `{status, details}` for the duty-state readers.
|
||||
Map<String, dynamic> _dutyEnvelope(Map data) {
|
||||
final bool on = data['onduty'] == true;
|
||||
@@ -54,11 +180,11 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
|
||||
lon: _d(data['longitude']),
|
||||
);
|
||||
if (res.ok) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
await prefs.setInt('dutylogid', id);
|
||||
// Looked at one key on one level and stored whatever it found, including
|
||||
// a zero — which shut the heartbeat's gate permanently on a response that
|
||||
// had merely spelled the id differently. See [_dutyLogIdIn].
|
||||
final id = _dutyLogIdIn(res);
|
||||
await _rememberDuty(id);
|
||||
return {
|
||||
'status': true,
|
||||
'code': 200,
|
||||
@@ -76,12 +202,19 @@ Future<Map<String, dynamic>?> _currentDuty() async {
|
||||
if (!res.ok) return {'status': false, 'code': res.status};
|
||||
|
||||
final on = res.map['onduty'] == true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('onduty', on ? 1 : 0);
|
||||
if (on) {
|
||||
final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0;
|
||||
await prefs.setInt('logid', id);
|
||||
await prefs.setInt('logId', id);
|
||||
// ── The line that was missing ──
|
||||
//
|
||||
// This wrote the id to `logid` and `logId` and stopped, leaving
|
||||
// `dutylogid` — the only key the heartbeat's gate reads — at zero. So the
|
||||
// reconciliation that exists to recover from "the server already has you
|
||||
// on duty" recovered the duty state and not the ability to report it, and
|
||||
// the next tick came straight back here. See the note at the top.
|
||||
await _rememberDuty(_dutyLogIdIn(res));
|
||||
} else {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('onduty', 0);
|
||||
await _forgetDuty();
|
||||
}
|
||||
return _dutyEnvelope(res.map);
|
||||
}
|
||||
@@ -108,11 +241,28 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
heading: _d(data['heading']),
|
||||
);
|
||||
|
||||
// Deliberately not awaited into the result: a telemetry failure must never
|
||||
// look like a location failure, because the caller treats the latter as duty
|
||||
// state going wrong.
|
||||
unawaited(
|
||||
MilerApi.postLog(
|
||||
// ── Awaited, but its outcome is still discarded ──
|
||||
//
|
||||
// This was `unawaited(...)`, for a good reason that turned out to be the
|
||||
// wrong mechanism: a telemetry failure must never read as a location failure,
|
||||
// because the caller treats the latter as duty state going wrong. `unawaited`
|
||||
// achieves that by never looking at the result — and also by not waiting for
|
||||
// the request to finish.
|
||||
//
|
||||
// On Android this heartbeat runs inside `flutter_foreground_task`'s **own
|
||||
// Flutter engine**, spun up per tick. When the callback returns, that engine
|
||||
// can be suspended before an in-flight future completes. So the awaited
|
||||
// `PUT /miler/location` above landed on every tick and this `POST /miler/logs`
|
||||
// was killed mid-flight — which is exactly what the backend saw: a rider with
|
||||
// a live position in Redis and no new telemetry row behind it, while the app
|
||||
// logged a successful heartbeat.
|
||||
//
|
||||
// Awaiting it costs one round trip on a call that already had to happen and
|
||||
// guarantees the request outlives the tick. The isolation the `unawaited` was
|
||||
// protecting is kept by the `try`: the result is logged and thrown away, and
|
||||
// nothing below branches on it.
|
||||
try {
|
||||
final logRes = await MilerApi.postLog(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
speed: _d(data['speed']),
|
||||
@@ -137,8 +287,18 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
|
||||
? null
|
||||
: _s(data['location_service']),
|
||||
isBackground: data['is_background'] == true,
|
||||
),
|
||||
);
|
||||
);
|
||||
// The one line that says whether telemetry reached the console. It was
|
||||
// silent on success *and* on failure, which is why a dropped post took
|
||||
// several rounds of backend archaeology to find.
|
||||
debugPrint(
|
||||
'[RIDERLOG][LOGS] POST /miler/logs -> ${logRes.status} '
|
||||
'ok=${logRes.ok} battery=${_s(data['battery'])} '
|
||||
'connection=${_s(data['connection'])}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[RIDERLOG][LOGS] POST /miler/logs failed: $e');
|
||||
}
|
||||
|
||||
return res.ok
|
||||
? ApiConfig.okEnvelope()
|
||||
@@ -158,13 +318,28 @@ bool _isOffDutyIntent(Map data) {
|
||||
|
||||
class CreateRiderLogProvider {
|
||||
/// The first call of a session starts duty; every later one is a heartbeat.
|
||||
///
|
||||
/// ── The start is no longer a tick the rider loses ──
|
||||
///
|
||||
/// This returned [_startDuty]'s envelope and stopped, so the tick that opened
|
||||
/// the shift carried no telemetry and no position — the console had nothing
|
||||
/// until the next one, thirty seconds later. Worse, when `duty/start`
|
||||
/// reconciled a rider the server already had on duty, the old gate never
|
||||
/// opened at all and *every* tick ended here. See the note at the top of this
|
||||
/// file.
|
||||
///
|
||||
/// Duty is established first, then the same payload beats immediately. The
|
||||
/// heartbeat is best-effort and its result is not merged into the duty
|
||||
/// envelope, because the caller reads that envelope for duty state and a
|
||||
/// telemetry failure is not a duty failure.
|
||||
Future<Map<String, dynamic>?> createRiderLog(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final int existing = prefs.getInt('dutylogid') ?? 0;
|
||||
if (existing <= 0) return _startDuty(data);
|
||||
return _heartbeat(data);
|
||||
if (await _dutyEstablished()) return _heartbeat(data);
|
||||
|
||||
final started = await _startDuty(data);
|
||||
if (await _dutyEstablished()) await _heartbeat(data);
|
||||
return started;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,19 +348,25 @@ class UpdateRiderLogProvider {
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
if (_isOffDutyIntent(data)) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('dutylogid', 0);
|
||||
await _forgetDuty();
|
||||
final res = await MilerApi.endDuty();
|
||||
if (res.ok) await prefs.setInt('onduty', 0);
|
||||
if (res.ok) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('onduty', 0);
|
||||
}
|
||||
return res.ok
|
||||
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
|
||||
: {'status': false, 'code': res.status};
|
||||
}
|
||||
|
||||
final onduty = data['onduty'];
|
||||
if (onduty == 1 || _s(onduty) == '1') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data);
|
||||
if ((onduty == 1 || _s(onduty) == '1') && !await _dutyEstablished()) {
|
||||
// Same shape as [CreateRiderLogProvider.createRiderLog]: establish duty,
|
||||
// then beat on the same tick rather than returning and leaving the
|
||||
// console with nothing for another interval.
|
||||
final started = await _startDuty(data);
|
||||
if (await _dutyEstablished()) await _heartbeat(data);
|
||||
return started;
|
||||
}
|
||||
return _heartbeat(data);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user