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:
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -47,6 +48,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
// Room for local analytics storage
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.2.2"
|
agp = "8.2.2"
|
||||||
kotlin = "1.9.22"
|
kotlin = "1.9.22"
|
||||||
|
ksp = "1.9.22-1.0.17"
|
||||||
coreKtx = "1.12.0"
|
coreKtx = "1.12.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
@@ -11,6 +12,7 @@ activity = "1.8.2"
|
|||||||
constraintlayout = "2.1.4"
|
constraintlayout = "2.1.4"
|
||||||
webkit = "1.10.0"
|
webkit = "1.10.0"
|
||||||
datastorePreferences = "1.0.0"
|
datastorePreferences = "1.0.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -23,7 +25,11 @@ androidx-activity = { group = "androidx.activity", name = "activity", version.re
|
|||||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user