@bricks-soft/capacitor-location-tracking 0.0.0-stage → 8.0.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.
Files changed (168) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2759 -2
  3. package/android/build.gradle +106 -0
  4. package/android/consumer-rules.pro +42 -0
  5. package/android/src/main/AndroidManifest.xml +52 -0
  6. package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
  7. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
  8. package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
  9. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
  10. package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
  11. package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
  12. package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
  13. package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
  14. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
  15. package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
  16. package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
  17. package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
  18. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
  19. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
  20. package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
  21. package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
  22. package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
  23. package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
  24. package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
  25. package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
  26. package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
  27. package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
  28. package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
  29. package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
  30. package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
  31. package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
  32. package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
  33. package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
  34. package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
  35. package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
  36. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
  37. package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
  38. package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
  39. package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
  40. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
  41. package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
  42. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
  43. package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
  44. package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
  45. package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
  46. package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
  47. package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
  48. package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
  49. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
  50. package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
  51. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
  52. package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
  53. package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
  54. package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
  55. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
  56. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
  57. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
  58. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
  59. package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
  60. package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
  61. package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
  62. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
  63. package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
  64. package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
  65. package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
  66. package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
  67. package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
  68. package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
  69. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
  70. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
  71. package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
  72. package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
  73. package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
  74. package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
  75. package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
  76. package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
  77. package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
  78. package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
  79. package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
  80. package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
  81. package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
  82. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
  83. package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
  84. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
  85. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
  86. package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
  87. package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
  88. package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
  89. package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
  90. package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
  91. package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
  92. package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
  93. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
  94. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
  95. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
  96. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
  97. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
  98. package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
  99. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
  100. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
  101. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
  102. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
  103. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
  104. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
  105. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
  106. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
  107. package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
  108. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
  109. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
  110. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
  111. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
  112. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
  113. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
  114. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
  115. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
  116. package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
  117. package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
  118. package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
  119. package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
  120. package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
  121. package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
  122. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
  123. package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
  124. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
  125. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
  126. package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
  127. package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
  128. package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
  129. package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
  130. package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
  131. package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
  132. package/android/src/main/res/values/lt_strings.xml +11 -0
  133. package/android/src/main/res/xml/lt_file_paths.xml +5 -0
  134. package/dist/esm/definitions.d.ts +719 -0
  135. package/dist/esm/definitions.js +2 -0
  136. package/dist/esm/definitions.js.map +1 -0
  137. package/dist/esm/events.d.ts +50 -0
  138. package/dist/esm/events.js +52 -0
  139. package/dist/esm/events.js.map +1 -0
  140. package/dist/esm/index.d.ts +3 -0
  141. package/dist/esm/index.js +5 -0
  142. package/dist/esm/index.js.map +1 -0
  143. package/dist/esm/plugin.d.ts +3 -0
  144. package/dist/esm/plugin.js +10 -0
  145. package/dist/esm/plugin.js.map +1 -0
  146. package/dist/esm/web/config.d.ts +19 -0
  147. package/dist/esm/web/config.js +243 -0
  148. package/dist/esm/web/config.js.map +1 -0
  149. package/dist/esm/web/device.d.ts +9 -0
  150. package/dist/esm/web/device.js +90 -0
  151. package/dist/esm/web/device.js.map +1 -0
  152. package/dist/esm/web/errors.d.ts +14 -0
  153. package/dist/esm/web/errors.js +19 -0
  154. package/dist/esm/web/errors.js.map +1 -0
  155. package/dist/esm/web/geolocation.d.ts +26 -0
  156. package/dist/esm/web/geolocation.js +117 -0
  157. package/dist/esm/web/geolocation.js.map +1 -0
  158. package/dist/esm/web/location.d.ts +7 -0
  159. package/dist/esm/web/location.js +70 -0
  160. package/dist/esm/web/location.js.map +1 -0
  161. package/dist/esm/web.d.ts +164 -0
  162. package/dist/esm/web.js +328 -0
  163. package/dist/esm/web.js.map +1 -0
  164. package/dist/plugin.cjs.js +945 -0
  165. package/dist/plugin.cjs.js.map +1 -0
  166. package/dist/plugin.js +948 -0
  167. package/dist/plugin.js.map +1 -0
  168. package/package.json +59 -4
