@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.
- package/LICENSE +21 -0
- package/README.md +2759 -2
- package/android/build.gradle +106 -0
- package/android/consumer-rules.pro +42 -0
- package/android/src/main/AndroidManifest.xml +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/LocationTrackingPlugin.kt +431 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingListener.kt +52 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/LocationTrackingNative.kt +267 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeListeners.kt +270 -0
- package/android/src/main/java/com/brickssoft/locationtracking/api/NativeThread.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/BridgeServices.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/CallResponder.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/OptionParsers.kt +281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginHandlers.kt +541 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/PluginPermissionHost.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/bridge/WatchRegistry.kt +84 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/Config.kt +231 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigJson.kt +574 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigStore.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/ConfigValidator.kt +170 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/RuntimeStateJson.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/SharedPrefsConfigStore.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/config/State.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/AppDispatchers.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Clock.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Components.kt +217 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Constants.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Errors.kt +27 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/EventBus.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/ForceStopProbe.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Iso8601.kt +63 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/Logger.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/RecordHooks.kt +47 -0
- package/android/src/main/java/com/brickssoft/locationtracking/core/TrackingEvent.kt +64 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqlSupport.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteGeofenceStore.kt +144 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/SqliteLocationStore.kt +215 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/Stores.kt +45 -0
- package/android/src/main/java/com/brickssoft/locationtracking/data/TrackingDatabase.kt +111 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceInfoProvider.kt +104 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DefaultDeviceMonitor.kt +335 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceMonitor.kt +36 -0
- package/android/src/main/java/com/brickssoft/locationtracking/device/DeviceStateMapping.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/DefaultTrackingEngine.kt +1281 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/ElasticDistance.kt +68 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/LocationRequests.kt +74 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/MotionStateMachine.kt +291 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/StationaryRegion.kt +41 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingEngine.kt +60 -0
- package/android/src/main/java/com/brickssoft/locationtracking/engine/TrackingTimers.kt +90 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DefaultGeofenceManager.kt +899 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/DwellTracker.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/GeofenceManager.kt +33 -0
- package/android/src/main/java/com/brickssoft/locationtracking/geofence/PolygonMath.kt +194 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/DefaultHeartbeatScheduler.kt +713 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarmReceiver.kt +69 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatAlarms.kt +100 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatScheduler.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/heartbeat/HeartbeatWindow.kt +179 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/AuthorizationManager.kt +213 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/BodyBuilder.kt +73 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSupport.kt +118 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/HttpSyncer.kt +18 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/OkHttpSyncer.kt +551 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/StrictJson.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/SyncPolicy.kt +109 -0
- package/android/src/main/java/com/brickssoft/locationtracking/http/TemplateRenderer.kt +143 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/FileLogger.kt +461 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFileProvider.kt +9 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogFormatter.kt +148 -0
- package/android/src/main/java/com/brickssoft/locationtracking/logging/LogStore.kt +30 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/DeviceModels.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/GeofenceSpec.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/ModelJson.kt +550 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/Record.kt +106 -0
- package/android/src/main/java/com/brickssoft/locationtracking/model/TrackedLocation.kt +53 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/BackgroundRationaleDialog.kt +85 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/CurrentActivityTracker.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/DefaultPermissionManager.kt +333 -0
- package/android/src/main/java/com/brickssoft/locationtracking/permission/PermissionManager.kt +54 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/DefaultPositionService.kt +277 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionService.kt +39 -0
- package/android/src/main/java/com/brickssoft/locationtracking/position/PositionWatch.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultLocationProcessor.kt +93 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultOdometer.kt +99 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/DefaultRecordFactory.kt +166 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Geo.kt +56 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/KalmanFilter.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/processing/Processing.kt +48 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/Backends.kt +187 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/DefaultProviderFactory.kt +169 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/ProviderPackaging.kt +135 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidActivityBackend.kt +25 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceBackend.kt +181 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidGeofenceReceiver.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidLocationBackend.kt +265 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/AndroidProviderBundle.kt +31 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/android/LocationProviders.kt +98 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityBackend.kt +80 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsActivityReceiver.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsErrors.kt +72 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceBackend.kt +152 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsGeofenceReceiver.kt +78 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsLocationBackend.kt +176 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsProviderBundle.kt +62 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsReceiverSupport.kt +88 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/gms/GmsTasks.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityBackend.kt +71 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsActivityReceiver.kt +57 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsErrors.kt +76 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceBackend.kt +116 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsGeofenceReceiver.kt +67 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsIntents.kt +83 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsLocationBackend.kt +197 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsProviderBundle.kt +55 -0
- package/android/src/main/java/com/brickssoft/locationtracking/provider/hms/HmsTasks.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/record/RecordSink.kt +95 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootCountStore.kt +44 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/BootReceiver.kt +175 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/DefaultServiceController.kt +196 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/LocationTrackingService.kt +319 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationActionReceiver.kt +42 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/NotificationFactory.kt +338 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceCommands.kt +204 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceController.kt +22 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/ServiceDeps.kt +61 -0
- package/android/src/main/java/com/brickssoft/locationtracking/service/WhileInUseStartCheck.kt +115 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DefaultDeviceSettings.kt +79 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/DeviceSettings.kt +21 -0
- package/android/src/main/java/com/brickssoft/locationtracking/settings/OemPowerManagers.kt +198 -0
- package/android/src/main/res/drawable/lt_ic_notification.xml +11 -0
- package/android/src/main/res/values/lt_strings.xml +11 -0
- package/android/src/main/res/xml/lt_file_paths.xml +5 -0
- package/dist/esm/definitions.d.ts +719 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/events.d.ts +50 -0
- package/dist/esm/events.js +52 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +10 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/web/config.d.ts +19 -0
- package/dist/esm/web/config.js +243 -0
- package/dist/esm/web/config.js.map +1 -0
- package/dist/esm/web/device.d.ts +9 -0
- package/dist/esm/web/device.js +90 -0
- package/dist/esm/web/device.js.map +1 -0
- package/dist/esm/web/errors.d.ts +14 -0
- package/dist/esm/web/errors.js +19 -0
- package/dist/esm/web/errors.js.map +1 -0
- package/dist/esm/web/geolocation.d.ts +26 -0
- package/dist/esm/web/geolocation.js +117 -0
- package/dist/esm/web/geolocation.js.map +1 -0
- package/dist/esm/web/location.d.ts +7 -0
- package/dist/esm/web/location.js +70 -0
- package/dist/esm/web/location.js.map +1 -0
- package/dist/esm/web.d.ts +164 -0
- package/dist/esm/web.js +328 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +945 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +948 -0
- package/dist/plugin.js.map +1 -0
- 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
|
+
}
|