feat: Add local SQLite analytics tracking
CI / build (push) Has been cancelled
CI / lint (push) Has been cancelled

- AnalyticsEvent: Data class for tracked events
- AnalyticsDao: Room DAO with CRUD operations
- AnalyticsDatabase: Room database singleton
- AnalyticsManager: High-level API for logging events
- Unit tests for AnalyticsEvent

Event types: page_view, session_start, session_end,
idle_reset, admin_access, config_sync, signage_rotate

Supports JSON/CSV export for admin panel integration.
This commit is contained in:
2026-02-03 20:52:10 +00:00
parent 58f960ee2d
commit cef6ab1c6a
7 changed files with 376 additions and 0 deletions
@@ -0,0 +1,39 @@
package farm.hobbs.kioskbrowser.analytics
import androidx.room.*
/**
* Data Access Object for analytics events.
*/
@Dao
interface AnalyticsDao {
@Insert
suspend fun insert(event: AnalyticsEvent): Long
@Query("SELECT * FROM analytics_events ORDER BY timestamp DESC LIMIT :limit")
suspend fun getRecent(limit: Int = 50): List<AnalyticsEvent>
@Query("SELECT * FROM analytics_events WHERE eventType = :type ORDER BY timestamp DESC LIMIT :limit")
suspend fun getByType(type: String, limit: Int = 50): List<AnalyticsEvent>
@Query("SELECT * FROM analytics_events WHERE timestamp >= :startTime AND timestamp <= :endTime ORDER BY timestamp DESC")
suspend fun getByTimeRange(startTime: Long, endTime: Long): List<AnalyticsEvent>
@Query("SELECT * FROM analytics_events WHERE synced = 0 ORDER BY timestamp ASC LIMIT :limit")
suspend fun getUnsynced(limit: Int = 100): List<AnalyticsEvent>
@Query("UPDATE analytics_events SET synced = 1 WHERE id IN (:ids)")
suspend fun markSynced(ids: List<Long>)
@Query("DELETE FROM analytics_events WHERE timestamp < :beforeTime")
suspend fun deleteOlderThan(beforeTime: Long): Int
@Query("DELETE FROM analytics_events")
suspend fun deleteAll(): Int
@Query("SELECT COUNT(*) FROM analytics_events")
suspend fun count(): Int
@Query("SELECT COUNT(*) FROM analytics_events WHERE eventType = :type")
suspend fun countByType(type: String): Int
}
@@ -0,0 +1,31 @@
package farm.hobbs.kioskbrowser.analytics
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
/**
* Room database for storing analytics events locally.
*/
@Database(entities = [AnalyticsEvent::class], version = 1, exportSchema = false)
abstract class AnalyticsDatabase : RoomDatabase() {
abstract fun analyticsDao(): AnalyticsDao
companion object {
@Volatile
private var INSTANCE: AnalyticsDatabase? = null
fun getInstance(context: Context): AnalyticsDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AnalyticsDatabase::class.java,
"kiosk_analytics.db"
).build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,29 @@
package farm.hobbs.kioskbrowser.analytics
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a tracked analytics event.
*/
@Entity(tableName = "analytics_events")
data class AnalyticsEvent(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val deviceId: String,
val eventType: String,
val eventData: String, // JSON payload
val timestamp: Long = System.currentTimeMillis(),
val synced: Boolean = false
) {
companion object {
// Event types
const val PAGE_VIEW = "page_view"
const val SESSION_START = "session_start"
const val SESSION_END = "session_end"
const val IDLE_RESET = "idle_reset"
const val ADMIN_ACCESS = "admin_access"
const val CONFIG_SYNC = "config_sync"
const val SIGNAGE_ROTATE = "signage_rotate"
}
}
@@ -0,0 +1,155 @@
package farm.hobbs.kioskbrowser.analytics
import android.content.Context
import android.provider.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
/**
* Manager for tracking and exporting analytics events.
*/
class AnalyticsManager private constructor(private val context: Context) {
private val database = AnalyticsDatabase.getInstance(context)
private val dao = database.analyticsDao()
private val scope = CoroutineScope(Dispatchers.IO)
private val deviceId: String by lazy {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "unknown"
}
var enabled: Boolean = true
// Convenience methods for each event type
fun logPageView(url: String, durationMs: Long? = null) {
if (!enabled) return
val data = JSONObject().apply {
put("url", url)
durationMs?.let { put("duration_ms", it) }
}
log(AnalyticsEvent.PAGE_VIEW, data.toString())
}
fun logSessionStart() {
if (!enabled) return
log(AnalyticsEvent.SESSION_START, "{}")
}
fun logSessionEnd(reason: String, durationMs: Long) {
if (!enabled) return
val data = JSONObject().apply {
put("reason", reason)
put("duration_ms", durationMs)
}
log(AnalyticsEvent.SESSION_END, data.toString())
}
fun logIdleReset() {
if (!enabled) return
log(AnalyticsEvent.IDLE_RESET, "{}")
}
fun logAdminAccess(success: Boolean) {
if (!enabled) return
val data = JSONObject().apply {
put("success", success)
}
log(AnalyticsEvent.ADMIN_ACCESS, data.toString())
}
fun logConfigSync(success: Boolean, version: Int? = null, error: String? = null) {
if (!enabled) return
val data = JSONObject().apply {
put("success", success)
version?.let { put("version", it) }
error?.let { put("error", it) }
}
log(AnalyticsEvent.CONFIG_SYNC, data.toString())
}
fun logSignageRotate(fromUrl: String, toUrl: String) {
if (!enabled) return
val data = JSONObject().apply {
put("from_url", fromUrl)
put("to_url", toUrl)
}
log(AnalyticsEvent.SIGNAGE_ROTATE, data.toString())
}
private fun log(eventType: String, eventData: String) {
scope.launch {
val event = AnalyticsEvent(
deviceId = deviceId,
eventType = eventType,
eventData = eventData
)
dao.insert(event)
}
}
// Export functions
suspend fun getRecentEvents(limit: Int = 50): List<AnalyticsEvent> {
return dao.getRecent(limit)
}
suspend fun exportToJson(): String {
val events = dao.getRecent(1000)
val jsonArray = org.json.JSONArray()
events.forEach { event ->
val obj = JSONObject().apply {
put("id", event.id)
put("device_id", event.deviceId)
put("event_type", event.eventType)
put("event_data", JSONObject(event.eventData))
put("timestamp", event.timestamp)
}
jsonArray.put(obj)
}
return JSONObject().apply {
put("exported_at", System.currentTimeMillis())
put("device_id", deviceId)
put("event_count", events.size)
put("events", jsonArray)
}.toString(2)
}
suspend fun exportToCsv(): String {
val events = dao.getRecent(1000)
val sb = StringBuilder()
sb.appendLine("id,device_id,event_type,event_data,timestamp")
events.forEach { event ->
val escapedData = event.eventData.replace("\"", "\"\"")
sb.appendLine("${event.id},${event.deviceId},${event.eventType},\"$escapedData\",${event.timestamp}")
}
return sb.toString()
}
suspend fun clearAll(): Int {
return dao.deleteAll()
}
suspend fun getEventCount(): Int {
return dao.count()
}
suspend fun pruneOldEvents(daysToKeep: Int = 30) {
val cutoff = System.currentTimeMillis() - (daysToKeep * 24 * 60 * 60 * 1000L)
dao.deleteOlderThan(cutoff)
}
companion object {
@Volatile
private var INSTANCE: AnalyticsManager? = null
fun getInstance(context: Context): AnalyticsManager {
return INSTANCE ?: synchronized(this) {
val instance = AnalyticsManager(context.applicationContext)
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,110 @@
package farm.hobbs.kioskbrowser
import farm.hobbs.kioskbrowser.analytics.AnalyticsEvent
import org.junit.Assert.*
import org.junit.Test
/**
* Unit tests for AnalyticsEvent data class.
*/
class AnalyticsEventTest {
@Test
fun `event types are defined correctly`() {
assertEquals("page_view", AnalyticsEvent.PAGE_VIEW)
assertEquals("session_start", AnalyticsEvent.SESSION_START)
assertEquals("session_end", AnalyticsEvent.SESSION_END)
assertEquals("idle_reset", AnalyticsEvent.IDLE_RESET)
assertEquals("admin_access", AnalyticsEvent.ADMIN_ACCESS)
assertEquals("config_sync", AnalyticsEvent.CONFIG_SYNC)
assertEquals("signage_rotate", AnalyticsEvent.SIGNAGE_ROTATE)
}
@Test
fun `event has default timestamp`() {
val before = System.currentTimeMillis()
val event = AnalyticsEvent(
deviceId = "test-device",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}"
)
val after = System.currentTimeMillis()
assertTrue(event.timestamp >= before)
assertTrue(event.timestamp <= after)
}
@Test
fun `event has default synced false`() {
val event = AnalyticsEvent(
deviceId = "test-device",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}"
)
assertFalse(event.synced)
}
@Test
fun `event has default id zero`() {
val event = AnalyticsEvent(
deviceId = "test-device",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}"
)
assertEquals(0L, event.id)
}
@Test
fun `event can store json data`() {
val jsonData = """{"url":"https://example.com","duration_ms":5000}"""
val event = AnalyticsEvent(
deviceId = "test-device",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = jsonData
)
assertEquals(jsonData, event.eventData)
}
@Test
fun `event equality works correctly`() {
val event1 = AnalyticsEvent(
id = 1,
deviceId = "device1",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}",
timestamp = 12345L
)
val event2 = AnalyticsEvent(
id = 1,
deviceId = "device1",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}",
timestamp = 12345L
)
assertEquals(event1, event2)
}
@Test
fun `different events are not equal`() {
val event1 = AnalyticsEvent(
id = 1,
deviceId = "device1",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}",
timestamp = 12345L
)
val event2 = AnalyticsEvent(
id = 2,
deviceId = "device1",
eventType = AnalyticsEvent.PAGE_VIEW,
eventData = "{}",
timestamp = 12345L
)
assertNotEquals(event1, event2)
}
}