feat: Add local SQLite analytics tracking
- 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user