Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7501905
feat(location): record background fixes to a compressed iOS store
whes1015 Aug 24, 2026
1f58370
feat(location): record background fixes to a compressed Android store
whes1015 Aug 24, 2026
de19232
feat(location): read the recorded movement history from Dart
whes1015 Aug 24, 2026
064af3f
feat(bug-tracker): distinguish developers from triage staff on threads
whes1015 Aug 25, 2026
8f0ad4d
feat(bug-tracker): add three triage staff to the staff list
whes1015 Aug 25, 2026
e0472f0
feat(bug-tracker): add 898836485397180426 to the staff list
whes1015 Aug 25, 2026
c6a1d26
feat(debug): show how many fixes the location track holds
whes1015 Aug 25, 2026
c8e222c
feat(debug): add a button that clears the location track
whes1015 Aug 25, 2026
75c3e83
docs(more): rewrite the developer note around backlog transparency
whes1015 Aug 25, 2026
8b9c9a1
fix(status): cap the 5xx error rate at three digits
whes1015 Aug 25, 2026
1b45f6d
fix(map): lift the zoom caps that left deep zooms blurry
whes1015 Aug 25, 2026
6d41b6a
fix(map): correct layer caps against a full server re-probe
whes1015 Aug 25, 2026
9b3948d
feat(map): clamp frame sources to their published min zoom too
whes1015 Aug 25, 2026
88aa564
fix(notify): send a tapped notification to what it is about
whes1015 Aug 27, 2026
d1dc65a
fix(bug-tracker): follow the tracker's new tag vocabulary
whes1015 Aug 27, 2026
72906ee
fix(bug-tracker): separate a thread's reply count from its time
whes1015 Aug 27, 2026
2a4dad7
fix(earthquake): return to the list when a report no longer exists
whes1015 Aug 27, 2026
5070a20
refactor(log): start the launch clock before anything reads it
whes1015 Aug 27, 2026
e3b2dd6
build: add a debug APK target to the build script
whes1015 Aug 27, 2026
5644ac9
refactor(map): drop the Flutter wind-particle fallback
whes1015 Aug 27, 2026
e21ffa2
fix(map): shorten the wind particle trails to a dash
whes1015 Aug 27, 2026
811bfd8
perf(map): stop the disaster overlays at the last distinct DPM level
whes1015 Aug 27, 2026
3cce77b
fix(weather): widen the frame overlays to their published zoom range
whes1015 Aug 27, 2026
e3116f9
fix(map): mount every weather frame with its own zoom range
whes1015 Aug 27, 2026
192dbc8
perf(map): declare the basemap and terrain ceilings from one place
whes1015 Aug 27, 2026
086f2b6
chore: delete the ARB migrations that have already run
whes1015 Aug 27, 2026
7529f2b
build: move the release-highlights generator out of the ARB folder
whes1015 Aug 27, 2026
cd88e31
refactor(data): share the astronomy pages' observer township
whes1015 Aug 27, 2026
2d7e106
refactor: ask a home area for its township code
whes1015 Aug 27, 2026
85f0593
refactor(storage): drop the schema version nothing reads
whes1015 Aug 27, 2026
2b5d664
fix(bug-tracker): show the bug list's own empty message
whes1015 Aug 27, 2026
cbb3eb3
perf(eew): take the shorter route to the same shaking numbers
whes1015 Aug 27, 2026
56d03ce
perf(home): stop the raindrop solver allocating per contact
whes1015 Aug 27, 2026
5eb6b69
perf(home): cut the per-cloud work out of the sky's draw
whes1015 Aug 27, 2026
a8fa230
perf(eew): build the township centroids once, not per alert
whes1015 Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
213 changes: 213 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/LocationTrackStore.kt
Original file line number Diff line number Diff line change
@@ -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<Any>(
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
}
}
}
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -94,6 +95,7 @@
A382CD9DEA741E45DBF741D7 /* rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = "<group>"; };
AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = "<group>"; };
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = "<group>"; };
B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
14 changes: 14 additions & 0 deletions ios/Runner/BackgroundLocationPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading