@bricks-soft/capacitor-location-tracking 8.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +375 -124
- package/android/build.gradle +2 -1
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +4 -1
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +14 -5
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +70 -15
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +8 -3
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +10 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SecretStore.kt +188 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +188 -17
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +11 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +21 -5
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +20 -9
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +24 -2
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +43 -1
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceDetails.kt +167 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +191 -34
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +6 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +3 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +52 -8
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +78 -27
- package/android/src/main/java/com/brickssoft/locationtracking/http/ConfigChecks.kt +140 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +50 -12
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +17 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +507 -92
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +6 -6
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +23 -1
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadJobs.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/UploadWait.kt +184 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +66 -7
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogRedaction.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +44 -1
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +79 -8
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +12 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +10 -2
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +24 -1
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +31 -10
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +48 -3
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/LocationSubscriptions.kt +180 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +200 -126
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +20 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +39 -51
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +8 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +47 -53
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +34 -2
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +19 -1
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +7 -0
- package/android/src/main/res/xml/lt_file_paths.xml +1 -1
- package/dist/esm/definitions.d.ts +107 -4
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/events.d.ts +8 -2
- package/dist/esm/events.js +7 -1
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web/config.js +10 -0
- package/dist/esm/web/config.js.map +1 -1
- package/dist/esm/web/device.d.ts +2 -2
- package/dist/esm/web/device.js +9 -2
- package/dist/esm/web/device.js.map +1 -1
- package/dist/esm/web.d.ts +0 -8
- package/dist/esm/web.js +12 -3
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +40 -6
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +40 -6
- package/dist/plugin.js.map +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
package com.brickssoft.locationtracking.device
|
|
2
|
+
|
|
3
|
+
import android.app.ActivityManager
|
|
4
|
+
import android.app.usage.UsageStatsManager
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.os.BatteryManager
|
|
9
|
+
import android.os.Build
|
|
10
|
+
import android.os.PowerManager
|
|
11
|
+
import android.os.StatFs
|
|
12
|
+
import android.provider.Settings
|
|
13
|
+
import com.brickssoft.locationtracking.core.Logger
|
|
14
|
+
import com.brickssoft.locationtracking.model.BatteryDetails
|
|
15
|
+
import com.brickssoft.locationtracking.model.DeviceSnapshot
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Device readings carried by records (the device-information parts d1 to d7):
|
|
19
|
+
* - [deviceId]: `Settings.Secure.ANDROID_ID`, on every record (`device.device_id`); read once per process.
|
|
20
|
+
* - [forHeartbeat]: `background_restricted` and `standby_bucket` (Android 9+), `disk_free_bytes`, `thermal_status`
|
|
21
|
+
* (Android 10+). They explain gaps: a "Restricted" battery setting, a low standby bucket and a full disk each stop or
|
|
22
|
+
* delay records, and heartbeats are the records sent while nothing else is recorded.
|
|
23
|
+
* - [batteryDetails]: battery `temperature`, `health` and `plugged`, for heartbeat records.
|
|
24
|
+
* - [forTrackingStart]: phone `manufacturer`, `model`, `os_version`, `sdk_int`, `low_ram_device` and
|
|
25
|
+
* `ram_total_bytes`. They do not change during a session.
|
|
26
|
+
*
|
|
27
|
+
* Every reading is null when the Android version does not have it or reading it fails (logged); none needs a
|
|
28
|
+
* permission. Never throws.
|
|
29
|
+
*/
|
|
30
|
+
interface DeviceDetails {
|
|
31
|
+
fun forHeartbeat(): DeviceSnapshot
|
|
32
|
+
|
|
33
|
+
fun forTrackingStart(): DeviceSnapshot
|
|
34
|
+
|
|
35
|
+
fun batteryDetails(): BatteryDetails?
|
|
36
|
+
|
|
37
|
+
fun deviceId(): String?
|
|
38
|
+
|
|
39
|
+
companion object {
|
|
40
|
+
/** No readings (tests, and callers without a Context). */
|
|
41
|
+
val NONE: DeviceDetails = object : DeviceDetails {
|
|
42
|
+
override fun forHeartbeat(): DeviceSnapshot = DeviceSnapshot()
|
|
43
|
+
|
|
44
|
+
override fun forTrackingStart(): DeviceSnapshot = DeviceSnapshot()
|
|
45
|
+
|
|
46
|
+
override fun batteryDetails(): BatteryDetails? = null
|
|
47
|
+
|
|
48
|
+
override fun deviceId(): String? = null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** [DeviceDetails] from the platform services. */
|
|
54
|
+
class DefaultDeviceDetails(context: Context, private val sdkInt: Int = Build.VERSION.SDK_INT) : DeviceDetails {
|
|
55
|
+
private val appContext: Context = context.applicationContext ?: context
|
|
56
|
+
|
|
57
|
+
// One instance per process (Components); ANDROID_ID only changes on a factory reset or a new signing key.
|
|
58
|
+
private val deviceId: String? by lazy {
|
|
59
|
+
read("ANDROID_ID") { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun forHeartbeat(): DeviceSnapshot = DeviceSnapshot(
|
|
63
|
+
backgroundRestricted = read("isBackgroundRestricted") {
|
|
64
|
+
if (sdkInt >= Build.VERSION_CODES.P) activityManager()?.isBackgroundRestricted else null
|
|
65
|
+
},
|
|
66
|
+
standbyBucket = read("getAppStandbyBucket") {
|
|
67
|
+
if (sdkInt >= Build.VERSION_CODES.P) {
|
|
68
|
+
appContext.getSystemService(UsageStatsManager::class.java)?.appStandbyBucket?.let(::standbyBucketName)
|
|
69
|
+
} else {
|
|
70
|
+
null
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
diskFreeBytes = read("StatFs") { StatFs(appContext.filesDir.path).availableBytes },
|
|
74
|
+
thermalStatus = read("getCurrentThermalStatus") {
|
|
75
|
+
if (sdkInt >= Build.VERSION_CODES.Q) {
|
|
76
|
+
appContext.getSystemService(PowerManager::class.java)?.currentThermalStatus?.let(::thermalStatusName)
|
|
77
|
+
} else {
|
|
78
|
+
null
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
override fun forTrackingStart(): DeviceSnapshot {
|
|
84
|
+
val memory = read("getMemoryInfo") {
|
|
85
|
+
activityManager()?.let { manager -> ActivityManager.MemoryInfo().also { manager.getMemoryInfo(it) } }
|
|
86
|
+
}
|
|
87
|
+
return DeviceSnapshot(
|
|
88
|
+
manufacturer = Build.MANUFACTURER.orEmpty(),
|
|
89
|
+
model = Build.MODEL.orEmpty(),
|
|
90
|
+
osVersion = Build.VERSION.RELEASE.orEmpty(),
|
|
91
|
+
sdkInt = sdkInt,
|
|
92
|
+
lowRamDevice = read("isLowRamDevice") { activityManager()?.isLowRamDevice },
|
|
93
|
+
ramTotalBytes = memory?.totalMem?.takeIf { it > 0 },
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override fun deviceId(): String? = deviceId
|
|
98
|
+
|
|
99
|
+
override fun batteryDetails(): BatteryDetails? = read("battery details") {
|
|
100
|
+
val intent: Intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
|
101
|
+
?: return@read null
|
|
102
|
+
val temperature = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
|
|
103
|
+
BatteryDetails(
|
|
104
|
+
temperature = if (temperature == Int.MIN_VALUE) null else temperature / 10f,
|
|
105
|
+
health = healthName(intent.getIntExtra(BatteryManager.EXTRA_HEALTH, BatteryManager.BATTERY_HEALTH_UNKNOWN)),
|
|
106
|
+
plugged = pluggedName(intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private fun activityManager(): ActivityManager? = appContext.getSystemService(ActivityManager::class.java)
|
|
111
|
+
|
|
112
|
+
private inline fun <T> read(what: String, block: () -> T?): T? = try {
|
|
113
|
+
block()
|
|
114
|
+
} catch (e: Exception) {
|
|
115
|
+
Logger.w(TAG, "$what unavailable", e)
|
|
116
|
+
null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
internal companion object {
|
|
120
|
+
private const val TAG = "LT.DeviceDetails"
|
|
121
|
+
|
|
122
|
+
/** `UsageStatsManager` bucket values (EXEMPTED = 5 is not public API). */
|
|
123
|
+
fun standbyBucketName(bucket: Int): String = when {
|
|
124
|
+
bucket <= STANDBY_BUCKET_EXEMPTED -> "exempted"
|
|
125
|
+
bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active"
|
|
126
|
+
bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working_set"
|
|
127
|
+
bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent"
|
|
128
|
+
bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare"
|
|
129
|
+
else -> "restricted"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private const val STANDBY_BUCKET_EXEMPTED = 5
|
|
133
|
+
|
|
134
|
+
fun thermalStatusName(status: Int): String = when (status) {
|
|
135
|
+
PowerManager.THERMAL_STATUS_NONE -> "none"
|
|
136
|
+
PowerManager.THERMAL_STATUS_LIGHT -> "light"
|
|
137
|
+
PowerManager.THERMAL_STATUS_MODERATE -> "moderate"
|
|
138
|
+
PowerManager.THERMAL_STATUS_SEVERE -> "severe"
|
|
139
|
+
PowerManager.THERMAL_STATUS_CRITICAL -> "critical"
|
|
140
|
+
PowerManager.THERMAL_STATUS_EMERGENCY -> "emergency"
|
|
141
|
+
PowerManager.THERMAL_STATUS_SHUTDOWN -> "shutdown"
|
|
142
|
+
else -> "unknown"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fun healthName(health: Int): String = when (health) {
|
|
146
|
+
BatteryManager.BATTERY_HEALTH_GOOD -> "good"
|
|
147
|
+
BatteryManager.BATTERY_HEALTH_OVERHEAT -> "overheat"
|
|
148
|
+
BatteryManager.BATTERY_HEALTH_DEAD -> "dead"
|
|
149
|
+
BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE -> "over_voltage"
|
|
150
|
+
BatteryManager.BATTERY_HEALTH_COLD -> "cold"
|
|
151
|
+
BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE -> "failure"
|
|
152
|
+
else -> "unknown"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `EXTRA_PLUGGED`; the dock value (8) is public only from Android 14. */
|
|
156
|
+
fun pluggedName(plugged: Int): String = when (plugged) {
|
|
157
|
+
0 -> "none"
|
|
158
|
+
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
|
|
159
|
+
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
|
|
160
|
+
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
|
|
161
|
+
BATTERY_PLUGGED_DOCK -> "dock"
|
|
162
|
+
else -> "other"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private const val BATTERY_PLUGGED_DOCK = 8
|
|
166
|
+
}
|
|
167
|
+
}
|
package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
package com.brickssoft.locationtracking.engine
|
|
2
2
|
|
|
3
|
+
import android.os.Build
|
|
3
4
|
import com.brickssoft.locationtracking.config.Config
|
|
4
5
|
import com.brickssoft.locationtracking.config.ConfigStore
|
|
5
6
|
import com.brickssoft.locationtracking.config.GeolocationConfig
|
|
@@ -22,6 +23,8 @@ import com.brickssoft.locationtracking.http.HttpSyncer
|
|
|
22
23
|
import com.brickssoft.locationtracking.model.ActivitySample
|
|
23
24
|
import com.brickssoft.locationtracking.model.ActivityType
|
|
24
25
|
import com.brickssoft.locationtracking.model.GeofenceAction
|
|
26
|
+
import com.brickssoft.locationtracking.model.LocationProviderSetting
|
|
27
|
+
import com.brickssoft.locationtracking.model.ProviderKind
|
|
25
28
|
import com.brickssoft.locationtracking.model.PermissionLevel
|
|
26
29
|
import com.brickssoft.locationtracking.model.ProviderState
|
|
27
30
|
import com.brickssoft.locationtracking.model.Record
|
|
@@ -99,6 +102,8 @@ class DefaultTrackingEngine(
|
|
|
99
102
|
private val scope: CoroutineScope,
|
|
100
103
|
/** Background triggers do not restore tracking in a process started after a user force stop. */
|
|
101
104
|
private val forceStopProbe: ForceStopProbe = ForceStopProbe.NEVER,
|
|
105
|
+
/** `Build.VERSION.SDK_INT`; injectable for tests. */
|
|
106
|
+
private val sdkInt: Int = Build.VERSION.SDK_INT,
|
|
102
107
|
) : TrackingEngine {
|
|
103
108
|
private val mutex = Mutex()
|
|
104
109
|
private val engineContext: CoroutineContext =
|
|
@@ -109,6 +114,9 @@ class DefaultTrackingEngine(
|
|
|
109
114
|
private var session: Session? = null
|
|
110
115
|
private var nextSessionId = 1L
|
|
111
116
|
|
|
117
|
+
/** This process handled the user stop it started after (see [handleUserStop]). Guarded by [mutex]. */
|
|
118
|
+
private var userStopHandled = false
|
|
119
|
+
|
|
112
120
|
private val config: Config get() = configStore.config.value
|
|
113
121
|
private val runtime: RuntimeState get() = configStore.runtime.value
|
|
114
122
|
|
|
@@ -119,6 +127,8 @@ class DefaultTrackingEngine(
|
|
|
119
127
|
val new = configStore.ready(config, reset)
|
|
120
128
|
configStore.updateRuntime { it.copy(didReady = true) }
|
|
121
129
|
onConfigChanged(old, new)
|
|
130
|
+
// Queued records are sent whether tracking is on or off (recommendation 10).
|
|
131
|
+
guarded("syncer.start") { syncer.start() }
|
|
122
132
|
if (session == null && runtime.enabled) {
|
|
123
133
|
Logger.i(TAG, "ready: tracking is enabled but not running in this process; restoring")
|
|
124
134
|
restoreLocked(REASON_RESTORE)
|
|
@@ -172,7 +182,21 @@ class DefaultTrackingEngine(
|
|
|
172
182
|
|
|
173
183
|
override fun state(): State = State(configStore.config.value, configStore.runtime.value, providers.kind)
|
|
174
184
|
|
|
175
|
-
|
|
185
|
+
/**
|
|
186
|
+
* A restore the user did not start: boot, app update, heartbeat alarm, or the service restarted by the system
|
|
187
|
+
* (sticky or a start command of an earlier process). After a user stop (recommendation 6) the heartbeat-alarm and
|
|
188
|
+
* service restores (reason `restore`) do not restore; boot and app-update restores are not checked: on Android 11
|
|
189
|
+
* and 12 an update kill is probably recorded like a user stop.
|
|
190
|
+
*/
|
|
191
|
+
override suspend fun restore(reason: String) = serialized {
|
|
192
|
+
if (reason == REASON_RESTORE && session == null && runtime.enabled && forceStopProbe.startedAfterForceStop()) {
|
|
193
|
+
handleUserStop("restore after a user stop")
|
|
194
|
+
return@serialized
|
|
195
|
+
}
|
|
196
|
+
restoreLocked(reason, background = true)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
override fun startedAfterUserStop(): Boolean = runtime.enabled && forceStopProbe.startedAfterForceStop()
|
|
176
200
|
|
|
177
201
|
override suspend fun endWithoutRestore(reason: String) = serialized {
|
|
178
202
|
if (session != null || !runtime.enabled) return@serialized
|
|
@@ -206,7 +230,7 @@ class DefaultTrackingEngine(
|
|
|
206
230
|
Logger.i(TAG, "resume notification: resuming ${runtime.trackingMode.wire} tracking")
|
|
207
231
|
configStore.updateRuntime { it.copy(enabled = true) }
|
|
208
232
|
restoreLocked(REASON_RESUME_NOTIFICATION)
|
|
209
|
-
if (session != null) events.emit(TrackingEvent.EnabledChange(true))
|
|
233
|
+
if (session != null) events.emit(TrackingEvent.EnabledChange(true, REASON_RESUME_NOTIFICATION))
|
|
210
234
|
}
|
|
211
235
|
}
|
|
212
236
|
}
|
|
@@ -230,11 +254,24 @@ class DefaultTrackingEngine(
|
|
|
230
254
|
guarded("serviceController.showResumeNotification") { serviceController.showResumeNotification(deadline) }
|
|
231
255
|
}
|
|
232
256
|
|
|
233
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* When `stopAfterElapsedMinutes` ends the session (wall time, epoch ms); null without a limit. Within the boot in
|
|
259
|
+
* which the session started it is computed from elapsed realtime (`trackingStartedElapsed`), so setting the wall
|
|
260
|
+
* clock back or forward does not move it (recommendation 16); after a reboot, from the wall clock
|
|
261
|
+
* (`trackingStartedAt`).
|
|
262
|
+
*/
|
|
234
263
|
private fun elapsedStopAt(): Long? {
|
|
235
264
|
val minutes = config.geolocation.stopAfterElapsedMinutes
|
|
236
|
-
|
|
237
|
-
|
|
265
|
+
if (minutes <= 0) return null
|
|
266
|
+
val r = runtime
|
|
267
|
+
val startedAt = r.trackingStartedAt ?: return null
|
|
268
|
+
val startedElapsed = r.trackingStartedElapsed
|
|
269
|
+
val startedBoot = r.trackingStartedBootCount
|
|
270
|
+
if (startedElapsed != null && startedBoot != null && startedBoot >= 0 && startedBoot == clock.bootCount()) {
|
|
271
|
+
val remaining = minutes * MINUTE_MS - (clock.elapsedRealtime() - startedElapsed)
|
|
272
|
+
return clock.now() + remaining
|
|
273
|
+
}
|
|
274
|
+
return startedAt + minutes * MINUTE_MS
|
|
238
275
|
}
|
|
239
276
|
|
|
240
277
|
override suspend fun onTerminate() = serialized {
|
|
@@ -281,17 +318,15 @@ class DefaultTrackingEngine(
|
|
|
281
318
|
Logger.w(TAG, "startGeofences without background location permission: geofences may not fire in background")
|
|
282
319
|
}
|
|
283
320
|
val wasEnabled = runtime.enabled
|
|
284
|
-
|
|
285
|
-
configStore.updateRuntime {
|
|
286
|
-
it.copy(enabled = true, trackingMode = mode, isMoving = false, trackingStartedAt = startedAt)
|
|
287
|
-
}
|
|
321
|
+
upgradeBackendIfBetter(null)
|
|
322
|
+
configStore.updateRuntime { sessionStart(it).copy(enabled = true, trackingMode = mode, isMoving = false) }
|
|
288
323
|
if (!startService()) {
|
|
289
324
|
// Foreground location is granted (checked above): Android refused the foreground service, for example a
|
|
290
325
|
// start from the background, or Android 14+ with only while-in-use location permission. The error code
|
|
291
326
|
// stays PERMISSION_DENIED for the JS API; the audit record gives the precise reason.
|
|
292
327
|
configStore.updateRuntime { it.copy(enabled = false) }
|
|
293
328
|
if (wasEnabled) releaseOrphanedRegistrations()
|
|
294
|
-
|
|
329
|
+
submitStop(null, REASON_SERVICE_START_FAILED)
|
|
295
330
|
throw TrackingException(
|
|
296
331
|
ErrorCode.PERMISSION_DENIED,
|
|
297
332
|
"The location foreground service could not be started (Android refused a start from the background)",
|
|
@@ -299,7 +334,7 @@ class DefaultTrackingEngine(
|
|
|
299
334
|
}
|
|
300
335
|
// A process that died while tracking may have left its stationary region with the OS.
|
|
301
336
|
val s = activate(mode, reason, leftoverRegion = wasEnabled)
|
|
302
|
-
events.emit(TrackingEvent.EnabledChange(true))
|
|
337
|
+
events.emit(TrackingEvent.EnabledChange(true, reason))
|
|
303
338
|
if (mode == TrackingMode.LOCATION) launchInitialFix(s)
|
|
304
339
|
}
|
|
305
340
|
|
|
@@ -307,7 +342,7 @@ class DefaultTrackingEngine(
|
|
|
307
342
|
* Cold process with `runtime.enabled`: restarts everything in the persisted mode, without permission prompts. If the
|
|
308
343
|
* session's `stopAfterElapsedMinutes` deadline has passed, it records that stop instead and starts nothing.
|
|
309
344
|
*/
|
|
310
|
-
private suspend fun restoreLocked(reason: String) {
|
|
345
|
+
private suspend fun restoreLocked(reason: String, background: Boolean = false) {
|
|
311
346
|
if (!runtime.enabled) {
|
|
312
347
|
Logger.i(TAG, "restore($reason): tracking is not enabled; nothing to restore")
|
|
313
348
|
return
|
|
@@ -335,10 +370,21 @@ class DefaultTrackingEngine(
|
|
|
335
370
|
stopLocked(REASON_PERMISSION_DENIED)
|
|
336
371
|
return
|
|
337
372
|
}
|
|
373
|
+
if (background && sdkInt in Build.VERSION_CODES.R..Build.VERSION_CODES.TIRAMISU &&
|
|
374
|
+
!permissions.hasBackgroundLocation()
|
|
375
|
+
) {
|
|
376
|
+
// Recommendation 5: on Android 11 to 13 a location foreground service started from the background with
|
|
377
|
+
// only "while in use" permission is created but gets no location, so tracking would look on with no fixes.
|
|
378
|
+
// (Android 14+ refuses such a start in DefaultServiceController; Android 10 still gives it location.)
|
|
379
|
+
Logger.w(TAG, "restore($reason): Android 11-13 without background location permission; stopping")
|
|
380
|
+
stopLocked(REASON_SERVICE_START_FAILED)
|
|
381
|
+
offerResume()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
338
384
|
val mode = runtime.trackingMode
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
385
|
+
configStore.updateRuntime { current ->
|
|
386
|
+
val started = if (current.trackingStartedAt == null) sessionStart(current) else current
|
|
387
|
+
started.copy(enabled = true, trackingMode = mode, isMoving = false)
|
|
342
388
|
}
|
|
343
389
|
// The system may have restarted the service itself (START_STICKY), which then calls restore(): starting it again
|
|
344
390
|
// from the background could be refused on Android 12+ although it is already in the foreground.
|
|
@@ -389,8 +435,8 @@ class DefaultTrackingEngine(
|
|
|
389
435
|
recordMotionChange(s, isMoving = false, bestKnownLocation(s))
|
|
390
436
|
}
|
|
391
437
|
s.mode = mode
|
|
392
|
-
|
|
393
|
-
configStore.updateRuntime { it.copy(trackingMode = mode, isMoving = false
|
|
438
|
+
// A new stop window starts (owner decision on recommendation 16b: keep this behavior).
|
|
439
|
+
configStore.updateRuntime { sessionStart(it).copy(trackingMode = mode, isMoving = false) }
|
|
394
440
|
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(mode) }
|
|
395
441
|
configureMode(s)
|
|
396
442
|
submit(RecordEvent.TRACKING_START, bestKnownLocation(s), reason = reason)
|
|
@@ -437,7 +483,9 @@ class DefaultTrackingEngine(
|
|
|
437
483
|
} else {
|
|
438
484
|
stopActivityBackend()
|
|
439
485
|
}
|
|
440
|
-
|
|
486
|
+
submitStop(s, reason)
|
|
487
|
+
// What stays queued is uploaded by the upload job if this process dies first.
|
|
488
|
+
guarded("syncer.onTrackingStopped") { syncer.onTrackingStopped() }
|
|
441
489
|
// After the record, so a slow OS call cannot delay it. Without a session (cold process): GMS and HMS keep
|
|
442
490
|
// geofences across a process death, so the previous process may have left the region.
|
|
443
491
|
if (s != null) removeStationaryRegion(s) else removeStationaryRegionFrom(null)
|
|
@@ -445,7 +493,7 @@ class DefaultTrackingEngine(
|
|
|
445
493
|
guarded("heartbeat.stop") { heartbeat.stop() }
|
|
446
494
|
guarded("geofences.onTrackingStopped") { geofences.onTrackingStopped() }
|
|
447
495
|
configStore.updateRuntime { it.copy(enabled = false, isMoving = false) }
|
|
448
|
-
events.emit(TrackingEvent.EnabledChange(false))
|
|
496
|
+
events.emit(TrackingEvent.EnabledChange(false, reason))
|
|
449
497
|
Logger.i(TAG, "tracking stopped: reason=$reason")
|
|
450
498
|
}
|
|
451
499
|
|
|
@@ -458,15 +506,33 @@ class DefaultTrackingEngine(
|
|
|
458
506
|
*/
|
|
459
507
|
private suspend fun restoreFromBackgroundTrigger(trigger: String): Boolean {
|
|
460
508
|
if (forceStopProbe.startedAfterForceStop()) {
|
|
461
|
-
|
|
462
|
-
releaseOrphanedRegistrations()
|
|
509
|
+
handleUserStop("$trigger after a user stop")
|
|
463
510
|
return false
|
|
464
511
|
}
|
|
465
512
|
Logger.i(TAG, "$trigger in a process where tracking is enabled but not running; restoring")
|
|
466
|
-
restoreLocked(REASON_RESTORE)
|
|
513
|
+
restoreLocked(REASON_RESTORE, background = true)
|
|
467
514
|
return session != null
|
|
468
515
|
}
|
|
469
516
|
|
|
517
|
+
/**
|
|
518
|
+
* A background trigger in a process that started after the user stopped the app (force stop, or the Task
|
|
519
|
+
* Manager's Stop button on Android 13+, which leaves the alarms in place). Tracking is not restored: the leftover OS
|
|
520
|
+
* registrations and the heartbeat alarms are released, the service (if the system restarted it) is stopped, and
|
|
521
|
+
* `enabled` stays set, so opening the app (`ready()`) restores tracking. A `tracking_stop` record with reason
|
|
522
|
+
* `user_stopped` and `stopped_at` (the time of the user stop) is written once per user stop (recommendation 6).
|
|
523
|
+
*/
|
|
524
|
+
private suspend fun handleUserStop(trigger: String) {
|
|
525
|
+
guarded("serviceController.stop") { serviceController.stop() }
|
|
526
|
+
if (userStopHandled) return
|
|
527
|
+
userStopHandled = true
|
|
528
|
+
Logger.i(TAG, "$trigger: tracking stays off until the app is opened")
|
|
529
|
+
releaseOrphanedRegistrations()
|
|
530
|
+
val stoppedAt = forceStopProbe.forceStopTime()
|
|
531
|
+
if (stoppedAt != null && runtime.userStopRecordedFor == stoppedAt) return
|
|
532
|
+
submitStop(null, REASON_USER_STOPPED, stoppedAt)
|
|
533
|
+
configStore.updateRuntime { it.copy(userStopRecordedFor = stoppedAt ?: clock.now()) }
|
|
534
|
+
}
|
|
535
|
+
|
|
470
536
|
private suspend fun releaseOrphanedRegistrations() {
|
|
471
537
|
stopActivityBackend()
|
|
472
538
|
removeStationaryRegionFrom(null)
|
|
@@ -487,7 +553,9 @@ class DefaultTrackingEngine(
|
|
|
487
553
|
if (old == new) return
|
|
488
554
|
val s = session
|
|
489
555
|
val providerChanged = old.locationProvider != new.locationProvider
|
|
490
|
-
if (providerChanged)
|
|
556
|
+
if (providerChanged) {
|
|
557
|
+
switchProvider(s, "locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}")
|
|
558
|
+
}
|
|
491
559
|
if (old.notification.resume.enabled && !new.notification.resume.enabled) {
|
|
492
560
|
guarded("serviceController.cancelResumeNotification") { serviceController.cancelResumeNotification() }
|
|
493
561
|
}
|
|
@@ -505,6 +573,31 @@ class DefaultTrackingEngine(
|
|
|
505
573
|
}
|
|
506
574
|
}
|
|
507
575
|
|
|
576
|
+
/**
|
|
577
|
+
* `locationProvider: 'auto'` (recommendation 15): when a better backend than the current one is available now (GMS
|
|
578
|
+
* over HMS over the platform; for example Play services finished updating), switch to it. Checked when tracking
|
|
579
|
+
* starts, on provider changes and on heartbeats.
|
|
580
|
+
*/
|
|
581
|
+
private suspend fun upgradeBackendIfBetter(s: Session?) {
|
|
582
|
+
if (config.locationProvider != LocationProviderSetting.AUTO) return
|
|
583
|
+
val current = providers.kind
|
|
584
|
+
if (current == ProviderKind.GMS) return
|
|
585
|
+
val best = try {
|
|
586
|
+
providers.bestAvailable()
|
|
587
|
+
} catch (e: Exception) {
|
|
588
|
+
Logger.w(TAG, "cannot evaluate the location backends", e)
|
|
589
|
+
return
|
|
590
|
+
}
|
|
591
|
+
if (rank(best) <= rank(current)) return
|
|
592
|
+
switchProvider(s, "a better location backend is available (${current.wire} -> ${best.wire})")
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fun rank(kind: ProviderKind): Int = when (kind) {
|
|
596
|
+
ProviderKind.GMS -> 2
|
|
597
|
+
ProviderKind.HMS -> 1
|
|
598
|
+
ProviderKind.ANDROID -> 0
|
|
599
|
+
}
|
|
600
|
+
|
|
508
601
|
private fun activitySettingsChanged(old: Config, new: Config): Boolean =
|
|
509
602
|
old.activity.disableMotionActivityUpdates != new.activity.disableMotionActivityUpdates ||
|
|
510
603
|
old.activity.activityRecognitionInterval != new.activity.activityRecognitionInterval
|
|
@@ -514,7 +607,7 @@ class DefaultTrackingEngine(
|
|
|
514
607
|
* stationary region and geofences are removed from the current backend first and registered again on the
|
|
515
608
|
* selected one.
|
|
516
609
|
*/
|
|
517
|
-
private suspend fun switchProvider(s: Session?,
|
|
610
|
+
private suspend fun switchProvider(s: Session?, why: String) {
|
|
518
611
|
if (s != null) {
|
|
519
612
|
removeLocationUpdates(s)
|
|
520
613
|
stopActivityUpdates(s)
|
|
@@ -527,11 +620,7 @@ class DefaultTrackingEngine(
|
|
|
527
620
|
Logger.e(TAG, "provider reselection failed", e)
|
|
528
621
|
false
|
|
529
622
|
}
|
|
530
|
-
Logger.i(
|
|
531
|
-
TAG,
|
|
532
|
-
"locationProvider ${old.locationProvider.wire} -> ${new.locationProvider.wire}: " +
|
|
533
|
-
"backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}",
|
|
534
|
-
)
|
|
623
|
+
Logger.i(TAG, "$why: backend ${providers.kind.wire}${if (changed) " (changed)" else " (unchanged)"}")
|
|
535
624
|
if (s != null) {
|
|
536
625
|
guarded("geofences.onTrackingStarted") { geofences.onTrackingStarted(s.mode) }
|
|
537
626
|
startActivityUpdates(s)
|
|
@@ -814,8 +903,8 @@ class DefaultTrackingEngine(
|
|
|
814
903
|
timers.cancel(TimerKind.STOP_AFTER_ELAPSED)
|
|
815
904
|
return
|
|
816
905
|
}
|
|
817
|
-
val
|
|
818
|
-
timers.schedule(TimerKind.STOP_AFTER_ELAPSED,
|
|
906
|
+
val deadline = elapsedStopAt() ?: (clock.now() + minutes * MINUTE_MS)
|
|
907
|
+
timers.schedule(TimerKind.STOP_AFTER_ELAPSED, deadline - clock.now()) {
|
|
819
908
|
if (session === s) {
|
|
820
909
|
Logger.i(TAG, "stopAfterElapsedMinutes ($minutes) reached")
|
|
821
910
|
guarded("stop after elapsed") { stopLocked(REASON_STOP_AFTER_ELAPSED) }
|
|
@@ -830,6 +919,7 @@ class DefaultTrackingEngine(
|
|
|
830
919
|
val batch = locations.toList()
|
|
831
920
|
scope.launch {
|
|
832
921
|
mutex.withLock {
|
|
922
|
+
if (session === s) s.registrationFailures = 0
|
|
833
923
|
for (raw in batch) {
|
|
834
924
|
if (session !== s) return@withLock
|
|
835
925
|
guarded("location fix") { handleFix(s, raw) }
|
|
@@ -840,6 +930,33 @@ class DefaultTrackingEngine(
|
|
|
840
930
|
}
|
|
841
931
|
}
|
|
842
932
|
|
|
933
|
+
private fun dispatchRegistrationError(s: Session, error: TrackingException) {
|
|
934
|
+
scope.launch { mutex.withLock { guarded("location request error") { onRegistrationFailed(s, error) } } }
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The location request of [s] failed (recommendation 3): the backend no longer has it. Forget it, so the next
|
|
939
|
+
* [applyLocationRequest] registers again, emit a `locationerror` event, and request it again after a growing wait
|
|
940
|
+
* (10 s, doubling up to 5 minutes; reset by the next delivered fix).
|
|
941
|
+
*/
|
|
942
|
+
private fun onRegistrationFailed(s: Session, error: TrackingException) {
|
|
943
|
+
if (session !== s) return
|
|
944
|
+
val kind = s.locationBackend?.kind ?: providers.kind
|
|
945
|
+
s.locationBackend = null
|
|
946
|
+
s.appliedSpec = null
|
|
947
|
+
s.registrationFailures++
|
|
948
|
+
val doublings = (s.registrationFailures - 1).coerceIn(0, 10)
|
|
949
|
+
val wait = minOf(REQUEST_RETRY_FIRST_MS shl doublings, REQUEST_RETRY_MAX_MS)
|
|
950
|
+
Logger.e(TAG, "location request on ${kind.wire} failed (${error.code}: ${error.message}); next try in ${wait / 1000} s")
|
|
951
|
+
events.emit(TrackingEvent.LocationError(error.code, error.message ?: error.code.name, kind, wait))
|
|
952
|
+
timers.schedule(TimerKind.REQUEST_RETRY, wait) {
|
|
953
|
+
if (session === s) {
|
|
954
|
+
Logger.i(TAG, "requesting location updates again after a failure")
|
|
955
|
+
applyLocationRequest(s)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
843
960
|
/**
|
|
844
961
|
* `delay()` does not advance while the CPU sleeps, so overdue timers are also checked on every fix, activity
|
|
845
962
|
* sample and heartbeat (whose alarm wakes the device every few minutes).
|
|
@@ -850,8 +967,18 @@ class DefaultTrackingEngine(
|
|
|
850
967
|
|
|
851
968
|
private fun onEvent(s: Session, event: TrackingEvent) {
|
|
852
969
|
when (event) {
|
|
853
|
-
is TrackingEvent.Heartbeat -> scope.launch {
|
|
854
|
-
|
|
970
|
+
is TrackingEvent.Heartbeat -> scope.launch {
|
|
971
|
+
mutex.withLock {
|
|
972
|
+
fireDueTimers(s)
|
|
973
|
+
if (session === s) guarded("backend check") { upgradeBackendIfBetter(s) }
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
is TrackingEvent.ProviderChange -> scope.launch {
|
|
977
|
+
mutex.withLock {
|
|
978
|
+
onProviderChange(s, event.state)
|
|
979
|
+
if (session === s) guarded("backend check") { upgradeBackendIfBetter(s) }
|
|
980
|
+
}
|
|
981
|
+
}
|
|
855
982
|
// The user geofences may now need the OS slot of the stationary region, or leave it free again.
|
|
856
983
|
is TrackingEvent.GeofencesChange -> scope.launch {
|
|
857
984
|
mutex.withLock { if (session === s) applyMotionRequests(s, checkLimit = true) }
|
|
@@ -1169,14 +1296,32 @@ class DefaultTrackingEngine(
|
|
|
1169
1296
|
return listOfNotNull(s?.lastFix, runtime.lastLocation, vetted).maxByOrNull { it.time } ?: fromBackend
|
|
1170
1297
|
}
|
|
1171
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* The `tracking_stop` record with [reason] (and [stoppedAt] when the stop happened earlier), and the stop in
|
|
1301
|
+
* `runtime.lastStopReason` / `lastStopAt` (recommendation 9).
|
|
1302
|
+
*/
|
|
1303
|
+
private suspend fun submitStop(s: Session?, reason: String, stoppedAt: Long? = null) {
|
|
1304
|
+
submit(RecordEvent.TRACKING_STOP, bestKnownLocation(s), reason = reason, stoppedAt = stoppedAt)
|
|
1305
|
+
configStore.updateRuntime { it.copy(lastStopReason = reason, lastStopAt = stoppedAt ?: clock.now()) }
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** [runtime] with a new session start: wall time, elapsed realtime and boot count (`stopAfterElapsedMinutes`). */
|
|
1309
|
+
private fun sessionStart(runtime: RuntimeState): RuntimeState = runtime.copy(
|
|
1310
|
+
trackingStartedAt = clock.now(),
|
|
1311
|
+
trackingStartedElapsed = clock.elapsedRealtime(),
|
|
1312
|
+
trackingStartedBootCount = clock.bootCount(),
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1172
1315
|
private suspend fun submit(
|
|
1173
1316
|
event: RecordEvent,
|
|
1174
1317
|
location: TrackedLocation?,
|
|
1175
1318
|
reason: String? = null,
|
|
1176
1319
|
isMoving: Boolean? = null,
|
|
1320
|
+
stoppedAt: Long? = null,
|
|
1177
1321
|
): Record? = try {
|
|
1178
1322
|
var record = recordFactory.create(event, location, reason = reason)
|
|
1179
1323
|
if (isMoving != null && record.isMoving != isMoving) record = record.copy(isMoving = isMoving)
|
|
1324
|
+
if (stoppedAt != null) record = record.copy(stoppedAt = stoppedAt)
|
|
1180
1325
|
recordSink.submit(record)
|
|
1181
1326
|
} catch (e: CancellationException) {
|
|
1182
1327
|
throw e
|
|
@@ -1202,7 +1347,14 @@ class DefaultTrackingEngine(
|
|
|
1202
1347
|
/** State of one tracking session in this process. Guarded by [mutex]. */
|
|
1203
1348
|
private inner class Session(val id: Long, var mode: TrackingMode, settings: MotionSettings) {
|
|
1204
1349
|
val motion = MotionStateMachine(settings)
|
|
1205
|
-
val listener = LocationListener {
|
|
1350
|
+
val listener = object : LocationListener {
|
|
1351
|
+
override fun onLocations(locations: List<TrackedLocation>) = dispatchLocations(this@Session, locations)
|
|
1352
|
+
|
|
1353
|
+
override fun onError(error: TrackingException) = dispatchRegistrationError(this@Session, error)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Consecutive failed location requests (see [onRegistrationFailed]). */
|
|
1357
|
+
var registrationFailures = 0
|
|
1206
1358
|
var locationBackend: LocationBackend? = null
|
|
1207
1359
|
var appliedSpec: LocationRequestSpec? = null
|
|
1208
1360
|
var activityBackend: ActivityBackend? = null
|
|
@@ -1254,6 +1406,11 @@ class DefaultTrackingEngine(
|
|
|
1254
1406
|
const val REASON_PERMISSION_DENIED = "permission_denied"
|
|
1255
1407
|
const val REASON_SERVICE_START_FAILED = "service_start_failed"
|
|
1256
1408
|
const val REASON_RESUME_NOTIFICATION = "resume_notification"
|
|
1409
|
+
const val REASON_USER_STOPPED = "user_stopped"
|
|
1410
|
+
|
|
1411
|
+
/** Waits before requesting location updates again after a failure: 10 s, doubling up to 5 minutes. */
|
|
1412
|
+
const val REQUEST_RETRY_FIRST_MS = 10_000L
|
|
1413
|
+
const val REQUEST_RETRY_MAX_MS = 5 * 60_000L
|
|
1257
1414
|
|
|
1258
1415
|
/** `device.checkProviderState` reason after the backend changed. */
|
|
1259
1416
|
const val PROVIDER_CHECK_REASON = "reselect"
|
|
@@ -52,6 +52,12 @@ interface TrackingEngine : ActivitySink, StationaryRegionSink {
|
|
|
52
52
|
*/
|
|
53
53
|
suspend fun resumeFromNotification() = Unit
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* True when this process started after the user stopped the app (Task Manager Stop or force stop, see
|
|
57
|
+
* `ForceStopProbe`) while tracking was enabled: a background restore must not run (recommendation 6).
|
|
58
|
+
*/
|
|
59
|
+
fun startedAfterUserStop(): Boolean = false
|
|
60
|
+
|
|
55
61
|
/**
|
|
56
62
|
* A transition of the engine's stationary region (`Constants.STATIONARY_REGION_ID`), routed by the
|
|
57
63
|
* GeofenceManager. Round 2, unit 2 implements it (EXIT = the device left the stationary anchor). Default: ignored.
|