Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,19 @@ jobs:
# Keyed on the lockfile, so it is reused until a dependency actually
# moves. Without it every release re-downloads the full package set —
# mise caches the SDK but knows nothing about pub.
#
# No restore-keys fallback: `~/.pub-cache/git` mirrors the exact fork
# commit the lockfile resolved, and a lockfile bump (e.g. a new MapLibre
# fork pin) must not reuse a mirror of the previous commit — pub then
# resolves the old fork's dependency set and "removes" packages the new
# lockfile still needs, corrupting .dart_tool/package_graph.json. A
# precise key costs one full download on a dependency change and is
# correct every time.
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('pubspec.lock') }}
restore-keys: ${{ runner.os }}-pub-

# The iOS build resolves its dependencies through Swift Package Manager
# (there is no Podfile). Uncached, every release re-clones and rebuilds
Expand Down
6 changes: 5 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ dependencies {
// put it on a consumer's compile classpath, so calling addListener/get needs
// this explicitly.
implementation("androidx.concurrent:concurrent-futures:1.2.0")
// Durable, queryable watchdog behind the geofence/alarm background-location
// path. WorkManager persists its unique periodic work across process death
// and reboot; it only repairs an overdue path and never takes a healthy
// path's extra location fix.
implementation("androidx.work:work-runtime:2.11.2")
// Backports java.time/etc. for awesome_notifications (see compileOptions).
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}

17 changes: 10 additions & 7 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
<!-- Location (geolocator): resolve the device's township for local alerts. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Background reporting: an adaptive-interval alarm updates the device
township while the app is backgrounded (LocationAlarmReceiver), instead
of a battery-hungry foreground service. -->
<!-- Background reporting: geofence/alarm receivers enqueue a JobService
instead of doing location and network work inside a broadcast window. -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<!-- Onboarding "省電白名單": request Doze exemption so background reporting
keeps running on aggressive battery managers (safety-app use). -->
Expand Down Expand Up @@ -65,16 +64,20 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Background device-location reporting (native-first). Primary spine:
a low-power EXIT geofence fires this receiver on real moves. -->
<!-- A low-power EXIT geofence fires this receiver on real moves. -->
<receiver
android:name=".GeofenceReceiver"
android:exported="false" />
<!-- Fallback spine (de-Googled devices): an adaptive alarm fires this
receiver, which reports the fix and reschedules. -->
<!-- Independent bounded fallback on every Android device. -->
<receiver
android:name=".LocationAlarmReceiver"
android:exported="false" />
<!-- Receivers only enqueue work; this service owns location, geofence
registration, and HTTP outside BroadcastReceiver time limits. -->
<service
android:name=".BackgroundLocationJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!-- Re-arms the alarm after a reboot (elapsed-realtime alarms clear). -->
<receiver
android:name=".LocationBootReceiver"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ class BackgroundExecutionChannel(private val context: Context) :
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"status" -> result.success(status())
"openSystemSettings" -> result.success(openAppDetails())
"openOemSettings" -> result.success(openOemSettings())
else -> result.notImplemented()
}
Expand Down Expand Up @@ -368,6 +369,16 @@ class BackgroundExecutionChannel(private val context: Context) :
Log.i(TAG, "vendor screen $screen unavailable", e)
}
}
return openAppDetails()
}

