diff --git a/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt index 3b195406d..09f70a61b 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/BackgroundLocationChannel.kt @@ -116,6 +116,16 @@ class BackgroundLocationChannel(private val context: Context) : }.start() } + // Off the main thread: this opens the database and vacuums it, and + // the caller re-reads the count as soon as it returns. + "clearTrack" -> { + val app = context.applicationContext + Thread { + LocationTrackStore.clear(app) + Handler(Looper.getMainLooper()).post { result.success(null) } + }.start() + } + else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt index d9fb02c6d..fd3ed9a42 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt @@ -117,6 +117,12 @@ object BgLocationStore { if (!enabled(context)) return val prefs = prefs(context) + // Before the throttle on purpose. The throttle below exists to spare the + // server a 429; the local track has no server to spare, and dropping a + // fix the OS took the trouble to deliver would put holes in the history + // for a reason that has nothing to do with it. + LocationTrackStore.record(context, lat, lng) + // At most one report a minute, across every trigger. // // Four callers fire this independently — the geofence, the alarm diff --git a/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt b/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt new file mode 100644 index 000000000..847adfb87 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt @@ -0,0 +1,213 @@ +package com.exptech.dpip + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.File + +/** + * The device's own movement history, written where the fixes arrive. + * + * The counterpart of iOS's `LocationTrackStore.swift`, and deliberately the + * same file format: one delta-encoded table, anchors every 64 rows, native + * writes, Dart reads. The two platforms disagree about almost everything in + * background location — how they wake, how often, what they are allowed to do + * — so the one thing that should not also differ is what ends up on disk. + * + * ## Why its own file + * + * Not a table in `dpip.db`. That belongs to Dart's `sqlite_async`, which keeps + * a WAL connection pool across isolates and migrates its own schema; a second + * writer here would be a cross-process multi-writer against a schema this file + * cannot see. + * + * ## The encoding + * + * Rows hold **deltas**, not absolutes. SQLite stores a small integer in one or + * two bytes and a large one in four or six, so writing the difference from the + * previous fix is compression the record format performs for free — no private + * blob to encode, and nothing for the reader to decode but addition. + * + * Every 64th row is an **anchor** holding absolute values, recognised by + * `rowid % 64 == 0` so no column is spent marking it. Eviction drops whole + * anchor groups: a delta row is meaningless without the anchor it counts from, + * and removing one from the middle would displace every position after it with + * nothing to signal that it had happened. + */ +object LocationTrackStore { + /** Degrees as ten-thousandths — about 11 m. */ + private const val SCALE = 10_000.0 + + /** Rows between absolute anchors. */ + private const val ANCHOR_EVERY = 64L + + private const val BUDGET_BYTES = 50L * 1024 * 1024 + + /** Rows between size checks; the file cannot grow meaningfully in fewer. */ + private const val CHECK_EVERY = 512L + + private const val FILE = "location_track.db" + + @Volatile private var db: SQLiteDatabase? = null + + /** + * Records one fix. + * + * Never throws into the caller: this runs inside a JobService or an alarm + * receiver whose real work is reporting the position, and a storage failure + * must not take that down with it. + */ + @Synchronized + fun record(context: Context, lat: Double, lng: Double, atMillis: Long = System.currentTimeMillis()) { + val handle = open(context) ?: return + try { + val t = atMillis / 1000 + val latE4 = Math.round(lat * SCALE) + val lngE4 = Math.round(lng * SCALE) + + var rowid = lastRowId(handle) + 1 + val previous = if (rowid % ANCHOR_EVERY == 0L) null else lastAbsolute(handle) + + // A row that cannot be a delta has to be an anchor, and an anchor is + // recognised by its rowid alone — so move the row to the next + // boundary rather than write an absolute where a reader expects a + // delta. Happens on the first fix, and on the first after eviction + // removed the tail. + if (previous == null && rowid % ANCHOR_EVERY != 0L) { + rowid += ANCHOR_EVERY - rowid % ANCHOR_EVERY + } + + handle.execSQL( + "INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)", + arrayOf( + rowid, + previous?.let { t - it.t } ?: t, + previous?.let { latE4 - it.lat } ?: latE4, + previous?.let { lngE4 - it.lng } ?: lngE4, + ), + ) + evictIfNeeded(handle, rowid) + } catch (_: Throwable) { + // Deliberately swallowed, and deliberately not logged: this path can + // run hundreds of times a day in the background, and a log line per + // failure would be the only trace of a disk that is simply full. + // The size is visible through the app's storage screen instead. + } + } + + /** + * Deletes every recorded fix and hands the pages back to the filesystem. + * + * Through the open handle, never by deleting the file: this object caches + * the handle, and one whose file was removed underneath it goes on writing + * into an unlinked inode — the rows reappear on the next read, the space is + * never returned, and nothing reports a problem. + */ + @Synchronized + fun clear(context: Context): Boolean { + val handle = open(context) ?: return false + return try { + handle.execSQL("DELETE FROM fix") + // Only returns bytes because `auto_vacuum=INCREMENTAL` was set + // before the table existed — see [open]. + handle.execSQL("PRAGMA incremental_vacuum") + true + } catch (_: Throwable) { + false + } + } + + private fun open(context: Context): SQLiteDatabase? { + db?.let { if (it.isOpen) return it } + return try { + // filesDir, not noBackupFilesDir: the manifest already sets + // android:allowBackup="false", and this is the directory Dart's + // getApplicationSupportDirectory() resolves to — the reader should + // not have to guess where the writer put it. + val file = File(context.filesDir, FILE) + val handle = SQLiteDatabase.openOrCreateDatabase(file, null) + // Before the table exists: set afterwards it is a no-op until a full + // VACUUM, and without it deleted rows free pages inside the file and + // never give the bytes back to the budget. + handle.execSQL("PRAGMA auto_vacuum=INCREMENTAL") + handle.execSQL("PRAGMA journal_mode=WAL") + // A fix lost to a power cut is one point on a track; a blocked fsync + // inside a background wake can cost the report as well. + handle.execSQL("PRAGMA synchronous=NORMAL") + handle.execSQL( + """ + CREATE TABLE IF NOT EXISTS fix ( + id INTEGER PRIMARY KEY, + t INTEGER NOT NULL, + lat INTEGER NOT NULL, + lng INTEGER NOT NULL + ) + """.trimIndent(), + ) + db = handle + handle + } catch (_: Throwable) { + null + } + } + + private data class Fix(val t: Long, val lat: Long, val lng: Long) + + private fun scalar(handle: SQLiteDatabase, sql: String): Long = + handle.rawQuery(sql, null).use { if (it.moveToFirst()) it.getLong(0) else 0L } + + private fun lastRowId(handle: SQLiteDatabase): Long = + scalar(handle, "SELECT IFNULL(MAX(id), 0) FROM fix") + + /** + * The absolute position of the last row, rebuilt from its anchor forward. + * + * Bounded by [ANCHOR_EVERY], so at most 64 rows of addition — cheaper than a + * cached copy that a crash or a second process could leave stale. + */ + private fun lastAbsolute(handle: SQLiteDatabase): Fix? { + val last = lastRowId(handle) + if (last == 0L) return null + val anchor = last - last % ANCHOR_EVERY + var current: Fix? = null + handle.rawQuery( + "SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id", + arrayOf(anchor.toString()), + ).use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val t = cursor.getLong(1) + val lat = cursor.getLong(2) + val lng = cursor.getLong(3) + current = if (id % ANCHOR_EVERY == 0L || current == null) { + Fix(t, lat, lng) + } else { + current!!.let { Fix(it.t + t, it.lat + lat, it.lng + lng) } + } + } + } + return current + } + + /** Drops the oldest anchor groups until the file is back inside its budget. */ + private fun evictIfNeeded(handle: SQLiteDatabase, rowid: Long) { + if (rowid % CHECK_EVERY != 0L) return + val pageSize = scalar(handle, "PRAGMA page_size") + var bytes = pageSize * scalar(handle, "PRAGMA page_count") + if (bytes <= BUDGET_BYTES) return + + // Free about a tenth at a time. Trimming to exactly the limit would put + // the next fix straight back over it, and the vacuum is the expensive + // part of this path. + val target = BUDGET_BYTES - BUDGET_BYTES / 10 + var oldest = scalar(handle, "SELECT IFNULL(MIN(id), 0) FROM fix") + while (bytes > target && oldest > 0) { + val boundary = oldest + ANCHOR_EVERY - oldest % ANCHOR_EVERY + handle.execSQL("DELETE FROM fix WHERE id < $boundary") + handle.execSQL("PRAGMA incremental_vacuum") + bytes = pageSize * scalar(handle, "PRAGMA page_count") + val next = scalar(handle, "SELECT IFNULL(MIN(id), 0) FROM fix") + if (next <= oldest) break + oldest = next + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 71fd913c9..a4738f9d6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A8D382D04B4ACD327E29F46B /* eq.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 682D0165E2FF3895C5B252C5 /* eq.aiff */; }; AA0000000000000000000C02 /* CompassPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000C01 /* CompassPlugin.swift */; }; AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; }; + AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; }; AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; }; AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; }; AE92AD9862B7A721B0924557 /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E88CB92902C0CACB07792 /* eew.aiff */; }; @@ -94,6 +95,7 @@ A382CD9DEA741E45DBF741D7 /* rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = ""; }; AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; }; AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; + AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; }; AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; }; @@ -178,6 +180,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, AA0000000000000000000C01 /* CompassPlugin.swift */, AA0000000000000000000E01 /* ScreenWakePlugin.swift */, + AA0000000000000000000G01 /* LocationTrackStore.swift */, AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, @@ -373,6 +376,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, AA0000000000000000000C02 /* CompassPlugin.swift in Sources */, AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, + AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */, AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, diff --git a/ios/Runner/BackgroundLocationPlugin.swift b/ios/Runner/BackgroundLocationPlugin.swift index de3ac2ac1..ca211cdc0 100644 --- a/ios/Runner/BackgroundLocationPlugin.swift +++ b/ios/Runner/BackgroundLocationPlugin.swift @@ -91,6 +91,13 @@ public class BackgroundLocationPlugin: NSObject, FlutterPlugin, CLLocationManage result(nil) case "diagnostics": result(diagnostics()) + // Answered only once the delete has run. The developer page re-reads the + // fix count the moment this returns, and replying early would show it the + // count it just asked to throw away. + case "clearTrack": + LocationTrackStore.shared.clear { + DispatchQueue.main.async { result(nil) } + } default: result(FlutterMethodNotImplemented) } @@ -221,6 +228,13 @@ public class BackgroundLocationPlugin: NSObject, FlutterPlugin, CLLocationManage return } recenterRegion(coordinate) + // Before the distance gate on purpose. `shouldReport` exists to spare the + // server, and the local track has no server to spare — dropping a fix the + // OS took the trouble to deliver, because a report would have been too + // soon, would put holes in the history for a reason that has nothing to do + // with it. + LocationTrackStore.shared.record( + latitude: coordinate.latitude, longitude: coordinate.longitude) guard shouldReport(coordinate) else { return } report(coordinate) defaults.set(coordinate.latitude, forKey: Self.lastLatKey) diff --git a/ios/Runner/LocationTrackStore.swift b/ios/Runner/LocationTrackStore.swift new file mode 100644 index 000000000..8cded6eea --- /dev/null +++ b/ios/Runner/LocationTrackStore.swift @@ -0,0 +1,265 @@ +import Foundation +import SQLite3 + +/// The device's own movement history, written where the fixes arrive. +/// +/// Background location wakes the app with the screen off and Dart not running, +/// so the track has to be written by the same native code that already reports +/// the fix. Dart never writes here — it opens this file read-only — and the +/// eviction that keeps it under budget is native too. One owner for the format, +/// one owner for the size. +/// +/// ## Why its own file +/// +/// Not a table in `dpip.db`. That database belongs to Dart's `sqlite_async`, +/// which keeps a WAL connection pool across isolates; a second writer in +/// another process would be a cross-process multi-writer, and its schema is +/// migrated by code this class cannot see. A separate file makes both problems +/// disappear and costs one file handle. +/// +/// ## The encoding +/// +/// Rows hold **deltas**, not absolutes. SQLite already stores small integers in +/// one or two bytes and large ones in four or six, so a delta of `+3` costs a +/// byte where an absolute latitude costs four — the compression is the record +/// format's, and nothing has to encode or decode a private blob. +/// +/// Every 64th row is an **anchor**: absolute values, recognised by +/// `rowid % 64 == 0`, so no column is spent marking it. Eviction removes whole +/// anchor groups, which is what keeps the chain readable after the head is +/// gone — deleting a single delta row would silently displace everything after +/// it. +/// +/// anchor t(4) + lat(3) + lng(3) + header ≈ 14 B +/// delta t(1) + lat(1) + lng(1) + header ≈ 8 B +/// +/// Measured at 14.19 bytes a row over 200,000 rows, rowid and B-tree included, +/// so the budget below holds about 3.7 million fixes. Significant-change +/// delivers tens to low hundreds a day, which is a century of them; the budget +/// is there for the pathological case, not the expected one. +final class LocationTrackStore { + static let shared = LocationTrackStore() + + /// Degrees are stored as ten-thousandths: about 11 m, and the precision the + /// caller asked for. + private static let scale = 10_000.0 + + /// Rows between absolute anchors. A power of two so the modulo is cheap and + /// the boundary is obvious in a hex dump. + private static let anchorEvery: Int64 = 64 + + private static let budgetBytes: Int64 = 50 * 1024 * 1024 + + /// How often to bother checking the size. The check is a `pragma` pair, but + /// it runs inside a background wake window measured in seconds, and the file + /// cannot grow by a meaningful fraction of 50 MB between two fixes. + private static let checkEvery: Int64 = 512 + + private var db: OpaquePointer? + private let queue = DispatchQueue(label: "com.exptech.dpip.location-track") + + private init() {} + + /// Records one fix. Safe to call from any thread; never throws into the + /// caller, because the caller is a location callback whose failure would take + /// the reporting path down with it. + func record(latitude: Double, longitude: Double, at time: Date = Date()) { + queue.async { [weak self] in + guard let self, let db = self.open() else { return } + let t = Int64(time.timeIntervalSince1970) + let lat = Int64((latitude * Self.scale).rounded()) + let lng = Int64((longitude * Self.scale).rounded()) + + var rowid = self.lastRowId(db) + 1 + let previous = rowid % Self.anchorEvery == 0 ? nil : self.lastAbsolute(db) + + // A row that cannot be a delta has to be an anchor, and an anchor is + // recognised by its rowid alone — so move the row to the next boundary + // rather than writing an absolute value where a reader expects a delta. + // Happens exactly twice: on the first fix, and on the first after the + // tail was evicted. + if previous == nil, rowid % Self.anchorEvery != 0 { + rowid += Self.anchorEvery - rowid % Self.anchorEvery + } + + self.insert( + db, rowid: rowid, + t: previous.map { t - $0.t } ?? t, + lat: previous.map { lat - $0.lat } ?? lat, + lng: previous.map { lng - $0.lng } ?? lng) + self.evictIfNeeded(db) + } + } + + /// Deletes every recorded fix and hands the pages back to the filesystem. + /// + /// Through the open handle on the store's own queue, never by unlinking the + /// file. This class caches `db` for the life of the process, and a handle + /// whose file was removed underneath it goes on writing perfectly happily + /// into an unlinked inode: the rows reappear the moment anything reads, the + /// space is never returned, and nothing anywhere reports a problem. + /// + /// [completion] fires on the queue once the delete has actually run, so a + /// caller that re-reads the count sees the cleared store rather than the one + /// it asked to clear. + func clear(completion: (() -> Void)? = nil) { + queue.async { [weak self] in + guard let self, let db = self.open() else { + completion?() + return + } + self.exec(db, "DELETE FROM fix") + // Only gives bytes back because `auto_vacuum=INCREMENTAL` was set before + // the table existed — see `open()`. Without that this deletes rows and + // leaves the file exactly as large as it was. + self.exec(db, "PRAGMA incremental_vacuum") + completion?() + } + } + + // MARK: - storage + + private func open() -> OpaquePointer? { + if let db { return db } + guard let path = Self.path() else { return nil } + var handle: OpaquePointer? + guard sqlite3_open_v2( + path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil + ) == SQLITE_OK else { + sqlite3_close(handle) + return nil + } + // Explicit, not inherited. The default protection class would let these + // writes work today and stop working the day someone raises the app's + // default to `complete` — and every one of these writes happens with the + // screen off, which is exactly when that class denies access. + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: path) + + // Before the table exists: setting it afterwards is a no-op until a full + // VACUUM, and without it the file only ever grows — deleting rows would + // free pages inside the file and never give the bytes back to the budget. + exec(handle, "PRAGMA auto_vacuum=INCREMENTAL") + exec(handle, "PRAGMA journal_mode=WAL") + // Durability is worth less here than surviving the wake window: a fix lost + // to a power cut is one point on a track, while a blocked fsync inside a + // ten-second background window can cost the report as well. + exec(handle, "PRAGMA synchronous=NORMAL") + exec(handle, """ + CREATE TABLE IF NOT EXISTS fix ( + id INTEGER PRIMARY KEY, + t INTEGER NOT NULL, + lat INTEGER NOT NULL, + lng INTEGER NOT NULL + ) + """) + db = handle + return handle + } + + private static func path() -> String? { + // Application Support, beside the app's other databases, and excluded from + // backups: a movement history is regenerable and does not belong in a + // restore of a different device. + guard var url = try? FileManager.default.url( + for: .applicationSupportDirectory, in: .userDomainMask, + appropriateFor: nil, create: true) + else { return nil } + url.appendPathComponent("location_track.db") + var resource = URLResourceValues() + resource.isExcludedFromBackup = true + var mutable = url + try? mutable.setResourceValues(resource) + return url.path + } + + private func exec(_ db: OpaquePointer?, _ sql: String) { + sqlite3_exec(db, sql, nil, nil, nil) + } + + private func lastRowId(_ db: OpaquePointer) -> Int64 { + scalar(db, "SELECT IFNULL(MAX(id), 0) FROM fix") ?? 0 + } + + /// The absolute position of the last row, rebuilt from its anchor forward. + /// + /// Walking the group is bounded by [anchorEvery], so this is at most 64 rows + /// of arithmetic — cheaper than keeping a cached copy that a second process + /// or a crash could leave stale. + private func lastAbsolute(_ db: OpaquePointer) -> (t: Int64, lat: Int64, lng: Int64)? { + let last = lastRowId(db) + guard last > 0 else { return nil } + let anchor = last - (last % Self.anchorEvery) + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, "SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id", -1, &statement, nil + ) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, anchor) + + var current: (t: Int64, lat: Int64, lng: Int64)? + while sqlite3_step(statement) == SQLITE_ROW { + let id = sqlite3_column_int64(statement, 0) + let t = sqlite3_column_int64(statement, 1) + let lat = sqlite3_column_int64(statement, 2) + let lng = sqlite3_column_int64(statement, 3) + if id % Self.anchorEvery == 0 || current == nil { + current = (t, lat, lng) + } else if let previous = current { + current = (previous.t + t, previous.lat + lat, previous.lng + lng) + } + } + return current + } + + private func insert(_ db: OpaquePointer, rowid: Int64, t: Int64, lat: Int64, lng: Int64) { + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + db, "INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)", -1, &statement, nil + ) == SQLITE_OK else { return } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, rowid) + sqlite3_bind_int64(statement, 2, t) + sqlite3_bind_int64(statement, 3, lat) + sqlite3_bind_int64(statement, 4, lng) + sqlite3_step(statement) + } + + private func scalar(_ db: OpaquePointer, _ sql: String) -> Int64? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + return sqlite3_step(statement) == SQLITE_ROW ? sqlite3_column_int64(statement, 0) : nil + } + + /// Drops the oldest anchor groups until the file is back inside its budget. + /// + /// Whole groups, never single rows: a delta row is meaningless without the + /// anchor it counts from, so removing one row from the middle would shift + /// every position after it without any way to notice. + private func evictIfNeeded(_ db: OpaquePointer) { + let last = lastRowId(db) + guard last % Self.checkEvery == 0 else { return } + guard let pageSize = scalar(db, "PRAGMA page_size"), + let pageCount = scalar(db, "PRAGMA page_count") + else { return } + var bytes = pageSize * pageCount + guard bytes > Self.budgetBytes else { return } + + // Free about a tenth of the budget at a time. Trimming to exactly the limit + // would put the next fix straight back over it, and the VACUUM below is the + // expensive part of this whole path. + let target = Self.budgetBytes - Self.budgetBytes / 10 + var oldest = scalar(db, "SELECT IFNULL(MIN(id), 0) FROM fix") ?? 0 + while bytes > target, oldest > 0 { + let boundary = oldest + Self.anchorEvery - (oldest % Self.anchorEvery) + exec(db, "DELETE FROM fix WHERE id < \(boundary)") + exec(db, "PRAGMA incremental_vacuum") + guard let count = scalar(db, "PRAGMA page_count") else { break } + bytes = pageSize * count + guard let next = scalar(db, "SELECT IFNULL(MIN(id), 0) FROM fix"), next > oldest else { break } + oldest = next + } + } +} diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart index babad1739..482b0d700 100644 --- a/lib/core/diagnostics/diagnostics_report.dart +++ b/lib/core/diagnostics/diagnostics_report.dart @@ -14,6 +14,7 @@ library; import 'dart:io'; +import 'package:dpip/core/geo/location_track.dart'; import 'package:dpip/core/build_info.g.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; @@ -289,6 +290,12 @@ class DiagnosticsCollector { final tables = await database.tableStats(); final device = await DeviceInfoService.load(); final bgLocation = await backgroundLocation.diagnostics(); + // Read-only, and absent on a device that has never recorded one — a fresh + // install and a denied background grant both land here, and neither is an + // error worth a row that says so. + final track = await LocationTrack.open(); + final trackStats = await track?.stats(); + await track?.close(); // The two states that silently end background reporting while every // permission above still reads "granted" — and the two that were missing // from the dump people paste when asking why they got no alert. @@ -382,6 +389,14 @@ class DiagnosticsCollector { // different fixes, and a single last-report row cannot tell them // apart — it says `never` for both. (label: 'Wakes', value: _wakes(bgLocation)), + // Fixes and bytes together: the ratio is the compression working or + // not, and a count that climbs while the file does not (or the other + // way round) is the first sign the delta chain has gone wrong. + if (trackStats != null) + ( + label: 'Track fixes', + value: '${trackStats.fixes} · ${formatBytes(trackStats.bytes)}', + ), if (bgLocation['lastGeofenceError'] != null) ( label: 'Geofence error', diff --git a/lib/core/geo/location_track.dart b/lib/core/geo/location_track.dart new file mode 100644 index 000000000..9c60bcafc --- /dev/null +++ b/lib/core/geo/location_track.dart @@ -0,0 +1,250 @@ +/// Read access to the movement history the native side records. +library; + +import 'dart:io'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart' show Database; +import 'package:sqlite_async/native.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// One recorded position. +class TrackFix { + const TrackFix({ + required this.time, + required this.latitude, + required this.longitude, + }); + + final DateTime time; + + /// Degrees, to four places — about 11 m, which is the precision the store + /// keeps. Reading back more decimals than that would be inventing them. + final double latitude; + final double longitude; + + @override + String toString() => + 'TrackFix(${time.toIso8601String()}, $latitude, $longitude)'; +} + +/// Reads the track that iOS's `LocationTrackStore.swift` and Android's +/// `LocationTrackStore.kt` write. +/// +/// **Read-only, and that is the whole design.** Fixes arrive while the app is +/// backgrounded and Dart is not running, so the native side owns the writes; +/// it owns the eviction too, because a 50 MB budget enforced from two places +/// is a budget enforced from neither. The connection here is opened +/// `SQLITE_OPEN_READONLY` rather than merely used carefully — a future edit +/// that tried to insert or delete would fail at the connection, not silently +/// become a second owner of the file. +/// +/// ## The format it decodes +/// +/// One `fix` table of `(id, t, lat, lng)`, where every value is a **delta from +/// the previous row** except on anchors, and an anchor is any row whose `id` +/// is a multiple of [_anchorEvery]. Latitude and longitude are ten-thousandths +/// of a degree; `t` is Unix seconds. +/// +/// Storing differences is what makes the file small, and it costs nothing to +/// do: SQLite already writes a small integer in one byte and a large one in +/// four, so a step of a few hundred metres is a byte where an absolute +/// coordinate is four. There is no private blob, and decoding is addition. +/// +/// The price is that a row cannot be read on its own — the walk has to start +/// at an anchor. That is why [since] resolves a starting rowid first instead +/// of asking for `WHERE t >= ?`, which would match rows whose `t` is an +/// interval rather than an instant. +class LocationTrack { + LocationTrack._(this._db, this._path); + + static const _file = 'location_track.db'; + + /// Rows between absolute anchors. **Must match `anchorEvery` in both native + /// stores.** Changing it on one side would not throw — it would return + /// positions displaced by however far the device moved since the last real + /// anchor, which is a wrong answer that looks like a right one. + static const _anchorEvery = 64; + + /// Degrees per stored unit. + static const _scale = 10000.0; + + final SqliteDatabase _db; + final String _path; + + /// Opens the track, or null when the native side has never written one. + /// + /// Absence is the ordinary state on a fresh install, and on any device that + /// never granted background location, so it is not an error and is not + /// logged as one. + static Future open() async { + try { + // Application Support on iOS, `filesDir` on Android — the one directory + // both native writers were pointed at, because a reader that had to + // guess would not fail, it would quietly report an empty history. + final directory = await getApplicationSupportDirectory(); + return at('${directory.path}/$_file'); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'opening location track'); + return null; + } + } + + /// Opens a track at an explicit path, or null if there is no file there. + /// + /// [open] is this plus the directory lookup. Split out so a test can point + /// at a fixture and still go through the real read-only connection — the + /// part most likely to break, and the part whose breakage looks exactly + /// like an empty history. + static LocationTrack? at(String path) { + if (!File(path).existsSync()) return null; + return LocationTrack._( + SqliteDatabase.withFactory(_ReadOnlyFactory(path: path)), + path, + ); + } + + Future close() => _db.close(); + + /// The underlying connection, so a test can prove it rejects writes. + /// + /// Exposed rather than asserted in a comment: "Dart never writes here" is + /// the load-bearing half of the design, and the only way to show it holds + /// is to try a write and watch SQLite refuse. + @visibleForTesting + SqliteDatabase get connection => _db; + + /// Every fix recorded at or after [from], oldest first. + /// + /// [limit] keeps the most recent that many and drops the rest, which is the + /// only bound worth having here: the budget allows a few million rows, and + /// materialising all of them as objects would cost far more memory than the + /// file costs disk. + Future> since(DateTime from, {int? limit}) async { + try { + final start = await _anchorAtOrBefore(from); + final fixes = await _decodeFrom(start); + final wanted = fixes.where((fix) => !fix.time.isBefore(from)).toList(); + if (limit == null || wanted.length <= limit) return wanted; + return wanted.sublist(wanted.length - limit); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'reading location track'); + return const []; + } + } + + /// How many fixes are stored and how large the file is. + /// + /// For the storage screen. This file is the one part of the app's disk use + /// that grows without anybody opening anything, so it is worth being able + /// to see it. + Future<({int fixes, int bytes})> stats() async { + try { + final row = await _db.get('SELECT COUNT(*) AS n FROM fix'); + final file = File(_path); + return ( + fixes: ((row['n'] as num?) ?? 0).toInt(), + bytes: file.existsSync() ? file.lengthSync() : 0, + ); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'location track stats'); + return (fixes: 0, bytes: 0); + } + } + + /// The rowid of the newest anchor no later than [time], or 0 for "start at + /// the beginning". + /// + /// Only anchors are consulted because only anchors carry an absolute `t`; a + /// delta row's `t` is an interval, and comparing it to a wall clock would + /// match essentially at random. + /// + /// Descending, so the scan stops at the first hit. A window of the last day + /// or week — what anything asking this actually wants — reads a handful of + /// rows off the end of the rowid index and stops. Asking for a window older + /// than the whole track is the one case that scans it all, and it correctly + /// answers 0. + Future _anchorAtOrBefore(DateTime time) async { + final rows = await _db.getAll( + 'SELECT id FROM fix WHERE id % $_anchorEvery = 0 AND t <= ? ' + 'ORDER BY id DESC LIMIT 1', + [time.millisecondsSinceEpoch ~/ 1000], + ); + return rows.isEmpty ? 0 : ((rows.first['id'] as num?) ?? 0).toInt(); + } + + /// Rebuilds absolute positions from [startId] forward. + /// + /// [startId] has to be an anchor or 0 — the first row of the table is always + /// an anchor, because the writer rounds up to a boundary whenever it has + /// nothing to count from and eviction only ever deletes whole groups. The + /// `t == null` arm below is the belt to that braces: a first row that + /// somehow is not an anchor is read as absolute, which is the only reading + /// that can be right. + Future> _decodeFrom(int startId) async { + final rows = await _db.getAll( + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', + [startId], + ); + final fixes = []; + var t = 0; + var lat = 0; + var lng = 0; + var started = false; + for (final row in rows) { + final id = (row['id'] as num).toInt(); + if (!started || id % _anchorEvery == 0) { + t = (row['t'] as num).toInt(); + lat = (row['lat'] as num).toInt(); + lng = (row['lng'] as num).toInt(); + started = true; + } else { + t += (row['t'] as num).toInt(); + lat += (row['lat'] as num).toInt(); + lng += (row['lng'] as num).toInt(); + } + fixes.add( + TrackFix( + time: DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: true), + latitude: lat / _scale, + longitude: lng / _scale, + ), + ); + } + return fixes; + } +} + +/// Opens every connection `SQLITE_OPEN_READONLY`, and runs no pragma that +/// would persist anything. +/// +/// sqlite_async always opens one writable "primary" connection; this replaces +/// the options it passes so that connection is read-only too. The journal-mode +/// and journal-size pragmas are suppressed the same way — the file is already +/// in WAL because the native writer put it there, and re-asserting it from +/// here would be this side taking a write lock on a file it does not own. +/// +/// One reader is plenty: nothing reads this concurrently, and each connection +/// is a file handle held for the life of the app. +base class _ReadOnlyFactory extends NativeSqliteOpenFactory { + _ReadOnlyFactory({required super.path}) + : super( + sqliteOptions: const SqliteOptions( + journalMode: null, + journalSizeLimit: null, + synchronous: null, + maxReaders: 1, + ), + ); + + static const _readOnly = SqliteOpenOptions( + primaryConnection: false, + readOnly: true, + ); + + @override + Database openNativeConnection(SqliteOpenOptions options) => + super.openNativeConnection(_readOnly); +} diff --git a/lib/core/platform/background_location.dart b/lib/core/platform/background_location.dart index 94f3d148e..0827c7947 100644 --- a/lib/core/platform/background_location.dart +++ b/lib/core/platform/background_location.dart @@ -130,6 +130,24 @@ class BackgroundLocationService { } } + /// Deletes the on-device movement history. + /// + /// Native, not a file delete from here. The recorder caches its database + /// handle for the life of the process, so a file removed from Dart would be + /// an unlinked inode the native side keeps writing into — the rows come back + /// on the next read and the space is never returned. The side that owns + /// every write owns the delete too. + Future clearTrack() async { + try { + await _channel.invokeMethod('clearTrack'); + } on MissingPluginException { + // Unsupported platform / test harness — nothing recorded, nothing to + // clear. + } on Object catch (error, stackTrace) { + Log.handle(error, stackTrace, 'background location clearTrack'); + } + } + Future stop() async { try { await _channel.invokeMethod('stop'); diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3487108d9..169349c2d 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -30,6 +30,15 @@ /// | `dpip.db` | `mesh_nodes` | meshtastic | /// | `http_etag_cache.db` | `http_cache` | cache | /// | `http_etag_cache.db` | `net_bucket` | cache | +/// | `location_track.db` | `fix` | movement | +/// +/// The third file is not opened here and has no handle on [AppDatabase], +/// because nothing in Dart writes it: background location arrives when Dart is +/// not running, so the native stores own both the writes and the eviction that +/// keeps it under 50 MB. Dart reads it through +/// `lib/core/geo/location_track.dart`, on a connection opened read-only. It is +/// listed here because this table is where someone looks to find out what the +/// app keeps on disk, and a file that grows on its own belongs on that list. library; import 'package:dpip/core/logging/log.dart'; diff --git a/lib/core/storage/app_storage_scan.dart b/lib/core/storage/app_storage_scan.dart index 76f269341..62c89dd58 100644 --- a/lib/core/storage/app_storage_scan.dart +++ b/lib/core/storage/app_storage_scan.dart @@ -112,6 +112,10 @@ List storageBreakdown(StorageScan scan) { } known('ETag cache (SQLite)', (f) => f.name.startsWith('http_etag_cache.db')); + // Native-written movement history, budgeted at 50 MB. It grows without + // anybody opening anything, so it is the one slice a user could otherwise + // find no explanation for. `startsWith` catches the -wal and -shm companions. + known('Location track', (f) => f.name.startsWith('location_track.db')); known( 'MapLibre', (f) => f.path.contains('MapLibre') || f.path.contains('mapbox'), diff --git a/lib/features/bug_tracker/domain/bug_thread.dart b/lib/features/bug_tracker/domain/bug_thread.dart index 5aff7cfc9..6f6877ab8 100644 --- a/lib/features/bug_tracker/domain/bug_thread.dart +++ b/lib/features/bug_tracker/domain/bug_thread.dart @@ -33,12 +33,30 @@ class UnixSecondsDateTime implements JsonConverter { int toJson(DateTime value) => value.millisecondsSinceEpoch ~/ 1000; } -/// The tracker staff who answer reports — rendered with a developer badge so -/// official replies are visually distinct from user chatter. -const Set bugTrackerAdminIds = {780043079385612319}; - -/// Whether this author id belongs to tracker staff. -bool isBugTrackerStaff(int authorId) => bugTrackerAdminIds.contains(authorId); +/// The developers who build the app — rendered with a「開發人員」badge and a +/// primary-coloured name. +const Set bugTrackerAdminIds = {780043079385612319, 592012263834255360}; + +/// The tracker team who triage and reply — rendered with a「工作人員」badge, +/// one step quieter than the developer badge but still distinct from users. +const Set bugTrackerStaffIds = { + 452103762320949248, + 815574915901554699, + 878792688416227368, + 860479942550093866, + 898836485397180426, + 905433558921920562, + 1001016404289536051, +}; + +/// The role an author id carries on the tracker, driving badge and colour. +enum BugAuthorRole { user, staff, admin } + +BugAuthorRole bugAuthorRole(int authorId) { + if (bugTrackerAdminIds.contains(authorId)) return BugAuthorRole.admin; + if (bugTrackerStaffIds.contains(authorId)) return BugAuthorRole.staff; + return BugAuthorRole.user; +} /// One staff/victim reply inside a reported-bug thread. @freezed diff --git a/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart b/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart index add2c4ae0..d2b2a6110 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_thread_page.dart @@ -158,7 +158,7 @@ class _OpeningPost extends StatelessWidget { final created = DateFormat('yyyy/MM/dd HH:mm') .format(thread.createdAt.toLocal()); // OP author id lives on the model; see bug_thread.dart. - final staff = isBugTrackerStaff(thread.author); + final role = bugAuthorRole(thread.author); return Container( padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( @@ -225,14 +225,20 @@ class _OpeningPost extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w600, - color: staff ? colors.primary : null, + color: role == BugAuthorRole.admin + ? colors.primary + : null, ), ), ), - if (staff) ...[ + if (role == BugAuthorRole.admin) ...[ const SizedBox(width: AppSpacing.xs), const _DeveloperBadge(), ], + if (role == BugAuthorRole.staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _StaffBadge(), + ], ], ), Text( @@ -270,7 +276,7 @@ class _ChatReply extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final staff = isBugTrackerStaff(message.author); + final role = bugAuthorRole(message.author); final time = DateFormat('yyyy/MM/dd HH:mm').format(message.time.toLocal()); return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -301,14 +307,20 @@ class _ChatReply extends StatelessWidget { overflow: TextOverflow.ellipsis, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, - color: staff ? colors.primary : null, + color: role == BugAuthorRole.admin + ? colors.primary + : null, ), ), ), - if (staff) ...[ + if (role == BugAuthorRole.admin) ...[ const SizedBox(width: AppSpacing.xs), const _DeveloperBadge(), ], + if (role == BugAuthorRole.staff) ...[ + const SizedBox(width: AppSpacing.xs), + const _StaffBadge(), + ], const SizedBox(width: AppSpacing.xs), Text( time, @@ -378,6 +390,32 @@ class _CannotDisplay extends StatelessWidget { } } +/// The small「工作人員」tag beside triage-team names — tertiary tint, one +/// step quieter than the developer badge. +class _StaffBadge extends StatelessWidget { + const _StaffBadge(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colors.tertiary.withValues(alpha: 0.15), + borderRadius: AppRadius.small, + ), + child: Text( + AppLocalizations.of(context).bugTrackerStaff, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.tertiary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + /// The small「開發人員」tag beside staff names. class _DeveloperBadge extends StatelessWidget { const _DeveloperBadge(); diff --git a/lib/features/settings/presentation/pages/developer_page.dart b/lib/features/settings/presentation/pages/developer_page.dart index 63506d7d2..26eda2783 100644 --- a/lib/features/settings/presentation/pages/developer_page.dart +++ b/lib/features/settings/presentation/pages/developer_page.dart @@ -36,6 +36,9 @@ typedef _Field = ({String label, String? value}); /// Shared by the row, the dialog title, and its confirm button. const String _clearCacheTitle = 'Clear cache'; +/// Shared by the row, the dialog title, and its confirm button. +const String _clearTrackTitle = 'Clear location track'; + class DeveloperPage extends StatefulWidget { const DeveloperPage({super.key}); @@ -50,6 +53,7 @@ class _DeveloperPageState extends State { StorageScan? _storage; List? _tables; bool _clearing = false; + bool _clearingTrack = false; /// Version-row taps toward the experimental unlock. Deliberately not /// persisted — a fresh app start re-arms the easter egg. @@ -161,6 +165,56 @@ class _DeveloperPageState extends State { ..showSnackBar(const SnackBar(content: Text('Cache cleared'))); } + /// Empties the on-device movement history. + /// + /// Confirmed first, and worded as permanent because it is: the track is a + /// record of where this device has been, and unlike the cache above nothing + /// downloads it again. What is deleted is gone. + /// + /// The delete itself is native — see [BackgroundLocationService.clearTrack] + /// for why Dart must not unlink the file. + Future _confirmClearTrack() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text(_clearTrackTitle), + content: const Text( + 'The recorded movement history on this device will be deleted and ' + 'the space returned. Recording continues; only what has already ' + 'been recorded is removed. This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text(_clearTrackTitle), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final backgroundLocation = context.read(); + setState(() => _clearingTrack = true); + try { + await backgroundLocation.clearTrack(); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'dev: clear location track'); + } + if (!mounted) return; + setState(() => _clearingTrack = false); + // Re-read so the Track fixes row above shows the emptied store rather than + // the count the button was pressed to get rid of. + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(const SnackBar(content: Text('Location track cleared'))); + } + /// Labels omitted from the clipboard dump (still shown on screen). /// Labels that get their own copy button — long push tokens are the one /// case worth lifting out of a diagnostics screenshot on their own; every @@ -291,6 +345,25 @@ class _DeveloperPageState extends State { ), onTap: _reportNow, ), + ListTile( + leading: Icon( + Icons.route_outlined, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + _clearTrackTitle, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + subtitle: const Text( + 'Deletes the recorded movement history on this device', + ), + trailing: _clearingTrack + ? const InlineLoading(size: 18) + : null, + onTap: _clearingTrack ? null : _confirmClearTrack, + ), ListTile( leading: Icon( Icons.delete_sweep_outlined, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 09ae5a5e6..29e5db806 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3950,5 +3950,9 @@ }, "@bugTrackerSortMostDiscussed": { "description": "Sort chip: threads with the most replies first" + }, + "bugTrackerStaff": "Staff", + "@bugTrackerStaff": { + "description": "Badge beside triage-team names on bug threads" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 1ec030482..4f88b1896 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "Hindi maipakita ang nilalaman na ito — tingnan sa Discord", "bugTrackerJoinDiscussion": "Makilahok sa talakayan sa Discord", "bugTrackerSortLast": "Pinakabagong aktibidad", - "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan" + "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan", + "bugTrackerStaff": "Kawani" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index abc1af994..059b2affd 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "Konten ini tidak dapat ditampilkan — lihat di Discord", "bugTrackerJoinDiscussion": "Ikuti diskusi di Discord", "bugTrackerSortLast": "Aktivitas terbaru", - "bugTrackerSortMostDiscussed": "Paling banyak dibahas" + "bugTrackerSortMostDiscussed": "Paling banyak dibahas", + "bugTrackerStaff": "Staf" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 00f89f532..8b0fdc833 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "この内容は表示できません — Discord でご確認ください", "bugTrackerJoinDiscussion": "Discord で議論に参加する", "bugTrackerSortLast": "最新の返信", - "bugTrackerSortMostDiscussed": "返信が多い順" + "bugTrackerSortMostDiscussed": "返信が多い順", + "bugTrackerStaff": "スタッフ" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cbd563a66..c591136ab 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "이 내용을 표시할 수 없습니다 — Discord에서 확인하세요", "bugTrackerJoinDiscussion": "Discord에서 논의에 참여하기", "bugTrackerSortLast": "최근 활동", - "bugTrackerSortMostDiscussed": "답글 많은 순" + "bugTrackerSortMostDiscussed": "답글 많은 순", + "bugTrackerStaff": "스태프" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b13f92e37..2c446de88 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "ไม่สามารถแสดงเนื้อหานี้ได้ — ดูได้ที่ Discord", "bugTrackerJoinDiscussion": "ร่วมพูดคุยที่ Discord", "bugTrackerSortLast": "ล่าสุด", - "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด" + "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด", + "bugTrackerStaff": "ทีมงาน" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 26f9f5402..41abc3f35 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "Không thể hiển thị nội dung này — xem trên Discord", "bugTrackerJoinDiscussion": "Tham gia thảo luận trên Discord", "bugTrackerSortLast": "Hoạt động mới nhất", - "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất" + "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất", + "bugTrackerStaff": "Nhân sự" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index ceca4e183..f1de3b8a8 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "無法顯示呢個內容,請去 Discord 查看", "bugTrackerJoinDiscussion": "去 Discord 一齊傾", "bugTrackerSortLast": "最後傾偈", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 77032a60f..c36dd6ca9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1972,5 +1972,6 @@ "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", - "bugTrackerSortMostDiscussed": "最多讨论" + "bugTrackerSortMostDiscussed": "最多讨论", + "bugTrackerStaff": "工作人员" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 3b1b1a3a2..d0fa5ec81 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "无法显示此内容,请在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", - "bugTrackerSortMostDiscussed": "最多讨论" + "bugTrackerSortMostDiscussed": "最多讨论", + "bugTrackerStaff": "工作人员" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 4a3e20ad1..7a11b16e7 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 5dfbfb93a..a22319fb1 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1980,5 +1980,6 @@ "bugTrackerCannotDisplay": "無法顯示此內容,請在 Discord 上查看", "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", - "bugTrackerSortMostDiscussed": "最多討論" + "bugTrackerSortMostDiscussed": "最多討論", + "bugTrackerStaff": "工作人員" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 6181f6206..d75695c6f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6262,6 +6262,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Most discussed'** String get bugTrackerSortMostDiscussed; + + /// Badge beside triage-team names on bug threads + /// + /// In en, this message translates to: + /// **'Staff'** + String get bugTrackerStaff; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index c5c74f096..2f17c523c 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3294,4 +3294,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Most discussed'; + + @override + String get bugTrackerStaff => 'Staff'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 75859137a..b239099a3 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3312,4 +3312,7 @@ class AppLocalizationsFil extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Pinakamaraming talakayan'; + + @override + String get bugTrackerStaff => 'Kawani'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 79b755199..b607654c4 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3305,4 +3305,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Paling banyak dibahas'; + + @override + String get bugTrackerStaff => 'Staf'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 2f9c4701c..2224633fa 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3233,4 +3233,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '返信が多い順'; + + @override + String get bugTrackerStaff => 'スタッフ'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 840341bb0..1e20113fa 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3233,4 +3233,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '답글 많은 순'; + + @override + String get bugTrackerStaff => '스태프'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index d1a225ae3..e7fc22e85 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3287,4 +3287,7 @@ class AppLocalizationsTh extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'พูดคุยมากที่สุด'; + + @override + String get bugTrackerStaff => 'ทีมงาน'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index f22be1d17..982ca4413 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3295,4 +3295,7 @@ class AppLocalizationsVi extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => 'Nhiều thảo luận nhất'; + + @override + String get bugTrackerStaff => 'Nhân sự'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index fc826496a..96f11f7f0 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3216,4 +3216,7 @@ class AppLocalizationsYue extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index eac3d6ba7..28d57dd4a 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3216,6 +3216,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bugTrackerSortMostDiscussed => '最多讨论'; + + @override + String get bugTrackerStaff => '工作人员'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6429,6 +6432,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多讨论'; + + @override + String get bugTrackerStaff => '工作人员'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9642,6 +9648,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12855,4 +12864,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get bugTrackerSortMostDiscussed => '最多討論'; + + @override + String get bugTrackerStaff => '工作人員'; } diff --git a/pubspec.lock b/pubspec.lock index 4ef276279..e82963440 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1084,7 +1084,7 @@ packages: source: hosted version: "1.10.2" sqlite3: - dependency: "direct dev" + dependency: "direct main" description: name: sqlite3 sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" diff --git a/pubspec.yaml b/pubspec.yaml index 50be9eff4..135fc1f79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,11 @@ dependencies: # so a cold-start lock contention window no longer fails opens on the UI # thread, and WAL + busy_timeout (lockTimeout) ship as sane defaults. sqlite_async: ^0.14.4 + # The synchronous SQLite API. Not used to open anything in app code — it is + # here for two types: the in-memory test helper wraps one handle with + # SqliteDatabase.singleConnection, and the location track's read-only open + # factory has to name `Database` to override sqlite_async's connection hook. + sqlite3: ^3.5.2 talker_flutter: ^5.1.9 url_launcher: ^6.3.2 @@ -105,10 +110,6 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - # The synchronous SQLite API, for tests only: the in-memory helper - # (test/core/storage/memory_db.dart) wraps one open handle with - # SqliteDatabase.singleConnection so `:memory:` means one database. - sqlite3: ^3.5.2 # Virtual time for timer-driven state machines (the traceroute timeout). fake_async: ^1.3.0 # Dart 3.13 (Flutter 3.47) makes `final` illegal on non-primary-constructor diff --git a/test/core/geo/location_track_test.dart b/test/core/geo/location_track_test.dart new file mode 100644 index 000000000..8daca0065 --- /dev/null +++ b/test/core/geo/location_track_test.dart @@ -0,0 +1,258 @@ +/// The delta format, from the writer's side to the reader's. +/// +/// The native stores write this file and Dart only reads it, so nothing in the +/// app exercises both halves — a disagreement about the encoding would show up +/// as a track that is simply wrong, on a device, with no exception anywhere. +/// [_writeFixture] below is therefore a line-by-line restatement of what +/// `LocationTrackStore.swift` and `LocationTrackStore.kt` do, and these tests +/// assert the reader inverts it exactly. +library; + +import 'dart:io'; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +const _anchorEvery = 64; +const _scale = 10000.0; + +/// Writes fixes the way the native stores do: deltas, absolute every 64th row. +void _writeFixture(String path, List<(int, double, double)> fixes) { + final db = sqlite3.sqlite3.open(path); + db.execute('PRAGMA auto_vacuum=INCREMENTAL'); + db.execute('PRAGMA journal_mode=WAL'); + db.execute( + 'CREATE TABLE IF NOT EXISTS fix (' + 'id INTEGER PRIMARY KEY, t INTEGER NOT NULL, ' + 'lat INTEGER NOT NULL, lng INTEGER NOT NULL)', + ); + + for (final (time, latitude, longitude) in fixes) { + final lat = (latitude * _scale).round(); + final lng = (longitude * _scale).round(); + + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + var rowid = last + 1; + final previous = rowid % _anchorEvery == 0 ? null : _lastAbsolute(db); + if (previous == null && rowid % _anchorEvery != 0) { + rowid += _anchorEvery - rowid % _anchorEvery; + } + + db.execute('INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)', [ + rowid, + previous == null ? time : time - previous.$1, + previous == null ? lat : lat - previous.$2, + previous == null ? lng : lng - previous.$3, + ]); + } + db.close(); +} + +(int, int, int)? _lastAbsolute(sqlite3.Database db) { + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + if (last == 0) return null; + final anchor = last - last % _anchorEvery; + (int, int, int)? current; + for (final row in db.select( + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', + [anchor], + )) { + final id = row['id'] as int; + final t = row['t'] as int; + final lat = row['lat'] as int; + final lng = row['lng'] as int; + current = (id % _anchorEvery == 0 || current == null) + ? (t, lat, lng) + : (current.$1 + t, current.$2 + lat, current.$3 + lng); + } + return current; +} + +/// A plausible walk: a fix a minute, drifting a few metres each time. +List<(int, double, double)> _walk(int count, {int from = 1735689600}) => [ + for (var i = 0; i < count; i++) + (from + i * 60, 25.0330 + i * 0.0003, 121.5654 + i * 0.0002), +]; + +void main() { + late Directory dir; + late String path; + + setUp(() { + dir = Directory.systemTemp.createTempSync('location_track_test'); + path = '${dir.path}/location_track.db'; + }); + + tearDown(() => dir.deleteSync(recursive: true)); + + test('no file is an empty history, not an error', () { + expect(LocationTrack.at(path), isNull); + }); + + test('a single fix round-trips', () async { + _writeFixture(path, [(1735689600, 25.0330, 121.5654)]); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final fixes = await track.since(DateTime.utc(2000)); + expect(fixes, hasLength(1)); + expect( + fixes.single.time, + DateTime.fromMillisecondsSinceEpoch(1735689600 * 1000, isUtc: true), + ); + expect(fixes.single.latitude, closeTo(25.0330, 1e-9)); + expect(fixes.single.longitude, closeTo(121.5654, 1e-9)); + }); + + test('every fix survives several anchor boundaries', () async { + // 200 rows crosses the 64-row boundary three times, so the reader has to + // switch between "absolute" and "add to the running total" repeatedly. A + // one-row-out mistake there decodes into positions that drift. + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final fixes = await track.since(DateTime.utc(2000)); + expect(fixes, hasLength(expected.length)); + for (var i = 0; i < expected.length; i++) { + final (time, latitude, longitude) = expected[i]; + expect( + fixes[i].time.millisecondsSinceEpoch ~/ 1000, + time, + reason: 'time at $i', + ); + // Four decimals is what the store keeps, so that is the tolerance. + expect(fixes[i].latitude, closeTo(latitude, 5e-5), reason: 'lat at $i'); + expect(fixes[i].longitude, closeTo(longitude, 5e-5), reason: 'lng at $i'); + } + }); + + test( + 'a window starting mid-group still decodes absolute positions', + () async { + // The point of the anchor walk: row 100 is a delta, meaningless on its + // own. Asking for a window that begins there must produce the same + // coordinates as reading the whole track, not a delta read as a position. + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final all = await track.since(DateTime.utc(2000)); + final from = all[100].time; + final window = await track.since(from); + + expect(window, hasLength(all.length - 100)); + expect(window.first.latitude, closeTo(all[100].latitude, 1e-9)); + expect(window.first.longitude, closeTo(all[100].longitude, 1e-9)); + expect(window.last.latitude, closeTo(all.last.latitude, 1e-9)); + }, + ); + + test('a window older than the whole track returns all of it', () async { + _writeFixture(path, _walk(70)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + expect(await track.since(DateTime.utc(1990)), hasLength(70)); + }); + + test('limit keeps the most recent fixes', () async { + final expected = _walk(200); + _writeFixture(path, expected); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final all = await track.since(DateTime.utc(2000)); + final tail = await track.since(DateTime.utc(2000), limit: 10); + expect(tail, hasLength(10)); + expect(tail.last.time, all.last.time); + expect(tail.first.time, all[190].time); + }); + + test('the first fix lands on an anchor rowid', () { + // The writer has nothing to subtract from on the very first fix, so it + // moves the row up to the next multiple of 64 rather than writing an + // absolute value at a rowid the reader would treat as a delta. Everything + // below depends on this, so it is asserted rather than assumed. + _writeFixture(path, _walk(3)); + final db = sqlite3.sqlite3.open(path); + addTearDown(db.close); + final ids = db + .select('SELECT id FROM fix ORDER BY id') + .map((row) => row['id'] as int) + .toList(); + expect(ids, [_anchorEvery, _anchorEvery + 1, _anchorEvery + 2]); + }); + + test('the track still decodes after the oldest groups are evicted', () async { + // What native eviction does: delete whole anchor groups off the front. + // The remaining rows still read correctly only because the first survivor + // is itself an anchor — drop one row fewer and every position after it + // would be a delta read as a coordinate, somewhere off the coast. + final expected = _walk(200); + _writeFixture(path, expected); + + // Rows start at rowid 64, so expected[i] is at rowid 64 + i. Deleting + // below 128 takes the first whole group, expected[0..63]. + const dropped = _anchorEvery; + final writer = sqlite3.sqlite3.open(path); + writer.execute('DELETE FROM fix WHERE id < ${_anchorEvery * 2}'); + writer.close(); + + final track = LocationTrack.at(path)!; + addTearDown(track.close); + final fixes = await track.since(DateTime.utc(2000)); + + expect(fixes, hasLength(expected.length - dropped)); + final (time, latitude, longitude) = expected[dropped]; + expect(fixes.first.time.millisecondsSinceEpoch ~/ 1000, time); + expect(fixes.first.latitude, closeTo(latitude, 5e-5)); + expect(fixes.first.longitude, closeTo(longitude, 5e-5)); + // And the tail is untouched by the eviction. + expect(fixes.last.latitude, closeTo(expected.last.$2, 5e-5)); + }); + + test('stats report the row count and the file size', () async { + _writeFixture(path, _walk(200)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final stats = await track.stats(); + expect(stats.fixes, 200); + expect(stats.bytes, greaterThan(0)); + }); + + test('the connection cannot write', () async { + // The separation of duties is enforced by the connection, not by care: + // the native side owns every write, including eviction. + _writeFixture(path, _walk(10)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + await expectLater( + track.connection.execute('DELETE FROM fix'), + throwsA(isA()), + ); + }); + + test('the encoding stays compact', () async { + // The claim behind the 50 MB budget is roughly 14 bytes a row. This does + // not pin the exact figure — page overhead and the WAL move it — but it + // does fail if someone stores absolutes again, which would roughly double + // it and quietly halve how much history fits. + _writeFixture(path, _walk(20000)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final stats = await track.stats(); + expect(stats.fixes, 20000); + expect( + stats.bytes / stats.fixes, + lessThan(20), + reason: '${stats.bytes} bytes for ${stats.fixes} fixes', + ); + }); +} diff --git a/test/core/platform/background_location_test.dart b/test/core/platform/background_location_test.dart index fc422f45f..fc84afff6 100644 --- a/test/core/platform/background_location_test.dart +++ b/test/core/platform/background_location_test.dart @@ -83,6 +83,50 @@ void main() { }, ); + test('clearTrack asks the native side to delete, never Dart', () async { + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + await service.clearTrack(); + + // The whole point of the round trip. The recorder caches its database + // handle for the life of the process, so a file unlinked from Dart would + // leave it writing into a dead inode — the rows return on the next read + // and the space never comes back. One side owns every write, this one too. + expect(calls.single.method, 'clearTrack'); + expect(calls.single.arguments, isNull); + }); + + test('clearTrack survives a platform with no such method', () async { + messenger.setMockMethodCallHandler(channel, null); + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + // A developer-page button must not throw on a platform that never recorded + // anything — nothing to clear is not a failure. + await expectLater(service.clearTrack(), completes); + }); + + test('clearTrack swallows a platform failure', () async { + messenger.setMockMethodCallHandler( + channel, + (call) async => throw PlatformException(code: 'disk'), + ); + final service = BackgroundLocationService( + platform: 0, + version: '1', + channel: channel, + ); + + await expectLater(service.clearTrack(), completes); + }); + test('a platform failure is swallowed, not thrown', () async { messenger.setMockMethodCallHandler( channel, diff --git a/tool/check/storage.sh b/tool/check/storage.sh index 8f743f136..7a2a85ad3 100755 --- a/tool/check/storage.sh +++ b/tool/check/storage.sh @@ -25,7 +25,8 @@ lib/core/storage/app_database.dart lib/core/astro/tle_store.dart lib/core/meshtastic/data/mesh_store.dart lib/core/network/etag_cache_store.dart -lib/core/network/network_usage_store.dart' +lib/core/network/network_usage_store.dart +lib/core/geo/location_track.dart' fail=0 while IFS= read -r file; do