react-native-nitro-geolocation 1.3.2 → 1.3.3
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/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/AndroidBackgroundHttpSync.kt +51 -8
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/BackgroundDecisions.kt +52 -0
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBackgroundEventHub.kt +9 -3
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBackgroundLocationController.kt +179 -52
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBackgroundLocationService.kt +26 -4
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBackgroundStore.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBootReceiver.kt +20 -9
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroGeoLog.kt +33 -0
- package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroLocationUpdateReceiver.kt +11 -1
- package/android/src/test/java/com/margelo/nitro/nitrogeolocation/background/BackgroundDecisionsTest.kt +67 -0
- package/ios/NitroBackgroundLocation.swift +22 -10
- package/package.json +1 -1
- package/src/background/types.ts +8 -0
|
@@ -6,6 +6,7 @@ import com.margelo.nitro.nitrogeolocation.StoredBackgroundLocation
|
|
|
6
6
|
import java.io.OutputStreamWriter
|
|
7
7
|
import java.net.HttpURLConnection
|
|
8
8
|
import java.net.URL
|
|
9
|
+
import kotlin.random.Random
|
|
9
10
|
|
|
10
11
|
internal class AndroidBackgroundHttpSync {
|
|
11
12
|
fun uploadLocationsWithRetry(
|
|
@@ -43,7 +44,7 @@ internal class AndroidBackgroundHttpSync {
|
|
|
43
44
|
lastError = error.message ?: "HTTP sync failed"
|
|
44
45
|
}
|
|
45
46
|
if (attempt < maxAttempts - 1) {
|
|
46
|
-
Thread.sleep(
|
|
47
|
+
Thread.sleep(backoffDelayMs(attempt))
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -61,10 +62,16 @@ internal class AndroidBackgroundHttpSync {
|
|
|
61
62
|
locations: Array<StoredBackgroundLocation>
|
|
62
63
|
): Int {
|
|
63
64
|
val connection = createConnection(sync)
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
return try {
|
|
66
|
+
OutputStreamWriter(connection.outputStream).use { writer ->
|
|
67
|
+
writer.write(sync.batchBody(locations).toString())
|
|
68
|
+
}
|
|
69
|
+
val code = connection.responseCode
|
|
70
|
+
drainResponse(connection, code)
|
|
71
|
+
code
|
|
72
|
+
} finally {
|
|
73
|
+
connection.disconnect()
|
|
66
74
|
}
|
|
67
|
-
return connection.responseCode
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
private fun uploadSingleLocationsWithRetry(
|
|
@@ -93,7 +100,7 @@ internal class AndroidBackgroundHttpSync {
|
|
|
93
100
|
lastError = error.message ?: "HTTP sync failed"
|
|
94
101
|
}
|
|
95
102
|
if (!didSync && attempt < maxAttempts - 1) {
|
|
96
|
-
Thread.sleep(
|
|
103
|
+
Thread.sleep(backoffDelayMs(attempt))
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
if (!didSync) {
|
|
@@ -115,10 +122,16 @@ internal class AndroidBackgroundHttpSync {
|
|
|
115
122
|
location: StoredBackgroundLocation
|
|
116
123
|
): Int {
|
|
117
124
|
val connection = createConnection(sync)
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
return try {
|
|
126
|
+
OutputStreamWriter(connection.outputStream).use { writer ->
|
|
127
|
+
writer.write(sync.singleBody(location).toString())
|
|
128
|
+
}
|
|
129
|
+
val code = connection.responseCode
|
|
130
|
+
drainResponse(connection, code)
|
|
131
|
+
code
|
|
132
|
+
} finally {
|
|
133
|
+
connection.disconnect()
|
|
120
134
|
}
|
|
121
|
-
return connection.responseCode
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
private fun createConnection(sync: BackgroundHttpSyncOptions): HttpURLConnection {
|
|
@@ -131,4 +144,34 @@ internal class AndroidBackgroundHttpSync {
|
|
|
131
144
|
sync.headers?.forEach { (key, value) -> setRequestProperty(key, value) }
|
|
132
145
|
}
|
|
133
146
|
}
|
|
147
|
+
|
|
148
|
+
// Drain (up to a cap) then disconnect so the socket is returned to the pool; an undrained
|
|
149
|
+
// HttpURLConnection blocks connection reuse and leaks sockets over a long trip.
|
|
150
|
+
private fun drainResponse(connection: HttpURLConnection, code: Int) {
|
|
151
|
+
runCatching {
|
|
152
|
+
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
|
153
|
+
stream?.use { input ->
|
|
154
|
+
val buffer = ByteArray(4_096)
|
|
155
|
+
var total = 0
|
|
156
|
+
while (total < MAX_DRAIN_BYTES) {
|
|
157
|
+
val read = input.read(buffer)
|
|
158
|
+
if (read < 0) break
|
|
159
|
+
total += read
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Exponential backoff with full jitter so many devices retrying a failed sync don't hammer the
|
|
166
|
+
// server in lockstep (thundering herd).
|
|
167
|
+
private fun backoffDelayMs(attempt: Int): Long {
|
|
168
|
+
return backoffBaseDelayMs(attempt, BASE_BACKOFF_MS, MAX_BACKOFF_MS) +
|
|
169
|
+
Random.nextLong(BASE_BACKOFF_MS)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private companion object {
|
|
173
|
+
const val BASE_BACKOFF_MS = 1_000L
|
|
174
|
+
const val MAX_BACKOFF_MS = 30_000L
|
|
175
|
+
const val MAX_DRAIN_BYTES = 64 * 1024
|
|
176
|
+
}
|
|
134
177
|
}
|
package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/BackgroundDecisions.kt
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package com.margelo.nitro.nitrogeolocation.background
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.os.Build
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure decision helpers for the background pipeline, extracted so they can be unit-tested with
|
|
8
|
+
* plain JUnit (no Android framework instances) — see BackgroundDecisionsTest. They take their
|
|
9
|
+
* inputs explicitly instead of reading global state. The Android constants used here
|
|
10
|
+
* (PendingIntent.FLAG_*, Build.VERSION_CODES.*) are compile-time `static final int` values, so the
|
|
11
|
+
* logic is fully evaluable on a plain JVM.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Flags for the broadcast PendingIntents the OS fills in at delivery time (location / geofence /
|
|
16
|
+
* activity). The result MUST be mutable on API 31+ (S), otherwise FusedLocationProviderClient
|
|
17
|
+
* rejects registration with "PendingIntent must be mutable" and zero updates are ever delivered.
|
|
18
|
+
* Pre-S PendingIntents are mutable by default, so no immutability flag is set there.
|
|
19
|
+
*/
|
|
20
|
+
internal fun mutablePendingIntentFlags(sdkInt: Int): Int {
|
|
21
|
+
return if (sdkInt >= Build.VERSION_CODES.S) {
|
|
22
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
|
23
|
+
} else {
|
|
24
|
+
PendingIntent.FLAG_UPDATE_CURRENT
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Capped exponential backoff base (without jitter): baseMs * 2^attempt, clamped to [baseMs, maxMs].
|
|
30
|
+
* Callers add jitter on top to avoid synchronized retries across devices.
|
|
31
|
+
*/
|
|
32
|
+
internal fun backoffBaseDelayMs(attempt: Int, baseMs: Long, maxMs: Long): Long {
|
|
33
|
+
if (attempt <= 0) return baseMs
|
|
34
|
+
val grown = if (attempt >= 31) maxMs else baseMs shl attempt
|
|
35
|
+
return grown.coerceIn(baseMs, maxMs)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the effective store cap (rows), preserving the library's original opt-out for unbounded
|
|
40
|
+
* storage while adding a safe default when the option is unset:
|
|
41
|
+
* - unset (null) → [default] (a safety cap so the store can't grow without bound by accident)
|
|
42
|
+
* - explicit <= 0 → 0, meaning UNBOUNDED — pruneRows treats <= 0 as no-prune, the same opt-out the
|
|
43
|
+
* original code gave for any non-positive value
|
|
44
|
+
* - explicit > 0 → that cap
|
|
45
|
+
*/
|
|
46
|
+
internal fun resolveMaxStored(configured: Int?, default: Int): Int {
|
|
47
|
+
return when {
|
|
48
|
+
configured == null -> default
|
|
49
|
+
configured <= 0 -> 0
|
|
50
|
+
else -> configured
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -46,20 +46,26 @@ class NitroBackgroundEventHub {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
fun emit(event: BackgroundEventEnvelope) {
|
|
49
|
-
eventListeners.values.forEach { listener -> listener(event) }
|
|
49
|
+
eventListeners.values.forEach { listener -> dispatch { listener(event) } }
|
|
50
50
|
|
|
51
51
|
when (event.type) {
|
|
52
52
|
BackgroundEventType.LOCATION -> {
|
|
53
53
|
event.location?.let { location ->
|
|
54
|
-
locationListeners.values.forEach { listener -> listener(location) }
|
|
54
|
+
locationListeners.values.forEach { listener -> dispatch { listener(location) } }
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
BackgroundEventType.ERROR -> {
|
|
58
58
|
event.error?.let { error ->
|
|
59
|
-
errorListeners.values.forEach { listener -> listener(error) }
|
|
59
|
+
errorListeners.values.forEach { listener -> dispatch { listener(error) } }
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
else -> Unit
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
// Listeners run inline on the caller's thread (often the broadcast receiver thread). Isolate
|
|
67
|
+
// each one so a single throwing listener cannot abort delivery to the remaining listeners.
|
|
68
|
+
private inline fun dispatch(block: () -> Unit) {
|
|
69
|
+
runCatching(block).onFailure { NitroGeoLog.w("background event listener threw", it) }
|
|
70
|
+
}
|
|
65
71
|
}
|
|
@@ -34,6 +34,14 @@ private const val ACTION_GEOFENCE_UPDATE =
|
|
|
34
34
|
private const val ACTION_ACTIVITY_UPDATE =
|
|
35
35
|
"com.margelo.nitro.nitrogeolocation.background.ACTIVITY_UPDATE"
|
|
36
36
|
|
|
37
|
+
// LocationError codes mirror the W3C GeolocationPositionError contract.
|
|
38
|
+
private const val ERROR_CODE_PERMISSION_DENIED = 1
|
|
39
|
+
private const val ERROR_CODE_POSITION_UNAVAILABLE = 2
|
|
40
|
+
|
|
41
|
+
// Default store caps so an unconfigured store cannot grow without bound over a long trip.
|
|
42
|
+
private const val DEFAULT_MAX_STORED_LOCATIONS = 10_000
|
|
43
|
+
private const val DEFAULT_MAX_STORED_EVENTS = 10_000
|
|
44
|
+
|
|
37
45
|
class NitroBackgroundLocationController private constructor(
|
|
38
46
|
private val context: Context
|
|
39
47
|
) {
|
|
@@ -59,12 +67,23 @@ class NitroBackgroundLocationController private constructor(
|
|
|
59
67
|
private val httpSync = AndroidBackgroundHttpSync()
|
|
60
68
|
private val taskExecutor = Executors.newSingleThreadExecutor()
|
|
61
69
|
|
|
70
|
+
// Serializes HTTP-sync uploads: a burst of locations queues onto one worker instead of
|
|
71
|
+
// spawning an unbounded number of raw threads.
|
|
72
|
+
private val syncExecutor = Executors.newSingleThreadExecutor()
|
|
73
|
+
|
|
62
74
|
@Volatile
|
|
63
75
|
private var config: BackgroundLocationOptions? = null
|
|
64
76
|
|
|
65
77
|
@Volatile
|
|
66
78
|
private var state = BackgroundLocationState.IDLE
|
|
67
79
|
|
|
80
|
+
@Volatile
|
|
81
|
+
private var lastError: LocationError? = null
|
|
82
|
+
|
|
83
|
+
// Serializes lifecycle transitions (configure/start/stop/reset), which are invoked from
|
|
84
|
+
// Nitro Promise.async worker threads and must not interleave on the shared singleton.
|
|
85
|
+
private val lifecycleLock = Any()
|
|
86
|
+
|
|
68
87
|
fun checkBackgroundPermission(): BackgroundPermissionResult {
|
|
69
88
|
return permissions.checkBackgroundPermission()
|
|
70
89
|
}
|
|
@@ -78,9 +97,11 @@ class NitroBackgroundLocationController private constructor(
|
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
fun configure(options: BackgroundLocationOptions) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
synchronized(lifecycleLock) {
|
|
101
|
+
validate(options)
|
|
102
|
+
config = options
|
|
103
|
+
persistConfig(options)
|
|
104
|
+
}
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
fun getConfigOrNull(): BackgroundLocationOptions? {
|
|
@@ -94,41 +115,53 @@ class NitroBackgroundLocationController private constructor(
|
|
|
94
115
|
}
|
|
95
116
|
|
|
96
117
|
fun start(options: BackgroundLocationOptions?) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
permissions.
|
|
105
|
-
|
|
118
|
+
synchronized(lifecycleLock) {
|
|
119
|
+
options?.let(::configure)
|
|
120
|
+
val current = requireConfig()
|
|
121
|
+
validate(current)
|
|
122
|
+
NitroGeoLog.d(
|
|
123
|
+
"start(): provider=${current.android?.locationProvider} interval=${current.interval} state=$state"
|
|
124
|
+
)
|
|
125
|
+
if (permissions.foregroundPermission() != PermissionStatus.GRANTED) {
|
|
126
|
+
throw SecurityException("Foreground location permission is required")
|
|
127
|
+
}
|
|
128
|
+
if (current.android?.foregroundService == null &&
|
|
129
|
+
permissions.backgroundPermission() != BackgroundPermissionStatus.GRANTED) {
|
|
130
|
+
throw SecurityException("Background location permission is required")
|
|
131
|
+
}
|
|
132
|
+
state = BackgroundLocationState.STARTING
|
|
133
|
+
prefs.edit().putBoolean("running", true).apply()
|
|
134
|
+
ContextCompat.startForegroundService(
|
|
135
|
+
appContext,
|
|
136
|
+
Intent(appContext, NitroBackgroundLocationService::class.java)
|
|
137
|
+
)
|
|
138
|
+
// State stays STARTING until the service actually registers updates and the provider
|
|
139
|
+
// confirms (see startNativeLocationUpdates) — only then do we report RUNNING.
|
|
140
|
+
NitroGeoLog.d("start(): foreground service requested, state=STARTING")
|
|
106
141
|
}
|
|
107
|
-
state = BackgroundLocationState.STARTING
|
|
108
|
-
prefs.edit().putBoolean("running", true).apply()
|
|
109
|
-
ContextCompat.startForegroundService(
|
|
110
|
-
appContext,
|
|
111
|
-
Intent(appContext, NitroBackgroundLocationService::class.java)
|
|
112
|
-
)
|
|
113
|
-
state = BackgroundLocationState.RUNNING
|
|
114
142
|
}
|
|
115
143
|
|
|
116
144
|
fun stop() {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
synchronized(lifecycleLock) {
|
|
146
|
+
NitroGeoLog.d("stop(): tearing down location updates")
|
|
147
|
+
state = BackgroundLocationState.STOPPING
|
|
148
|
+
stopNativeLocationUpdates()
|
|
149
|
+
stopActivityRecognition()
|
|
150
|
+
appContext.stopService(Intent(appContext, NitroBackgroundLocationService::class.java))
|
|
151
|
+
prefs.edit().putBoolean("running", false).apply()
|
|
152
|
+
state = BackgroundLocationState.STOPPED
|
|
153
|
+
}
|
|
123
154
|
}
|
|
124
155
|
|
|
125
156
|
fun reset() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
synchronized(lifecycleLock) {
|
|
158
|
+
stop()
|
|
159
|
+
removeGeofences(null)
|
|
160
|
+
config = null
|
|
161
|
+
prefs.edit().clear().apply()
|
|
162
|
+
store.clearEvents(null)
|
|
163
|
+
store.clearLocations(null)
|
|
164
|
+
}
|
|
132
165
|
}
|
|
133
166
|
|
|
134
167
|
fun getStatus(): BackgroundLocationStatus {
|
|
@@ -156,17 +189,65 @@ class NitroBackgroundLocationController private constructor(
|
|
|
156
189
|
permissions.notificationPermission()
|
|
157
190
|
),
|
|
158
191
|
null,
|
|
159
|
-
|
|
192
|
+
currentLastError()
|
|
160
193
|
)
|
|
161
194
|
}
|
|
162
195
|
|
|
196
|
+
internal fun recordError(code: Int, message: String, throwable: Throwable? = null) {
|
|
197
|
+
val error = LocationError(code.toDouble(), message)
|
|
198
|
+
lastError = error
|
|
199
|
+
prefs.edit()
|
|
200
|
+
.putInt("lastErrorCode", code)
|
|
201
|
+
.putString("lastErrorMessage", message)
|
|
202
|
+
.putLong("lastErrorAt", System.currentTimeMillis())
|
|
203
|
+
.apply()
|
|
204
|
+
NitroGeoLog.e("background location error [$code]: $message", throwable)
|
|
205
|
+
runCatching {
|
|
206
|
+
dispatchEvent(
|
|
207
|
+
BackgroundEventEnvelope(
|
|
208
|
+
null,
|
|
209
|
+
null,
|
|
210
|
+
null,
|
|
211
|
+
null,
|
|
212
|
+
null,
|
|
213
|
+
error,
|
|
214
|
+
UUID.randomUUID().toString(),
|
|
215
|
+
BackgroundEventType.ERROR,
|
|
216
|
+
System.currentTimeMillis().toDouble(),
|
|
217
|
+
false
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
internal fun recordError(message: String, throwable: Throwable) =
|
|
224
|
+
recordError(ERROR_CODE_POSITION_UNAVAILABLE, message, throwable)
|
|
225
|
+
|
|
226
|
+
private fun clearError() {
|
|
227
|
+
lastError = null
|
|
228
|
+
prefs.edit()
|
|
229
|
+
.remove("lastErrorCode")
|
|
230
|
+
.remove("lastErrorMessage")
|
|
231
|
+
.remove("lastErrorAt")
|
|
232
|
+
.apply()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private fun currentLastError(): LocationError? {
|
|
236
|
+
lastError?.let { return it }
|
|
237
|
+
val message = prefs.getString("lastErrorMessage", null) ?: return null
|
|
238
|
+
return LocationError(prefs.getInt("lastErrorCode", 0).toDouble(), message)
|
|
239
|
+
.also { lastError = it }
|
|
240
|
+
}
|
|
241
|
+
|
|
163
242
|
@SuppressLint("MissingPermission")
|
|
164
243
|
fun startNativeLocationUpdates() {
|
|
165
244
|
val current = requireConfig()
|
|
166
245
|
if (current.android?.locationProvider == AndroidBackgroundProvider.ANDROID_PLATFORM) {
|
|
246
|
+
NitroGeoLog.d("startNativeLocationUpdates(): ANDROID_PLATFORM LocationManager path")
|
|
167
247
|
startPlatformLocationUpdates(current)
|
|
168
248
|
return
|
|
169
249
|
}
|
|
250
|
+
NitroGeoLog.d("startNativeLocationUpdates(): FUSED provider, registering broadcast PendingIntent")
|
|
170
251
|
val request = LocationRequest.Builder(
|
|
171
252
|
resolvePriority(current),
|
|
172
253
|
current.interval?.toLong() ?: 10_000L
|
|
@@ -177,7 +258,27 @@ class NitroBackgroundLocationController private constructor(
|
|
|
177
258
|
.setMaxUpdateDelayMillis(current.maxUpdateDelay?.toLong() ?: 0L)
|
|
178
259
|
.build()
|
|
179
260
|
|
|
180
|
-
|
|
261
|
+
try {
|
|
262
|
+
fusedLocationClient.requestLocationUpdates(request, locationPendingIntent())
|
|
263
|
+
.addOnSuccessListener {
|
|
264
|
+
NitroGeoLog.d("startNativeLocationUpdates(): fused registration accepted")
|
|
265
|
+
state = BackgroundLocationState.RUNNING
|
|
266
|
+
clearError()
|
|
267
|
+
}
|
|
268
|
+
.addOnFailureListener { error ->
|
|
269
|
+
recordError(
|
|
270
|
+
ERROR_CODE_POSITION_UNAVAILABLE,
|
|
271
|
+
"Failed to register fused location updates: ${error.message}",
|
|
272
|
+
error
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
} catch (error: SecurityException) {
|
|
276
|
+
recordError(
|
|
277
|
+
ERROR_CODE_PERMISSION_DENIED,
|
|
278
|
+
"Missing location permission for fused updates: ${error.message}",
|
|
279
|
+
error
|
|
280
|
+
)
|
|
281
|
+
}
|
|
181
282
|
}
|
|
182
283
|
|
|
183
284
|
fun stopNativeLocationUpdates() {
|
|
@@ -186,6 +287,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
186
287
|
}
|
|
187
288
|
|
|
188
289
|
fun handleNativeLocation(location: Location, source: BackgroundLocationSource) {
|
|
290
|
+
NitroGeoLog.d("handleNativeLocation(): src=$source lat=${location.latitude} lng=${location.longitude}")
|
|
189
291
|
val id = UUID.randomUUID().toString()
|
|
190
292
|
val backgroundLocation = BackgroundLocation(
|
|
191
293
|
id,
|
|
@@ -391,7 +493,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
391
493
|
if (interval > 0 && now - lastSyncAt < interval) return
|
|
392
494
|
prefs.edit().putLong("lastSyncAt", now).apply()
|
|
393
495
|
|
|
394
|
-
|
|
496
|
+
syncExecutor.execute {
|
|
395
497
|
val result = runCatching { syncStoredLocations() }.getOrElse { error ->
|
|
396
498
|
BackgroundHttpSyncResult(
|
|
397
499
|
false,
|
|
@@ -415,7 +517,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
415
517
|
)
|
|
416
518
|
persistEventIfNeeded(event)
|
|
417
519
|
dispatchEvent(event)
|
|
418
|
-
}
|
|
520
|
+
}
|
|
419
521
|
}
|
|
420
522
|
|
|
421
523
|
private fun shouldPersist(): Boolean {
|
|
@@ -428,12 +530,18 @@ class NitroBackgroundLocationController private constructor(
|
|
|
428
530
|
store.pruneEvents(currentMaxStoredEvents())
|
|
429
531
|
}
|
|
430
532
|
|
|
431
|
-
private fun currentMaxStoredLocations(): Int
|
|
432
|
-
return
|
|
533
|
+
private fun currentMaxStoredLocations(): Int {
|
|
534
|
+
return resolveMaxStored(
|
|
535
|
+
getConfigOrNull()?.maxStoredLocations?.toInt(),
|
|
536
|
+
DEFAULT_MAX_STORED_LOCATIONS
|
|
537
|
+
)
|
|
433
538
|
}
|
|
434
539
|
|
|
435
|
-
private fun currentMaxStoredEvents(): Int
|
|
436
|
-
return
|
|
540
|
+
private fun currentMaxStoredEvents(): Int {
|
|
541
|
+
return resolveMaxStored(
|
|
542
|
+
getConfigOrNull()?.maxStoredEvents?.toInt(),
|
|
543
|
+
DEFAULT_MAX_STORED_EVENTS
|
|
544
|
+
)
|
|
437
545
|
}
|
|
438
546
|
|
|
439
547
|
@SuppressLint("MissingPermission")
|
|
@@ -443,13 +551,26 @@ class NitroBackgroundLocationController private constructor(
|
|
|
443
551
|
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
|
444
552
|
.filter { provider -> runCatching { platformLocationManager.isProviderEnabled(provider) }.getOrDefault(false) }
|
|
445
553
|
.ifEmpty { listOf(LocationManager.GPS_PROVIDER) }
|
|
554
|
+
var registered = false
|
|
446
555
|
providers.forEach { provider ->
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
556
|
+
try {
|
|
557
|
+
platformLocationManager.requestLocationUpdates(
|
|
558
|
+
provider,
|
|
559
|
+
interval,
|
|
560
|
+
distance,
|
|
561
|
+
locationPendingIntent()
|
|
562
|
+
)
|
|
563
|
+
registered = true
|
|
564
|
+
} catch (error: SecurityException) {
|
|
565
|
+
recordError(
|
|
566
|
+
ERROR_CODE_PERMISSION_DENIED,
|
|
567
|
+
"Missing location permission for $provider updates: ${error.message}",
|
|
568
|
+
error
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (registered) {
|
|
573
|
+
state = BackgroundLocationState.RUNNING
|
|
453
574
|
}
|
|
454
575
|
}
|
|
455
576
|
|
|
@@ -484,10 +605,16 @@ class NitroBackgroundLocationController private constructor(
|
|
|
484
605
|
|
|
485
606
|
private fun dispatchEvent(event: BackgroundEventEnvelope) {
|
|
486
607
|
eventHub.emit(event)
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
608
|
+
// The in-process eventHub already delivered to any live JS listeners; the headless task is
|
|
609
|
+
// the fallback for when JS is dead. Guard its start: startService from the background can be
|
|
610
|
+
// rejected (ForegroundServiceStartNotAllowed / IllegalState), which must not crash the
|
|
611
|
+
// broadcast receiver thread that drives delivery.
|
|
612
|
+
runCatching {
|
|
613
|
+
val intent = Intent(appContext, NitroBackgroundHeadlessTaskService::class.java)
|
|
614
|
+
.putExtra("event", event.toJson().toString())
|
|
615
|
+
appContext.startService(intent)
|
|
616
|
+
HeadlessJsTaskService.acquireWakeLockNow(appContext)
|
|
617
|
+
}.onFailure { NitroGeoLog.w("dispatchEvent: headless task dispatch failed", it) }
|
|
491
618
|
}
|
|
492
619
|
|
|
493
620
|
private fun waitForTask(task: Task<Void>) {
|
|
@@ -661,7 +788,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
661
788
|
appContext,
|
|
662
789
|
1001,
|
|
663
790
|
intent,
|
|
664
|
-
|
|
791
|
+
mutablePendingIntentFlags(Build.VERSION.SDK_INT)
|
|
665
792
|
)
|
|
666
793
|
}
|
|
667
794
|
|
|
@@ -672,7 +799,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
672
799
|
appContext,
|
|
673
800
|
1002,
|
|
674
801
|
intent,
|
|
675
|
-
|
|
802
|
+
mutablePendingIntentFlags(Build.VERSION.SDK_INT)
|
|
676
803
|
)
|
|
677
804
|
}
|
|
678
805
|
|
|
@@ -683,7 +810,7 @@ class NitroBackgroundLocationController private constructor(
|
|
|
683
810
|
appContext,
|
|
684
811
|
1003,
|
|
685
812
|
intent,
|
|
686
|
-
|
|
813
|
+
mutablePendingIntentFlags(Build.VERSION.SDK_INT)
|
|
687
814
|
)
|
|
688
815
|
}
|
|
689
816
|
|
|
@@ -2,7 +2,9 @@ package com.margelo.nitro.nitrogeolocation.background
|
|
|
2
2
|
|
|
3
3
|
import android.app.Service
|
|
4
4
|
import android.content.Intent
|
|
5
|
+
import android.content.pm.ServiceInfo
|
|
5
6
|
import android.os.IBinder
|
|
7
|
+
import androidx.core.app.ServiceCompat
|
|
6
8
|
|
|
7
9
|
class NitroBackgroundLocationService : Service() {
|
|
8
10
|
private val controller by lazy {
|
|
@@ -14,18 +16,38 @@ class NitroBackgroundLocationService : Service() {
|
|
|
14
16
|
val foregroundService = config.android?.foregroundService
|
|
15
17
|
?: throw IllegalStateException("Android foreground service options are required")
|
|
16
18
|
val notification = NitroBackgroundNotificationFactory.create(this, foregroundService)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
val notificationId = foregroundService.notificationId?.toInt() ?: 9471
|
|
20
|
+
NitroGeoLog.d("Service.onStartCommand(): startForeground id=$notificationId type=location")
|
|
21
|
+
try {
|
|
22
|
+
ServiceCompat.startForeground(
|
|
23
|
+
this,
|
|
24
|
+
notificationId,
|
|
25
|
+
notification,
|
|
26
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
|
27
|
+
)
|
|
28
|
+
} catch (error: Exception) {
|
|
29
|
+
// Android 14+ rejects a "location" foreground service if location permission is not
|
|
30
|
+
// held at start time (SecurityException), and Android 12+ rejects background starts
|
|
31
|
+
// (ForegroundServiceStartNotAllowedException, an IllegalStateException). Record and stop
|
|
32
|
+
// cleanly instead of letting the service crash.
|
|
33
|
+
controller.recordError("Failed to start foreground location service: ${error.message}", error)
|
|
34
|
+
stopSelf()
|
|
35
|
+
return START_NOT_STICKY
|
|
36
|
+
}
|
|
37
|
+
NitroGeoLog.d("Service.onStartCommand(): starting native location updates")
|
|
21
38
|
controller.startNativeLocationUpdates()
|
|
22
39
|
if (config.trackingMode == com.margelo.nitro.nitrogeolocation.BackgroundTrackingMode.ACTIVITYAWARE ||
|
|
23
40
|
config.activityRecognition?.enabled == true) {
|
|
24
41
|
runCatching {
|
|
25
42
|
controller.startActivityRecognition(config.activityRecognition)
|
|
43
|
+
}.onFailure {
|
|
44
|
+
controller.recordError("Failed to start activity recognition: ${it.message}", it)
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
runCatching { controller.registerPersistedGeofencesIfNeeded() }
|
|
48
|
+
.onFailure {
|
|
49
|
+
controller.recordError("Failed to register persisted geofences: ${it.message}", it)
|
|
50
|
+
}
|
|
29
51
|
return if (config.stopOnTerminate == false) START_STICKY else START_NOT_STICKY
|
|
30
52
|
}
|
|
31
53
|
|
package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBackgroundStore.kt
CHANGED
|
@@ -29,6 +29,12 @@ import org.json.JSONObject
|
|
|
29
29
|
class NitroBackgroundStore(context: Context) :
|
|
30
30
|
SQLiteOpenHelper(context, "nitro_background_location.db", null, 3) {
|
|
31
31
|
|
|
32
|
+
init {
|
|
33
|
+
// WAL lets the broadcast-receiver writer and concurrent readers (JS Promise threads and the
|
|
34
|
+
// sync worker) proceed without blocking each other under a steady stream of location inserts.
|
|
35
|
+
setWriteAheadLoggingEnabled(true)
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
override fun onCreate(db: SQLiteDatabase) {
|
|
33
39
|
db.execSQL(
|
|
34
40
|
"""
|
package/android/src/main/java/com/margelo/nitro/nitrogeolocation/background/NitroBootReceiver.kt
CHANGED
|
@@ -13,14 +13,25 @@ class NitroBootReceiver : BroadcastReceiver() {
|
|
|
13
13
|
return
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
val
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
// registerPersistedGeofencesIfNeeded() blocks (waitForTask, up to 30s) and start() does
|
|
17
|
+
// disk I/O; running them inline would block the broadcast's main thread and risk an ANR.
|
|
18
|
+
// Hand off to a worker thread and keep the broadcast alive with goAsync() until it finishes.
|
|
19
|
+
val appContext = context.applicationContext
|
|
20
|
+
val pendingResult = goAsync()
|
|
21
|
+
Thread {
|
|
22
|
+
try {
|
|
23
|
+
val prefs = appContext.getSharedPreferences(
|
|
24
|
+
"nitro_background_location",
|
|
25
|
+
Context.MODE_PRIVATE
|
|
26
|
+
)
|
|
27
|
+
val controller = NitroBackgroundLocationController.getInstance(appContext)
|
|
28
|
+
runCatching { controller.registerPersistedGeofencesIfNeeded() }
|
|
29
|
+
if (prefs.getBoolean("startOnBoot", false)) {
|
|
30
|
+
runCatching { controller.start(null) }
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
pendingResult.finish()
|
|
34
|
+
}
|
|
35
|
+
}.start()
|
|
25
36
|
}
|
|
26
37
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.margelo.nitro.nitrogeolocation.background
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.margelo.nitro.nitrogeolocation.BuildConfig
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight tagged logger for the background location pipeline.
|
|
8
|
+
*
|
|
9
|
+
* The background delivery path runs across a foreground service, a broadcast receiver and a
|
|
10
|
+
* headless task, where a misconfiguration can silently stop location delivery with no error.
|
|
11
|
+
* This logger makes that path observable from logcat.
|
|
12
|
+
*
|
|
13
|
+
* Verbose [d] logs are gated behind [verbose] (defaults to debug builds) so release builds stay
|
|
14
|
+
* quiet, while [w] and [e] always emit because they mark real delivery or registration failures.
|
|
15
|
+
*/
|
|
16
|
+
internal object NitroGeoLog {
|
|
17
|
+
private const val TAG = "NitroGeolocation"
|
|
18
|
+
|
|
19
|
+
@Volatile
|
|
20
|
+
var verbose: Boolean = BuildConfig.DEBUG
|
|
21
|
+
|
|
22
|
+
fun d(message: String) {
|
|
23
|
+
if (verbose) Log.d(TAG, message)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fun w(message: String, error: Throwable? = null) {
|
|
27
|
+
Log.w(TAG, message, error)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fun e(message: String, error: Throwable? = null) {
|
|
31
|
+
Log.e(TAG, message, error)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -11,6 +11,7 @@ import com.margelo.nitro.nitrogeolocation.BackgroundLocationSource
|
|
|
11
11
|
|
|
12
12
|
class NitroLocationUpdateReceiver : BroadcastReceiver() {
|
|
13
13
|
override fun onReceive(context: Context, intent: Intent) {
|
|
14
|
+
NitroGeoLog.d("LocationUpdateReceiver.onReceive(): action=${intent.action}")
|
|
14
15
|
val controller = NitroBackgroundLocationController.getInstance(context)
|
|
15
16
|
val platformLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
16
17
|
intent.getParcelableExtra(LocationManager.KEY_LOCATION_CHANGED, Location::class.java)
|
|
@@ -26,7 +27,16 @@ class NitroLocationUpdateReceiver : BroadcastReceiver() {
|
|
|
26
27
|
return
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
val result = LocationResult.extractResult(intent)
|
|
30
|
+
val result = LocationResult.extractResult(intent)
|
|
31
|
+
if (result == null) {
|
|
32
|
+
NitroGeoLog.w(
|
|
33
|
+
"LocationUpdateReceiver.onReceive(): broadcast carried no location " +
|
|
34
|
+
"(KEY_LOCATION_CHANGED and LocationResult both null) — dropping. " +
|
|
35
|
+
"On Android 12+ this usually means the broadcast PendingIntent was built " +
|
|
36
|
+
"with FLAG_IMMUTABLE, so the OS could not inject the LocationResult extras."
|
|
37
|
+
)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
30
40
|
for (location in result.locations) {
|
|
31
41
|
controller.handleNativeLocation(
|
|
32
42
|
location,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package com.margelo.nitro.nitrogeolocation.background
|
|
2
|
+
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
7
|
+
import org.junit.Assert.assertTrue
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
|
|
10
|
+
class BackgroundDecisionsTest {
|
|
11
|
+
@Test
|
|
12
|
+
fun pendingIntentFlagsAreMutableOnApiSAndAbove() {
|
|
13
|
+
val flags = mutablePendingIntentFlags(Build.VERSION_CODES.S)
|
|
14
|
+
assertTrue(
|
|
15
|
+
"FLAG_MUTABLE must be set on API >= S, or the OS rejects the registration",
|
|
16
|
+
(flags and PendingIntent.FLAG_MUTABLE) != 0
|
|
17
|
+
)
|
|
18
|
+
assertTrue(
|
|
19
|
+
"FLAG_UPDATE_CURRENT must be set",
|
|
20
|
+
(flags and PendingIntent.FLAG_UPDATE_CURRENT) != 0
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun pendingIntentFlagsAreNotImmutableBelowApiS() {
|
|
26
|
+
val flags = mutablePendingIntentFlags(Build.VERSION_CODES.R)
|
|
27
|
+
assertFalse(
|
|
28
|
+
"FLAG_IMMUTABLE must not be set below S (PendingIntents are mutable by default there)",
|
|
29
|
+
(flags and PendingIntent.FLAG_IMMUTABLE) != 0
|
|
30
|
+
)
|
|
31
|
+
assertTrue(
|
|
32
|
+
"FLAG_UPDATE_CURRENT must be set",
|
|
33
|
+
(flags and PendingIntent.FLAG_UPDATE_CURRENT) != 0
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun backoffGrowsExponentiallyThenCaps() {
|
|
39
|
+
assertEquals(1_000L, backoffBaseDelayMs(0, 1_000L, 30_000L))
|
|
40
|
+
assertEquals(2_000L, backoffBaseDelayMs(1, 1_000L, 30_000L))
|
|
41
|
+
assertEquals(4_000L, backoffBaseDelayMs(2, 1_000L, 30_000L))
|
|
42
|
+
assertEquals(8_000L, backoffBaseDelayMs(3, 1_000L, 30_000L))
|
|
43
|
+
assertEquals(30_000L, backoffBaseDelayMs(10, 1_000L, 30_000L))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun backoffClampsNegativeAttemptToBase() {
|
|
48
|
+
assertEquals(1_000L, backoffBaseDelayMs(-1, 1_000L, 30_000L))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun resolveMaxStoredUsesConfiguredPositiveValue() {
|
|
53
|
+
assertEquals(500, resolveMaxStored(500, 10_000))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun resolveMaxStoredFallsBackToDefaultWhenUnset() {
|
|
58
|
+
assertEquals(10_000, resolveMaxStored(null, 10_000))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test
|
|
62
|
+
fun resolveMaxStoredTreatsNonPositiveAsUnbounded() {
|
|
63
|
+
// 0 is the library's explicit unbounded opt-out (pruneRows treats <= 0 as no-prune).
|
|
64
|
+
assertEquals(0, resolveMaxStored(0, 10_000))
|
|
65
|
+
assertEquals(0, resolveMaxStored(-5, 10_000))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -544,6 +544,12 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
|
|
|
544
544
|
}
|
|
545
545
|
|
|
546
546
|
func handleError(_ error: Error) {
|
|
547
|
+
// kCLErrorLocationUnknown is transient — CoreLocation couldn't get a fix right now but keeps
|
|
548
|
+
// trying (very common on the Simulator and during brief GPS gaps). Apple's guidance is to
|
|
549
|
+
// ignore it; forwarding it would pollute the consumer's error stream with benign noise.
|
|
550
|
+
if let clError = error as? CLError, clError.code == .locationUnknown {
|
|
551
|
+
return
|
|
552
|
+
}
|
|
547
553
|
let locationError = LocationError(code: -1, message: error.localizedDescription)
|
|
548
554
|
errorListeners.values.forEach { $0(locationError) }
|
|
549
555
|
}
|
|
@@ -690,25 +696,31 @@ class NitroBackgroundLocation: HybridNitroBackgroundLocationSpec {
|
|
|
690
696
|
return max(Int(value.rounded(.down)), 1)
|
|
691
697
|
}
|
|
692
698
|
|
|
699
|
+
// Default store cap (rows) applied when maxStored* is unset, matching the Android side. An
|
|
700
|
+
// explicit value <= 0 means UNBOUNDED (no cap), preserving the library's original opt-out.
|
|
701
|
+
private static let defaultMaxStoredRows = 10_000
|
|
702
|
+
|
|
703
|
+
private func resolveMaxStored(_ configured: Double?, default def: Int) -> Int? {
|
|
704
|
+
guard let configured = configured else { return def }
|
|
705
|
+
if configured <= 0 { return nil }
|
|
706
|
+
return Int(configured)
|
|
707
|
+
}
|
|
708
|
+
|
|
693
709
|
private func appendStoredLocation(_ location: StoredBackgroundLocation) {
|
|
694
710
|
guard shouldPersist() else { return }
|
|
695
711
|
storedLocations.append(location)
|
|
696
|
-
if let
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
storedLocations = Array(storedLocations.suffix(max))
|
|
700
|
-
}
|
|
712
|
+
if let max = resolveMaxStored(options?.maxStoredLocations, default: Self.defaultMaxStoredRows),
|
|
713
|
+
storedLocations.count > max {
|
|
714
|
+
storedLocations = Array(storedLocations.suffix(max))
|
|
701
715
|
}
|
|
702
716
|
}
|
|
703
717
|
|
|
704
718
|
private func appendStoredEvent(_ event: StoredBackgroundEventEnvelope) {
|
|
705
719
|
guard shouldPersist() else { return }
|
|
706
720
|
storedEvents.append(event)
|
|
707
|
-
if let
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
storedEvents = Array(storedEvents.suffix(max))
|
|
711
|
-
}
|
|
721
|
+
if let max = resolveMaxStored(options?.maxStoredEvents, default: Self.defaultMaxStoredRows),
|
|
722
|
+
storedEvents.count > max {
|
|
723
|
+
storedEvents = Array(storedEvents.suffix(max))
|
|
712
724
|
}
|
|
713
725
|
}
|
|
714
726
|
|
package/package.json
CHANGED
package/src/background/types.ts
CHANGED
|
@@ -76,7 +76,15 @@ export interface BackgroundLocationOptions {
|
|
|
76
76
|
maxUpdateDelay?: number;
|
|
77
77
|
waitForAccurateLocation?: boolean;
|
|
78
78
|
persist?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Max number of locations retained in the on-device store before older rows are pruned.
|
|
81
|
+
* Unset → a built-in safety cap (the native default). Set to `0` for UNBOUNDED storage.
|
|
82
|
+
*/
|
|
79
83
|
maxStoredLocations?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Max number of events retained in the on-device store before older rows are pruned.
|
|
86
|
+
* Unset → a built-in safety cap (the native default). Set to `0` for UNBOUNDED storage.
|
|
87
|
+
*/
|
|
80
88
|
maxStoredEvents?: number;
|
|
81
89
|
stopOnTerminate?: boolean;
|
|
82
90
|
startOnBoot?: boolean;
|