Skip to content

Commit be6e056

Browse files
committed
fix(location): record movement history on Android
Platform: android Fix(zh-Hant): 修正 Android 上允許背景位置之後仍然沒有移動軌跡 Fix(en-US): fix movement history staying empty on Android after background location is allowed
1 parent 1095e24 commit be6e056

3 files changed

Lines changed: 95 additions & 3 deletions

File tree

‎android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ object LocationTrackStore {
128128
// Before the table exists: set afterwards it is a no-op until a full
129129
// VACUUM, and without it deleted rows free pages inside the file and
130130
// never give the bytes back to the budget.
131-
handle.execSQL("PRAGMA auto_vacuum=INCREMENTAL")
132-
handle.execSQL("PRAGMA journal_mode=WAL")
131+
queryPragma(handle, "PRAGMA auto_vacuum=INCREMENTAL")
132+
queryPragma(handle, "PRAGMA journal_mode=WAL")
133133
// A fix lost to a power cut is one point on a track; a blocked fsync
134134
// inside a background wake can cost the report as well.
135-
handle.execSQL("PRAGMA synchronous=NORMAL")
135+
queryPragma(handle, "PRAGMA synchronous=NORMAL")
136136
handle.execSQL(
137137
"""
138138
CREATE TABLE IF NOT EXISTS fix (
@@ -152,6 +152,25 @@ object LocationTrackStore {
152152

153153
private data class Fix(val t: Long, val lat: Long, val lng: Long)
154154

155+
/**
156+
* Runs a PRAGMA that returns a row, and reads that row.
157+
*
158+
* [SQLiteDatabase.execSQL] rejects a statement that produces a result.
159+
* `PRAGMA journal_mode=WAL` does — it answers with the mode it just set —
160+
* and Android throws "Queries can be performed using SQLiteDatabase query
161+
* or rawQuery methods only." [open] caught that and returned null, but
162+
* [SQLiteDatabase.openOrCreateDatabase] had already created the file, so
163+
* the `fix` table was never created. The Dart reader then opened a
164+
* database with no table and reported `no such table: fix`, and every
165+
* later fix failed the same way: the history stayed empty.
166+
*
167+
* The cursor has to be stepped. A query that is never read is a query
168+
* that may not have run.
169+
*/
170+
private fun queryPragma(handle: SQLiteDatabase, sql: String) {
171+
handle.rawQuery(sql, null).use { it.moveToFirst() }
172+
}
173+
155174
private fun scalar(handle: SQLiteDatabase, sql: String): Long =
156175
handle.rawQuery(sql, null).use { if (it.moveToFirst()) it.getLong(0) else 0L }
157176

‎lib/core/geo/location_track.dart‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ class LocationTrack {
128128
/// file costs disk.
129129
Future<List<TrackFix>> since(DateTime from, {int? limit}) async {
130130
try {
131+
// No table is an empty history. See [_fixTableQuery].
132+
if (!await _hasFixTable()) return const [];
131133
final start = await _anchorAtOrBefore(from);
132134
final fixes = await _decodeFrom(start);
133135
final wanted = fixes.where((fix) => !fix.time.isBefore(from)).toList();
@@ -152,6 +154,15 @@ class LocationTrack {
152154
/// reading the decoder gives it.
153155
Future<({int fixes, int bytes, DateTime? oldest})> stats() async {
154156
try {
157+
// See [_fixTableQuery]: no table is an empty history, not a crash.
158+
if (!await _hasFixTable()) {
159+
final file = File(_path);
160+
return (
161+
fixes: 0,
162+
bytes: file.existsSync() ? file.lengthSync() : 0,
163+
oldest: null,
164+
);
165+
}
155166
final row = await _db.get('SELECT COUNT(*) AS n FROM fix');
156167
final first = await _db.getOptional(
157168
'SELECT t FROM fix ORDER BY id LIMIT 1',
@@ -195,6 +206,9 @@ class LocationTrack {
195206
if (!File(path).existsSync()) return;
196207
final db = sqlite3.open(path, mode: OpenMode.readOnly);
197208
try {
209+
// See [_fixTableQuery]. Throwing here is how the digest becomes a
210+
// crash report.
211+
if (db.select(_fixTableQuery).isEmpty) return;
198212
final fromMs = from.millisecondsSinceEpoch;
199213
final anchor = db.select(_anchorQuery, [fromMs ~/ 1000]);
200214
final start = anchor.isEmpty
@@ -225,6 +239,20 @@ class LocationTrack {
225239
}
226240
}
227241

242+
/// A file with no `fix` table is an empty history, the same answer as no
243+
/// file.
244+
///
245+
/// Android creates the file in `openOrCreateDatabase` and only then runs
246+
/// the pragmas and `CREATE TABLE`. `execSQL` rejects
247+
/// `PRAGMA journal_mode=WAL` because that pragma returns a row; the
248+
/// exception was swallowed and the table was never created, so every read
249+
/// reported `no such table: fix`.
250+
static const _fixTableQuery =
251+
"SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'fix'";
252+
253+
Future<bool> _hasFixTable() async =>
254+
(await _db.getOptional(_fixTableQuery)) != null;
255+
228256
/// The newest anchor at or before an instant (see [_anchorAtOrBefore]).
229257
static const _anchorQuery =
230258
'SELECT id FROM fix WHERE id % $_anchorEvery = 0 AND t <= ? '

‎test/core/geo/location_track_test.dart‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ library;
1212
import 'dart:io';
1313

1414
import 'package:dpip/core/geo/location_track.dart';
15+
import 'package:dpip/core/logging/crash_sink.dart';
16+
import 'package:dpip/core/logging/log.dart';
1517
import 'package:flutter_test/flutter_test.dart';
1618
import 'package:sqlite3/sqlite3.dart' as sqlite3;
1719

@@ -38,6 +40,35 @@ void main() {
3840
expect(LocationTrack.at(path), isNull);
3941
});
4042

43+
test(
44+
'a database with no fix table is an empty history, not a crash',
45+
() async {
46+
// What Android left behind: openOrCreateDatabase created the file, then
47+
// execSQL("PRAGMA journal_mode=WAL") threw before CREATE TABLE. The
48+
// pragma writes the header, so the file is a database and not empty.
49+
final created = sqlite3.sqlite3.open(path);
50+
created.execute('PRAGMA user_version = 0');
51+
created.close();
52+
final track = LocationTrack.at(path)!;
53+
addTearDown(track.close);
54+
55+
final sink = _RecordingSink();
56+
Log.crashSink = sink;
57+
addTearDown(() => Log.crashSink = null);
58+
59+
final stats = await track.stats();
60+
expect(stats.fixes, 0);
61+
expect(stats.oldest, isNull);
62+
expect(stats.bytes, greaterThan(0));
63+
expect(await track.since(DateTime.utc(2000)), isEmpty);
64+
65+
final visited = <int>[];
66+
LocationTrack.scan(path, DateTime.utc(2000), (t, _, _) => visited.add(t));
67+
expect(visited, isEmpty);
68+
expect(sink.reports, isEmpty);
69+
},
70+
);
71+
4172
test('a single fix round-trips', () async {
4273
writeTrackFixture(path, [(1735689600, 25.0330, 121.5654)]);
4374
final track = LocationTrack.at(path)!;
@@ -267,3 +298,17 @@ void main() {
267298
);
268299
});
269300
}
301+
302+
class _RecordingSink implements CrashSink {
303+
final List<Object> reports = [];
304+
305+
@override
306+
void report(
307+
Object error,
308+
StackTrace stackTrace, {
309+
String? context,
310+
bool fatal = false,
311+
}) {
312+
reports.add(error);
313+
}
314+
}

0 commit comments

Comments
 (0)