/**
* The standard Android restriction and an OEM's auto-start manager are two
* different controls. This destination deliberately skips the OEM chain so
* a user fixing Android's own "Restricted" state lands on DPIP's app page,
* not on (for example) Samsung's sleeping-app list.
*/
private fun openAppDetails(): String {
return try {
context.startActivity(
Intent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
Expand All @@ -15,10 +14,9 @@ import io.flutter.plugin.common.MethodChannel
* Starts/stops autonomous background device-location reporting — the Android
* counterpart of iOS `BackgroundLocationPlugin`.
*
* Primary spine (with Google Play services): a low-power, event-driven,
* OEM-kill-resistant **EXIT geofence** ([GeofenceManager]) — an initial fix is
* taken and a geofence armed around it; the OS reports only real moves. Fallback
* (de-Googled devices): the adaptive-interval alarm ([LocationAlarmScheduler]).
* Fast path (with Google Play services): a low-power **EXIT geofence**. An
* independent 10–30 minute alarm remains behind it on every Android device and
* repairs silent geofence loss. Long work runs in [BackgroundLocationJobService].
* The foreground `DeviceLocationReporter` covers the app-open case; this is the
* terminated/background safety net.
*/
Expand All @@ -27,7 +25,6 @@ class BackgroundLocationChannel(private val context: Context) :

companion object {
const val NAME = "com.exptech.dpip/background_location"
private const val TAG = "DpipBgLocation"
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
Expand All @@ -40,42 +37,36 @@ class BackgroundLocationChannel(private val context: Context) :
result.error("bad_args", "Missing start args", null)
return
}
BgLocationStore.saveConfig(context, token, version, platform)
BgLocationStore.note(
context,
"start: gms=${GmsAvailability.available(context)} " +
"bgPermission=${GeofenceManager.hasPermission(context)}",
val wasEnabled = BgLocationStore.enabled(context)
val sameConfig = BgLocationStore.configMatches(
context, token, version, platform,
)
if (GmsAvailability.available(context)) {
// The alarm is NOT cancelled here. Arming the geofence needs
// a fix, and getting one can fail — location off at that
// moment, a 15 s BALANCED timeout indoors, no fresh cached
// fix. Cancelling first meant a failed arm left the device
// with neither spine and nothing scheduled to retry, so
// background reporting silently stopped until the next time
// the user opened the app. [armGeofence] stands the alarm
// down only once Play services confirms a fence is live.
//
// Schedule before arming, not after. Arming waits on a fix
// (up to ~20 s) and then on an asynchronous registration,
// and on a first-ever enable — or any stop/start from the
// developer page, which cancels the alarm — there is nothing
// pending for that whole window. A process death inside it
// left reporting enabled with no fence and no alarm, and
// nothing that would ever notice.
LocationAlarmScheduler.ensure(context)
armGeofence(context.applicationContext)
} else {
GeofenceManager.remove(context)
BgLocationStore.prefs(context).edit()
.putLong(
BgLocationStore.KEY_INTERVAL_MIN,
LocationAlarmScheduler.DEFAULT_INTERVAL_MIN,
)
.apply()
LocationAlarmScheduler.schedule(
context, LocationAlarmScheduler.DEFAULT_INTERVAL_MIN,
val hadPermission = BgLocationStore.permissionReady(context)
val hasPermission = GeofenceManager.hasPermission(context)

if (!sameConfig) BgLocationStore.saveConfig(context, token, version, platform)
BgLocationStore.setPermissionReady(context, hasPermission)
LocationAlarmScheduler.ensure(context)
BackgroundLocationWatchdog.ensure(context)

// An unchanged start preserves both scheduler deadlines and does
// no location work. Flutter calls this on resume; re-registering
// the fence and taking a fix there caused the foreground wake storm.
val reason = when {
!sameConfig && !wasEnabled -> BackgroundLocationJobService.REASON_START
hasPermission && !hadPermission ->
BackgroundLocationJobService.REASON_PERMISSION_RESTORED
!sameConfig -> BackgroundLocationJobService.REASON_CONFIG
else -> null
}
if (!hasPermission) {
BgLocationStore.setArmed(context, false)
} else if (reason != null) {
BgLocationStore.note(
context,
"start: $reason gms=${GmsAvailability.available(context)}",
)
BackgroundLocationJobService.enqueue(context, reason)
}
result.success(null)
}
Expand All @@ -84,10 +75,17 @@ class BackgroundLocationChannel(private val context: Context) :
BgLocationStore.disable(context)
GeofenceManager.remove(context)
LocationAlarmScheduler.cancel(context)
BackgroundLocationWatchdog.cancel(context)
BackgroundLocationJobService.cancel(context)
result.success(null)
}

"diagnostics" -> result.success(diagnostics())
// WorkManager exposes real work state asynchronously. Query it off
// the platform thread so opening Developer diagnostics cannot stall UI.
"diagnostics" -> Thread {
val snapshot = diagnostics()
Handler(Looper.getMainLooper()).post { result.success(snapshot) }
}.start()

// Everything the background path recorded since the last drain, so
// it can be written into the app's own log. A BroadcastReceiver has
Expand Down Expand Up @@ -122,55 +120,13 @@ class BackgroundLocationChannel(private val context: Context) :
}
}

// Take an initial fix off the main thread, arm the geofence around it, and
// report it.
//
// Every path out of here leaves exactly one spine armed. The geofence is the
// one worth having — Play services keeps monitoring it after an OEM battery
// manager kills our process — but it can only be armed from a fix we do not
// always get, and `addGeofences` can still refuse afterwards. So the alarm
// stays scheduled until the fence is confirmed live, and is (re)scheduled if
// it is not. Both running briefly is harmless: the report is an idempotent
// GET, and the next successful arm cancels the alarm.
private fun armGeofence(appContext: Context) {
Thread {
try {
val location = FusedFix.get(appContext)
if (location == null) {
Log.w(TAG, "no fix available — geofence not armed, keeping the alarm")
BgLocationStore.note(appContext, "arm: no fix, alarm only")
LocationAlarmScheduler.ensure(appContext)
return@Thread
}
if (!BgLocationStore.enabled(appContext)) return@Thread
// Arm the fence first (spine safety), then report.
GeofenceManager.register(appContext, location.latitude, location.longitude) { armed ->
if (armed) {
BgLocationStore.note(appContext, "arm: geofence live")
LocationAlarmScheduler.resetWatchdog(appContext)
} else {
Log.w(TAG, "geofence refused — falling back to the alarm")
BgLocationStore.note(appContext, "arm: geofence refused, alarm only")
LocationAlarmScheduler.ensure(appContext)
}
}
BgLocationStore.report(appContext, location.latitude, location.longitude)
} catch (e: Exception) {
Log.w(TAG, "background location arm failed", e)
BgLocationStore.note(appContext, "arm failed: ${e.javaClass.simpleName}: ${e.message}")
LocationAlarmScheduler.ensure(appContext)
}
}.start()
}

/**
* A snapshot of whether background reporting is actually working, for the
* developer page. Keys are shared with the iOS plugin so one UI renders both.
*
* `armed` is the honest answer to "is anything monitoring right now", which
* is the question a user's bug report needs and the one nothing here could
* previously answer: the Geofencing API cannot be queried, so it is tracked
* as state; the alarm can be, via a no-create PendingIntent probe.
* The Geofencing API and AlarmManager expose no query for live registrations,
* so diagnostics report the last accepted registration/scheduling calls and
* the independent delivery/HTTP evidence beside them.
*/
private fun diagnostics(): Map<String, Any?> {
val prefs = BgLocationStore.prefs(context)
Expand All @@ -185,7 +141,28 @@ class BackgroundLocationChannel(private val context: Context) :
val canFix = GeofenceManager.hasPermission(context)
val fenceArmed = BgLocationStore.armed(context) && canFix
val alarmArmed = LocationAlarmScheduler.isScheduled(context) && canFix
val lastReportAt = prefs.getLong(BgLocationStore.KEY_LAST_REPORT_AT, 0L)
val legacyAttemptAt = prefs.getLong(BgLocationStore.KEY_LEGACY_LAST_REPORT_AT, 0L)
val attemptAt = prefs.getLong(BgLocationStore.KEY_LAST_ATTEMPT_AT, legacyAttemptAt)
val legacyAttemptOk = prefs.getBoolean(BgLocationStore.KEY_LEGACY_LAST_REPORT_OK, false)
val attemptOk = if (prefs.contains(BgLocationStore.KEY_LAST_ATTEMPT_OK)) {
prefs.getBoolean(BgLocationStore.KEY_LAST_ATTEMPT_OK, false)
} else {
legacyAttemptOk
}
val legacyAttemptCode = prefs.getInt(BgLocationStore.KEY_LEGACY_LAST_REPORT_CODE, -1)
val attemptCode = if (prefs.contains(BgLocationStore.KEY_LAST_ATTEMPT_CODE)) {
prefs.getInt(BgLocationStore.KEY_LAST_ATTEMPT_CODE, -1)
} else {
legacyAttemptCode
}
val successAt = prefs.getLong(
BgLocationStore.KEY_LAST_SUCCESS_AT,
if (legacyAttemptOk) legacyAttemptAt else 0L,
)
val successCode = prefs.getInt(
BgLocationStore.KEY_LAST_SUCCESS_CODE,
if (legacyAttemptOk) legacyAttemptCode else 0,
)
return mapOf(
"enabled" to BgLocationStore.enabled(context),
"authorization" to authorization(),
Expand All @@ -202,18 +179,21 @@ class BackgroundLocationChannel(private val context: Context) :
"wakeGeofence" to prefs.getInt("wake_geofence_n", 0),
"wakeAlarm" to prefs.getInt("wake_alarm_n", 0),
"wakeBoot" to prefs.getInt("wake_boot_n", 0),
"lastGeofenceError" to (
prefs.getInt("last_geofence_error", 0).takeIf { it != 0 }
),
"lastReportAt" to (if (lastReportAt == 0L) null else lastReportAt),
"lastReportOk" to (
if (lastReportAt == 0L) null
else prefs.getBoolean(BgLocationStore.KEY_LAST_REPORT_OK, false)
),
"lastReportCode" to (
if (lastReportAt == 0L) null
else prefs.getInt(BgLocationStore.KEY_LAST_REPORT_CODE, -1)
),
"lastGeofenceError" to prefs.getInt("last_geofence_error", 0)
.takeIf { it != 0 }
?.toString(),
"lastGeofenceTransitionAt" to prefs
.getLong("last_geofence_transition_at", 0L)
.takeIf { it > 0L },
"lastAttemptAt" to attemptAt.takeIf { it > 0L },
"lastAttemptOk" to attemptOk.takeIf { attemptAt > 0L },
"lastAttemptCode" to attemptCode.takeIf { attemptAt > 0L },
"lastSuccessAt" to successAt.takeIf { it > 0L },
"lastSuccessCode" to successCode.takeIf { successAt > 0L },
"lastThrottledAt" to prefs.getLong(BgLocationStore.KEY_LAST_THROTTLED_AT, 0L)
.takeIf { it > 0L },
"throttledCount" to prefs.getInt(BgLocationStore.KEY_THROTTLED_N, 0),
"nextAlarmAt" to LocationAlarmScheduler.nextWallTime(context),
"centreLat" to (
if (BgLocationStore.hasLast(context)) BgLocationStore.lastLat(context) else null
),
Expand All @@ -225,19 +205,14 @@ class BackgroundLocationChannel(private val context: Context) :
append(", geofence ").append(if (fenceArmed) "armed" else "not armed")
append(", alarm ")
if (alarmArmed) {
append("scheduled every ")
.append(
prefs.getLong(
BgLocationStore.KEY_INTERVAL_MIN,
LocationAlarmScheduler.DEFAULT_INTERVAL_MIN,
),
)
append("scheduled, adaptive ")
.append(LocationAlarmScheduler.storedInterval(context))
.append(" min")
} else {
append("not scheduled")
}
},
)
) + BackgroundLocationWatchdog.diagnostics(context)
}

/** The OS location authorization, in the same vocabulary the iOS side uses. */
Expand Down
Loading
Loading