@@ -0,0 +1,461 @@
1
+ package com.brickssoft.locationtracking.logging
2
+
3
+ import android.content.ClipData
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import android.util.Log
9
+ import androidx.core.content.FileProvider
10
+ import com.brickssoft.locationtracking.BuildConfig
11
+ import com.brickssoft.locationtracking.core.AppDispatchers
12
+ import com.brickssoft.locationtracking.core.Clock
13
+ import com.brickssoft.locationtracking.core.Constants
14
+ import com.brickssoft.locationtracking.core.ErrorCode
15
+ import com.brickssoft.locationtracking.core.Iso8601
16
+ import com.brickssoft.locationtracking.core.LogLevel
17
+ import com.brickssoft.locationtracking.core.Logger
18
+ import com.brickssoft.locationtracking.core.TrackingException
19
+ import com.brickssoft.locationtracking.model.HttpResult
20
+ import kotlinx.coroutines.CancellationException
21
+ import kotlinx.coroutines.CoroutineExceptionHandler
22
+ import kotlinx.coroutines.CoroutineScope
23
+ import kotlinx.coroutines.SupervisorJob
24
+ import kotlinx.coroutines.launch
25
+ import kotlinx.coroutines.sync.Mutex
26
+ import kotlinx.coroutines.sync.withLock
27
+ import kotlinx.coroutines.withContext
28
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
29
+ import okhttp3.MediaType.Companion.toMediaType
30
+ import okhttp3.MultipartBody
31
+ import okhttp3.OkHttpClient
32
+ import okhttp3.Request
33
+ import okhttp3.RequestBody.Companion.asRequestBody
34
+ import org.json.JSONException
35
+ import org.json.JSONObject
36
+ import java.io.BufferedReader
37
+ import java.io.File
38
+ import java.io.FileOutputStream
39
+ import java.io.IOException
40
+ import java.io.InputStreamReader
41
+ import java.io.RandomAccessFile
42
+ import java.util.concurrent.atomic.AtomicBoolean
43
+ import java.util.zip.GZIPOutputStream
44
+
45
+ /**
46
+ * [LogStore] that keeps the log in daily UTC files `filesDir/location-tracking-logs/lt-YYYY-MM-DD.log`, in the format
47
+ * of [LogFormatter].
48
+ *
49
+ * [write] never blocks and never throws: it stamps the entry with [Clock.now] and appends it to an in-memory queue
50
+ * (beyond [maxPending] pending entries the oldest are dropped, and a warning records how many). One writer at a time
51
+ * drains the queue on [AppDispatchers.io]. [read], [destroy], [upload] and [prepareEmail] drain the queue first, so
52
+ * they see every entry written before they were called.
53
+ *
54
+ * Files are purged by [configure] and on each UTC day rollover: a file is deleted when its whole day lies more than
55
+ * `maxDays` days back, i.e. the files of today and of the previous `maxDays` days are kept.
56
+ *
57
+ * [read] returns whole entries (a header line plus its indented continuation lines). `start`/`end` are inclusive
58
+ * bounds on the entry time; `level` keeps entries at that severity or above, as [LogLevel.allows] (so `OFF` matches
59
+ * nothing); a missing or non-positive `limit` means no limit.
60
+ *
61
+ * The logger's own I/O errors go to `android.util.Log` as a last resort: reporting them through [Logger] would recurse
62
+ * into this sink.
63
+ *
64
+ * @param maxPending entries kept in memory while the writer is behind; the oldest are dropped beyond it.
65
+ */
66
+ class FileLogger(
67
+ context: Context,
68
+ private val clock: Clock,
69
+ private val dispatchers: AppDispatchers,
70
+ private val http: Lazy<OkHttpClient>,
71
+ maxPending: Int = DEFAULT_MAX_PENDING,
72
+ ) : LogStore {
73
+ /** A queued entry; the stack trace is rendered at write time so the throwable is not retained. */
74
+ private class Entry(
75
+ val timeMs: Long,
76
+ val level: LogLevel,
77
+ val tag: String,
78
+ val message: String,
79
+ val stackTrace: String?,
80
+ )
81
+
82
+ private val appContext: Context = context.applicationContext ?: context
83
+ private val maxPending = maxPending.coerceAtLeast(1)
84
+
85
+ // Resolved on first use, on the writer's thread: Context.getFilesDir() may touch the disk.
86
+ private val logDir: File by lazy { File(appContext.filesDir, Constants.LOG_DIR) }
87
+ private val cacheLogDir: File by lazy { File(appContext.cacheDir, Constants.LOG_DIR) }
88
+
89
+ private val queueLock = Any()
90
+ private val pending = ArrayDeque<Entry>() // guarded by queueLock
91
+ private var dropped = 0 // guarded by queueLock
92
+ private val drainScheduled = AtomicBoolean(false)
93
+
94
+ /** Serializes all file access; whoever holds it is the single writer. */
95
+ private val fileLock = Mutex()
96
+ private var lastWrittenDay = Long.MIN_VALUE // guarded by fileLock
97
+ private var newlineCheckedFile: String? = null // guarded by fileLock
98
+
99
+ @Volatile
100
+ private var level: LogLevel = LogLevel.INFO
101
+
102
+ @Volatile
103
+ private var maxDays: Int = DEFAULT_MAX_DAYS
104
+
105
+ private val scope = CoroutineScope(
106
+ SupervisorJob() + dispatchers.io + CoroutineExceptionHandler { _, t -> fallbackLog("log writer failed", t) },
107
+ )
108
+
109
+ override fun write(level: LogLevel, tag: String, message: String, error: Throwable?) {
110
+ try {
111
+ if (!this.level.allows(level)) return
112
+ val stackTrace = error?.let { LogFormatter.stackTrace(it) }
113
+ synchronized(queueLock) {
114
+ if (pending.size >= maxPending) {
115
+ pending.removeFirst()
116
+ dropped++
117
+ }
118
+ // Stamped inside the lock so that queue (= file) order is timestamp order.
119
+ pending.addLast(Entry(clock.now(), level, tag, message, stackTrace))
120
+ }
121
+ scheduleDrain()
122
+ } catch (_: Throwable) {
123
+ // A logger must never break its caller.
124
+ }
125
+ }
126
+
127
+ override fun configure(level: LogLevel, maxDays: Int) {
128
+ this.level = level
129
+ this.maxDays = maxDays
130
+ launchSafely { fileLock.withLock { purgeLocked(LogFormatter.epochDay(clock.now())) } }
131
+ }
132
+
133
+ /** Writes every pending entry to disk. Used by tests and by every operation that reads the files. */
134
+ internal suspend fun flush() {
135
+ withContext(dispatchers.io) { fileLock.withLock { drainLocked() } }
136
+ }
137
+
138
+ override suspend fun read(q: LogQuery): String = withContext(dispatchers.io) {
139
+ fileLock.withLock {
140
+ drainLocked()
141
+ try {
142
+ readLocked(q)
143
+ } catch (e: IOException) {
144
+ throw TrackingException(ErrorCode.IO_ERROR, "Could not read the log: ${e.message}", e)
145
+ }
146
+ }
147
+ }
148
+
149
+ /** Deletes the daily files and every gzipped copy (email attachment, leftover upload files). */
150
+ override suspend fun destroy() {
151
+ withContext(dispatchers.io) {
152
+ fileLock.withLock {
153
+ drainLocked()
154
+ logFiles().forEach { it.second.delete() }
155
+ cacheLogDir.listFiles()?.forEach { it.delete() }
156
+ newlineCheckedFile = null
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Posts the gzipped log as multipart/form-data. HTTP and I/O failures come back as an unsuccessful [HttpResult]
163
+ * (status 0 when there was no response); an invalid [url] or header throws `INVALID_ARGUMENT`.
164
+ */
165
+ override suspend fun upload(url: String, headers: Map<String, String>, paramsJson: String?): HttpResult {
166
+ val httpUrl = url.toHttpUrlOrNull()
167
+ ?: throw TrackingException(ErrorCode.INVALID_ARGUMENT, "uploadLog: invalid URL: $url")
168
+ val request = Request.Builder().url(httpUrl)
169
+ try {
170
+ headers.forEach { (name, value) -> request.header(name, value) }
171
+ } catch (e: IllegalArgumentException) {
172
+ throw TrackingException(ErrorCode.INVALID_ARGUMENT, "uploadLog: ${e.message}", e)
173
+ }
174
+ return withContext(dispatchers.io) {
175
+ var gzip: File? = null
176
+ try {
177
+ val file = fileLock.withLock {
178
+ drainLocked()
179
+ cacheLogDir.mkdirs()
180
+ File.createTempFile(UPLOAD_TEMP_PREFIX, GZIP_SUFFIX, cacheLogDir).also {
181
+ gzip = it
182
+ writeGzipLocked(it)
183
+ }
184
+ }
185
+ val body = MultipartBody.Builder()
186
+ .setType(MultipartBody.FORM)
187
+ .addFormDataPart(UPLOAD_FIELD, UPLOAD_FILE_NAME, file.asRequestBody(GZIP_MEDIA_TYPE.toMediaType()))
188
+ parseParams(paramsJson).forEach { (name, value) -> body.addFormDataPart(name, value) }
189
+ http.value.newCall(request.post(body.build()).build()).execute().use { response ->
190
+ val text = response.body?.string().orEmpty()
191
+ if (!response.isSuccessful) Logger.w(TAG, "uploadLog: HTTP ${response.code}")
192
+ HttpResult(response.isSuccessful, response.code, text, emptyList())
193
+ }
194
+ } catch (e: CancellationException) {
195
+ throw e
196
+ } catch (e: Exception) {
197
+ // IOException (network, or gzip/disk), or a runtime failure inside OkHttp.
198
+ Logger.w(TAG, "uploadLog failed", e)
199
+ HttpResult(false, 0, e.message ?: e.javaClass.simpleName, emptyList())
200
+ } finally {
201
+ gzip?.delete()
202
+ }
203
+ }
204
+ }
205
+
206
+ override suspend fun prepareEmail(email: String, subject: String?): Intent = withContext(dispatchers.io) {
207
+ val file = fileLock.withLock {
208
+ drainLocked()
209
+ val temp = File(cacheLogDir, "$EMAIL_FILE_NAME.tmp")
210
+ try {
211
+ cacheLogDir.mkdirs()
212
+ writeGzipLocked(temp)
213
+ val target = File(cacheLogDir, EMAIL_FILE_NAME)
214
+ if (!temp.renameTo(target)) {
215
+ target.delete()
216
+ if (!temp.renameTo(target)) throw IOException("Could not rename $temp to $target")
217
+ }
218
+ target
219
+ } catch (e: IOException) {
220
+ temp.delete()
221
+ throw TrackingException(ErrorCode.IO_ERROR, "Could not prepare the log: ${e.message}", e)
222
+ }
223
+ }
224
+ val uri = try {
225
+ FileProvider.getUriForFile(appContext, appContext.packageName + Constants.LOG_FILE_PROVIDER_SUFFIX, file)
226
+ } catch (e: IllegalArgumentException) {
227
+ throw TrackingException(ErrorCode.INTERNAL, "LogFileProvider is not configured: ${e.message}", e)
228
+ }
229
+ emailIntent(email, subject?.takeIf { it.isNotBlank() } ?: DEFAULT_EMAIL_SUBJECT, uri)
230
+ }
231
+
232
+ private fun emailIntent(email: String, subject: String, uri: Uri): Intent {
233
+ val send = Intent(Intent.ACTION_SEND).apply {
234
+ type = GZIP_MEDIA_TYPE
235
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
236
+ putExtra(Intent.EXTRA_SUBJECT, subject)
237
+ putExtra(Intent.EXTRA_TEXT, emailSummary())
238
+ putExtra(Intent.EXTRA_STREAM, uri)
239
+ clipData = ClipData.newRawUri(EMAIL_FILE_NAME, uri)
240
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
241
+ }
242
+ return Intent.createChooser(send, subject).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
243
+ }
244
+
245
+ private fun emailSummary(): String = buildString {
246
+ append("Location tracking log\n")
247
+ append("Device: ").append(Build.MANUFACTURER).append(' ').append(Build.MODEL).append('\n')
248
+ append("Android: ").append(Build.VERSION.RELEASE).append(" (API ").append(Build.VERSION.SDK_INT).append(")\n")
249
+ append("Plugin version: ").append(BuildConfig.PLUGIN_VERSION).append('\n')
250
+ append("App: ").append(appContext.packageName).append('\n')
251
+ append("Created: ").append(Iso8601.format(clock.now())).append('\n')
252
+ }
253
+
254
+ private fun scheduleDrain() {
255
+ if (!drainScheduled.compareAndSet(false, true)) return
256
+ launchSafely {
257
+ // Cleared before draining: an entry queued from now on is either drained below or schedules a new drain.
258
+ drainScheduled.set(false)
259
+ fileLock.withLock { drainLocked() }
260
+ }
261
+ }
262
+
263
+ private fun launchSafely(block: suspend () -> Unit) {
264
+ try {
265
+ scope.launch { block() }
266
+ } catch (t: Throwable) {
267
+ drainScheduled.set(false)
268
+ fallbackLog("could not start the log writer", t)
269
+ }
270
+ }
271
+
272
+ /** Writes queued entries until the queue is empty. Caller holds [fileLock]. */
273
+ private fun drainLocked() {
274
+ while (true) {
275
+ val batch: List<Entry>
276
+ val droppedCount: Int
277
+ synchronized(queueLock) {
278
+ if (pending.isEmpty() && dropped == 0) return
279
+ batch = ArrayList(pending)
280
+ pending.clear()
281
+ droppedCount = dropped
282
+ dropped = 0
283
+ }
284
+ writeBatchLocked(withDropNotice(batch, droppedCount))
285
+ }
286
+ }
287
+
288
+ private fun withDropNotice(batch: List<Entry>, droppedCount: Int): List<Entry> {
289
+ if (droppedCount == 0 || !level.allows(LogLevel.WARN)) return batch
290
+ val time = batch.firstOrNull()?.timeMs ?: clock.now()
291
+ val notice = Entry(time, LogLevel.WARN, TAG, "Dropped $droppedCount log entries: write queue full", null)
292
+ return listOf(notice) + batch
293
+ }
294
+
295
+ /** Appends [batch] to the daily files, one file open per run of same-day entries. Caller holds [fileLock]. */
296
+ private fun writeBatchLocked(batch: List<Entry>) {
297
+ var start = 0
298
+ while (start < batch.size) {
299
+ val day = LogFormatter.epochDay(batch[start].timeMs)
300
+ var end = start + 1
301
+ while (end < batch.size && LogFormatter.epochDay(batch[end].timeMs) == day) end++
302
+ // Tracks the last day actually written (not the maximum), so purging resumes after a wrong clock is fixed.
303
+ if (lastWrittenDay != Long.MIN_VALUE && day > lastWrittenDay) purgeLocked(day)
304
+ lastWrittenDay = day
305
+ appendLocked(batch, start, end)
306
+ start = end
307
+ }
308
+ }
309
+
310
+ private fun appendLocked(batch: List<Entry>, start: Int, end: Int) {
311
+ val file = File(logDir, LogFormatter.fileName(batch[start].timeMs))
312
+ val text = StringBuilder()
313
+ try {
314
+ if (!logDir.isDirectory) logDir.mkdirs()
315
+ // A write cut short (process killed, disk full) leaves a line without '\n'; never glue a header onto it.
316
+ if (file.name != newlineCheckedFile && endsWithoutNewline(file)) text.append('\n')
317
+ for (i in start until end) {
318
+ val e = batch[i]
319
+ text.append(LogFormatter.formatEntry(e.timeMs, e.level, e.tag, e.message, e.stackTrace))
320
+ }
321
+ FileOutputStream(file, true).use { it.write(text.toString().toByteArray(Charsets.UTF_8)) }
322
+ newlineCheckedFile = file.name
323
+ } catch (e: IOException) {
324
+ newlineCheckedFile = null
325
+ fallbackLog("could not write $file", e)
326
+ }
327
+ }
328
+
329
+ private fun endsWithoutNewline(file: File): Boolean {
330
+ if (!file.isFile || file.length() == 0L) return false
331
+ return RandomAccessFile(file, "r").use { raf ->
332
+ raf.seek(raf.length() - 1)
333
+ raf.read() != '\n'.code
334
+ }
335
+ }
336
+
337
+ /** Deletes daily files whose day is more than [maxDays] days before [today]. Caller holds [fileLock]. */
338
+ private fun purgeLocked(today: Long) {
339
+ val keepFrom = today - maxDays.coerceAtLeast(0)
340
+ try {
341
+ logFiles().forEach { (day, file) -> if (day < keepFrom) file.delete() }
342
+ } catch (e: RuntimeException) {
343
+ fallbackLog("could not purge old log files", e)
344
+ }
345
+ }
346
+
347
+ /** Daily log files with their day numbers, oldest first. */
348
+ private fun logFiles(): List<Pair<Long, File>> =
349
+ logDir.listFiles().orEmpty()
350
+ .mapNotNull { file -> LogFormatter.epochDayOfFileName(file.name)?.let { it to file } }
351
+ .filter { it.second.isFile }
352
+ .sortedBy { it.first }
353
+
354
+ /** Caller holds [fileLock]. */
355
+ private fun readLocked(q: LogQuery): String {
356
+ val limit = q.limit?.takeIf { it > 0 }
357
+ val files = logFiles().filter { (day, _) ->
358
+ (q.start == null || (day + 1) * LogFormatter.DAY_MS > q.start) &&
359
+ (q.end == null || day * LogFormatter.DAY_MS <= q.end)
360
+ }
361
+ val out = StringBuilder()
362
+ var count = 0
363
+ if (q.ascending) {
364
+ for ((_, file) in files) {
365
+ forEachEntry(file, q) { entry ->
366
+ out.append(entry)
367
+ count++
368
+ limit == null || count < limit
369
+ }
370
+ if (limit != null && count >= limit) break
371
+ }
372
+ } else {
373
+ // Newest file first; of each file keep only the newest entries that are still needed.
374
+ for ((_, file) in files.asReversed()) {
375
+ val needed = limit?.minus(count)
376
+ val newest = ArrayDeque<String>()
377
+ forEachEntry(file, q) { entry ->
378
+ newest.addLast(entry)
379
+ if (needed != null && newest.size > needed) newest.removeFirst()
380
+ true
381
+ }
382
+ for (i in newest.indices.reversed()) out.append(newest[i])
383
+ count += newest.size
384
+ if (limit != null && count >= limit) break
385
+ }
386
+ }
387
+ return out.toString()
388
+ }
389
+
390
+ /** Calls [onEntry] with each entry of [file] that matches [q], in file order, while it returns true. */
391
+ private inline fun forEachEntry(file: File, q: LogQuery, onEntry: (String) -> Boolean) {
392
+ BufferedReader(InputStreamReader(file.inputStream(), Charsets.UTF_8)).use { reader ->
393
+ // The entry being read, or null while skipping one that does not match. Entries never span files.
394
+ var current: StringBuilder? = null
395
+ while (true) {
396
+ val line = reader.readLine() ?: break
397
+ val header = LogFormatter.parseHeader(line)
398
+ if (header == null) {
399
+ current?.append(line)?.append('\n')
400
+ continue
401
+ }
402
+ if (current != null && !onEntry(current.toString())) return
403
+ current = if (matches(header, q)) StringBuilder(line).append('\n') else null
404
+ }
405
+ if (current != null) onEntry(current.toString())
406
+ }
407
+ }
408
+
409
+ private fun matches(header: LogFormatter.Header, q: LogQuery): Boolean =
410
+ (q.start == null || header.timeMs >= q.start) &&
411
+ (q.end == null || header.timeMs <= q.end) &&
412
+ (q.level == null || q.level.allows(header.level))
413
+
414
+ /** Writes all daily files, oldest first, gzipped into [target]. Caller holds [fileLock]. */
415
+ private fun writeGzipLocked(target: File) {
416
+ FileOutputStream(target).use { raw ->
417
+ GZIPOutputStream(raw.buffered()).use { out ->
418
+ for ((_, file) in logFiles()) file.inputStream().use { it.copyTo(out) }
419
+ }
420
+ }
421
+ }
422
+
423
+ private fun parseParams(paramsJson: String?): List<Pair<String, String>> {
424
+ if (paramsJson.isNullOrBlank()) return emptyList()
425
+ val json = try {
426
+ JSONObject(paramsJson)
427
+ } catch (e: JSONException) {
428
+ Logger.w(TAG, "uploadLog: ignoring params that are not a JSON object", e)
429
+ return emptyList()
430
+ }
431
+ return json.keys().asSequence().map { key ->
432
+ val value = json.opt(key)
433
+ key to if (value == null || value == JSONObject.NULL) "" else value.toString()
434
+ }.toList()
435
+ }
436
+
437
+ /** Last resort for the logger's own failures; see the class comment. */
438
+ private fun fallbackLog(message: String, t: Throwable?) {
439
+ try {
440
+ Log.w(TAG, message, t)
441
+ } catch (_: Throwable) {
442
+ // logcat unavailable (plain JVM)
443
+ }
444
+ }
445
+
446
+ companion object {
447
+ const val DEFAULT_MAX_PENDING = 5_000
448
+ const val DEFAULT_MAX_DAYS = 3
449
+
450
+ /** Name of the gzipped log attached by [upload] and [prepareEmail]. */
451
+ const val UPLOAD_FILE_NAME = "location-tracking-log.txt.gz"
452
+ const val EMAIL_FILE_NAME = UPLOAD_FILE_NAME
453
+ const val UPLOAD_FIELD = "log"
454
+ const val DEFAULT_EMAIL_SUBJECT = "Location tracking log"
455
+ const val GZIP_MEDIA_TYPE = "application/gzip"
456
+
457
+ private const val TAG = "LT.FileLogger"
458
+ private const val UPLOAD_TEMP_PREFIX = "upload-"
459
+ private const val GZIP_SUFFIX = ".txt.gz"
460
+ }
461
+ }
@@ -0,0 +1,9 @@
1
+ package com.brickssoft.locationtracking.logging
2
+
3
+ import androidx.core.content.FileProvider
4
+
5
+ /**
6
+ * FileProvider for sharing log files (authority `${applicationId}.locationtracking.logs`). A subclass is required
7
+ * because the Capacitor app template already declares `androidx.core.content.FileProvider`.
8
+ */
9
+ class LogFileProvider : FileProvider()
@@ -0,0 +1,148 @@
1
+ package com.brickssoft.locationtracking.logging
2
+
3
+ import com.brickssoft.locationtracking.core.Iso8601
4
+ import com.brickssoft.locationtracking.core.LogLevel
5
+
6
+ /**
7
+ * Line format and file naming of the log files.
8
+ *
9
+ * An entry is one header line, `<ISO-8601 ms UTC> <LEVEL padded to 7> <tag>: <first message line>`, followed by the
10
+ * remaining message lines and the stack trace lines, each indented by [INDENT]. Only header lines start with a digit,
11
+ * so an entry is its header plus every following line up to the next header.
12
+ *
13
+ * Daily files are named `lt-YYYY-MM-DD.log` after the UTC date of the entries they hold.
14
+ */
15
+ internal object LogFormatter {
16
+ /** Prefix of every line of an entry after its header line. */
17
+ const val INDENT = " "
18
+
19
+ const val DAY_MS = 86_400_000L
20
+
21
+ /** Width of the level column: the length of `VERBOSE`. */
22
+ private const val LEVEL_WIDTH = 7
23
+
24
+ /** Length of `yyyy-MM-ddTHH:mm:ss.SSSZ`. */
25
+ private const val TIMESTAMP_LENGTH = 24
26
+
27
+ private const val FILE_PREFIX = "lt-"
28
+ private const val FILE_SUFFIX = ".log"
29
+ private const val DATE_LENGTH = 10
30
+
31
+ private val levels = LogLevel.entries.filter { it != LogLevel.OFF }
32
+
33
+ /** Level and time of a header line. */
34
+ data class Header(val timeMs: Long, val level: LogLevel)
35
+
36
+ /** Formats one entry. The result always ends with `\n`. */
37
+ fun format(timeMs: Long, level: LogLevel, tag: String, message: String, error: Throwable?): String =
38
+ formatEntry(timeMs, level, tag, message, error?.let { stackTrace(it) })
39
+
40
+ /** Like [format], with the stack trace already rendered by [stackTrace]. */
41
+ fun formatEntry(timeMs: Long, level: LogLevel, tag: String, message: String, stackTrace: String?): String {
42
+ val sb = StringBuilder(64 + tag.length + message.length + (stackTrace?.length ?: 0))
43
+ sb.append(Iso8601.format(timeMs)).append(' ')
44
+ .append(level.name.padEnd(LEVEL_WIDTH)).append(' ')
45
+ .append(singleLine(tag)).append(": ")
46
+ val lines = message.trimEnd('\r', '\n').lines()
47
+ sb.append(lines.first()).append('\n')
48
+ for (i in 1 until lines.size) sb.append(INDENT).append(lines[i]).append('\n')
49
+ stackTrace?.lines()?.forEach { line ->
50
+ if (line.isNotBlank()) sb.append(INDENT).append(line.trimEnd()).append('\n')
51
+ }
52
+ return sb.toString()
53
+ }
54
+
55
+ /** The stack trace text of [error], causes included; never throws, even if the throwable's own methods do. */
56
+ fun stackTrace(error: Throwable): String = try {
57
+ error.stackTraceToString()
58
+ } catch (_: Throwable) {
59
+ error.javaClass.name
60
+ }
61
+
62
+ /** Parses a header line, or returns null for continuation lines and garbage. */
63
+ fun parseHeader(line: String): Header? {
64
+ if (line.length < TIMESTAMP_LENGTH + 2 || line[TIMESTAMP_LENGTH] != ' ') return null
65
+ val time = parseTimestamp(line) ?: return null
66
+ val levelStart = TIMESTAMP_LENGTH + 1
67
+ val levelEnd = line.indexOf(' ', levelStart).let { if (it < 0) line.length else it }
68
+ val length = levelEnd - levelStart
69
+ val level = levels.firstOrNull { it.name.length == length && line.startsWith(it.name, levelStart) }
70
+ ?: return null
71
+ return Header(time, level)
72
+ }
73
+
74
+ /**
75
+ * Parses `yyyy-MM-ddTHH:mm:ss.SSSZ` at the start of [text] to epoch ms, or returns null.
76
+ * Much faster than `SimpleDateFormat`, which matters when reading days of logs.
77
+ */
78
+ fun parseTimestamp(text: CharSequence): Long? {
79
+ if (text.length < TIMESTAMP_LENGTH) return null
80
+ if (text[4] != '-' || text[7] != '-' || text[10] != 'T' || text[13] != ':' || text[16] != ':' ||
81
+ text[19] != '.' || text[23] != 'Z'
82
+ ) {
83
+ return null
84
+ }
85
+ val day = parseDate(text, 0) ?: return null
86
+ val hour = digits(text, 11, 2)
87
+ val minute = digits(text, 14, 2)
88
+ val second = digits(text, 17, 2)
89
+ val millis = digits(text, 20, 3)
90
+ if (hour !in 0..23 || minute !in 0..59 || second !in 0..59 || millis < 0) return null
91
+ return day * DAY_MS + hour * 3_600_000L + minute * 60_000L + second * 1_000L + millis
92
+ }
93
+
94
+ /** UTC day number (days since 1970-01-01) of [timeMs]. */
95
+ fun epochDay(timeMs: Long): Long = Math.floorDiv(timeMs, DAY_MS)
96
+
97
+ /** `lt-YYYY-MM-DD.log` for the UTC date of [timeMs]. */
98
+ fun fileName(timeMs: Long): String = FILE_PREFIX + Iso8601.format(timeMs).substring(0, DATE_LENGTH) + FILE_SUFFIX
99
+
100
+ /** UTC day number of a daily log file name, or null if [name] is not one. */
101
+ fun epochDayOfFileName(name: String): Long? {
102
+ if (name.length != FILE_PREFIX.length + DATE_LENGTH + FILE_SUFFIX.length) return null
103
+ if (!name.startsWith(FILE_PREFIX) || !name.endsWith(FILE_SUFFIX)) return null
104
+ val start = FILE_PREFIX.length
105
+ if (name[start + 4] != '-' || name[start + 7] != '-') return null
106
+ return parseDate(name, start)
107
+ }
108
+
109
+ /** Parses `yyyy-MM-dd` at [start] (separators already checked) to a UTC day number. */
110
+ private fun parseDate(text: CharSequence, start: Int): Long? {
111
+ val year = digits(text, start, 4)
112
+ val month = digits(text, start + 5, 2)
113
+ val day = digits(text, start + 8, 2)
114
+ if (year < 0 || month !in 1..12 || day !in 1..daysInMonth(year, month)) return null
115
+ return daysFromCivil(year, month, day)
116
+ }
117
+
118
+ /** Non-negative integer of [count] ASCII digits at [start], or -1. */
119
+ private fun digits(text: CharSequence, start: Int, count: Int): Int {
120
+ var value = 0
121
+ for (i in start until start + count) {
122
+ val c = text[i]
123
+ if (c !in '0'..'9') return -1
124
+ value = value * 10 + (c - '0')
125
+ }
126
+ return value
127
+ }
128
+
129
+ private fun daysInMonth(year: Int, month: Int): Int = when (month) {
130
+ 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28
131
+ 4, 6, 9, 11 -> 30
132
+ else -> 31
133
+ }
134
+
135
+ /** Days since 1970-01-01 of a proleptic Gregorian date (H. Hinnant's `days_from_civil`). */
136
+ private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
137
+ val y = if (month <= 2) year - 1 else year
138
+ val era = (if (y >= 0) y else y - 399) / 400
139
+ val yearOfEra = y - era * 400
140
+ val monthIndex = (month + 9) % 12
141
+ val dayOfYear = (153 * monthIndex + 2) / 5 + day - 1
142
+ val dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
143
+ return era * 146_097L + dayOfEra - 719_468L
144
+ }
145
+
146
+ private fun singleLine(text: String): String =
147
+ if (text.indexOf('\n') < 0 && text.indexOf('\r') < 0) text else text.replace('\r', ' ').replace('\n', ' ')
148
+ }
@@ -0,0 +1,30 @@
1
+ package com.brickssoft.locationtracking.logging
2
+
3
+ import android.content.Intent
4
+ import com.brickssoft.locationtracking.core.LogLevel
5
+ import com.brickssoft.locationtracking.core.LogSink
6
+ import com.brickssoft.locationtracking.model.HttpResult
7
+
8
+ /** JS `LogQuery`; times are epoch ms. */
9
+ data class LogQuery(
10
+ val start: Long? = null,
11
+ val end: Long? = null,
12
+ val level: LogLevel? = null,
13
+ val limit: Int? = null,
14
+ val ascending: Boolean = true,
15
+ )
16
+
17
+ /** Persistent log (daily files in `location-tracking-logs`). Set as `Logger.sink` by Components. */
18
+ interface LogStore : LogSink {
19
+ fun configure(level: LogLevel, maxDays: Int)
20
+
21
+ suspend fun read(q: LogQuery): String
22
+
23
+ suspend fun destroy()
24
+
25
+ /** Multipart upload; [paramsJson] is JSON object text. */
26
+ suspend fun upload(url: String, headers: Map<String, String>, paramsJson: String?): HttpResult
27
+
28
+ /** Builds an `ACTION_SEND` intent with the gzipped log attached via [LogFileProvider]. */
29
+ suspend fun prepareEmail(email: String, subject: String?): Intent
30
+ }