From cef6ab1c6a104c9e35a8fd259dbf0dc3dba3286d Mon Sep 17 00:00:00 2001 From: Spirit Date: Tue, 3 Feb 2026 20:52:10 +0000 Subject: [PATCH] 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. --- app/build.gradle.kts | 6 + .../kioskbrowser/analytics/AnalyticsDao.kt | 39 +++++ .../analytics/AnalyticsDatabase.kt | 31 ++++ .../kioskbrowser/analytics/AnalyticsEvent.kt | 29 ++++ .../analytics/AnalyticsManager.kt | 155 ++++++++++++++++++ .../hobbs/kioskbrowser/AnalyticsEventTest.kt | 110 +++++++++++++ gradle/libs.versions.toml | 6 + 7 files changed, 376 insertions(+) create mode 100644 app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDao.kt create mode 100644 app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDatabase.kt create mode 100644 app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsEvent.kt create mode 100644 app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsManager.kt create mode 100644 app/src/test/kotlin/farm/hobbs/kioskbrowser/AnalyticsEventTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26252c7..732b66b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) } android { @@ -47,6 +48,11 @@ dependencies { implementation(libs.androidx.webkit) 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) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDao.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDao.kt new file mode 100644 index 0000000..84d2db6 --- /dev/null +++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDao.kt @@ -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 + + @Query("SELECT * FROM analytics_events WHERE eventType = :type ORDER BY timestamp DESC LIMIT :limit") + suspend fun getByType(type: String, limit: Int = 50): List + + @Query("SELECT * FROM analytics_events WHERE timestamp >= :startTime AND timestamp <= :endTime ORDER BY timestamp DESC") + suspend fun getByTimeRange(startTime: Long, endTime: Long): List + + @Query("SELECT * FROM analytics_events WHERE synced = 0 ORDER BY timestamp ASC LIMIT :limit") + suspend fun getUnsynced(limit: Int = 100): List + + @Query("UPDATE analytics_events SET synced = 1 WHERE id IN (:ids)") + suspend fun markSynced(ids: List) + + @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 +} diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDatabase.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDatabase.kt new file mode 100644 index 0000000..600cf5c --- /dev/null +++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsEvent.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsEvent.kt new file mode 100644 index 0000000..a491e7a --- /dev/null +++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsEvent.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsManager.kt b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsManager.kt new file mode 100644 index 0000000..1ab5e4d --- /dev/null +++ b/app/src/main/kotlin/farm/hobbs/kioskbrowser/analytics/AnalyticsManager.kt @@ -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 { + 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 + } + } + } +} diff --git a/app/src/test/kotlin/farm/hobbs/kioskbrowser/AnalyticsEventTest.kt b/app/src/test/kotlin/farm/hobbs/kioskbrowser/AnalyticsEventTest.kt new file mode 100644 index 0000000..7571b97 --- /dev/null +++ b/app/src/test/kotlin/farm/hobbs/kioskbrowser/AnalyticsEventTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 653a292..36350f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.2.2" kotlin = "1.9.22" +ksp = "1.9.22-1.0.17" coreKtx = "1.12.0" junit = "4.13.2" junitVersion = "1.1.5" @@ -11,6 +12,7 @@ activity = "1.8.2" constraintlayout = "2.1.4" webkit = "1.10.0" datastorePreferences = "1.0.0" +room = "2.6.1" [libraries] 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-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }