feat: add Room entities, DAOs, database, and DataStore preferences

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 01:04:55 -04:00
parent 2a9b21b67f
commit b3d22650c7
15 changed files with 650 additions and 1 deletions

View File

@@ -5,6 +5,10 @@ plugins {
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android { android {
namespace = "xyz.cottongin.radio247" namespace = "xyz.cottongin.radio247"
compileSdk = 35 compileSdk = 35

View File

@@ -0,0 +1,321 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "2959b46abce28a2c49ca387873ac9c0d",
"entities": [
{
"tableName": "stations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `playlistId` INTEGER, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `defaultArtworkUrl` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER"
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultArtworkUrl",
"columnName": "defaultArtworkUrl",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_stations_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stations_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `starred` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "metadata_snapshots",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `title` TEXT, `artist` TEXT, `artworkUrl` TEXT, `timestamp` INTEGER NOT NULL, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT"
},
{
"fieldPath": "artist",
"columnName": "artist",
"affinity": "TEXT"
},
{
"fieldPath": "artworkUrl",
"columnName": "artworkUrl",
"affinity": "TEXT"
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_metadata_snapshots_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_stationId` ON `${TABLE_NAME}` (`stationId`)"
},
{
"name": "index_metadata_snapshots_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_snapshots_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "listening_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `stationId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`stationId`) REFERENCES `stations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationId",
"columnName": "stationId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_listening_sessions_stationId",
"unique": false,
"columnNames": [
"stationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_stationId` ON `${TABLE_NAME}` (`stationId`)"
}
],
"foreignKeys": [
{
"table": "stations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"stationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "connection_spans",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sessionId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`sessionId`) REFERENCES `listening_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "sessionId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_connection_spans_sessionId",
"unique": false,
"columnNames": [
"sessionId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_connection_spans_sessionId` ON `${TABLE_NAME}` (`sessionId`)"
}
],
"foreignKeys": [
{
"table": "listening_sessions",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"sessionId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2959b46abce28a2c49ca387873ac9c0d')"
]
}
}

View File

@@ -1,5 +1,17 @@
package xyz.cottongin.radio247 package xyz.cottongin.radio247
import android.app.Application import android.app.Application
import androidx.room.Room
import xyz.cottongin.radio247.data.db.RadioDatabase
import xyz.cottongin.radio247.data.prefs.RadioPreferences
class RadioApplication : Application() class RadioApplication : Application() {
val database: RadioDatabase by lazy {
Room.databaseBuilder(this, RadioDatabase::class.java, "radio_database")
.build()
}
val preferences: RadioPreferences by lazy {
RadioPreferences(this)
}
}

View File

@@ -0,0 +1,22 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import xyz.cottongin.radio247.data.model.ConnectionSpan
import kotlinx.coroutines.flow.Flow
@Dao
interface ConnectionSpanDao {
@Insert
suspend fun insert(span: ConnectionSpan): Long
@Query("UPDATE connection_spans SET endedAt = :endedAt WHERE id = :id")
suspend fun updateEndedAt(id: Long, endedAt: Long)
@Query("SELECT * FROM connection_spans WHERE sessionId = :sessionId ORDER BY startedAt DESC")
fun getBySession(sessionId: Long): Flow<List<ConnectionSpan>>
@Query("SELECT * FROM connection_spans WHERE endedAt IS NULL LIMIT 1")
suspend fun getActiveSpan(): ConnectionSpan?
}

View File

@@ -0,0 +1,22 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import xyz.cottongin.radio247.data.model.ListeningSession
import kotlinx.coroutines.flow.Flow
@Dao
interface ListeningSessionDao {
@Insert
suspend fun insert(session: ListeningSession): Long
@Query("UPDATE listening_sessions SET endedAt = :endedAt WHERE id = :id")
suspend fun updateEndedAt(id: Long, endedAt: Long)
@Query("SELECT * FROM listening_sessions WHERE endedAt IS NULL LIMIT 1")
suspend fun getActiveSession(): ListeningSession?
@Query("SELECT * FROM listening_sessions ORDER BY startedAt DESC LIMIT :limit")
fun getRecentSessions(limit: Int = 50): Flow<List<ListeningSession>>
}

View File

@@ -0,0 +1,22 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import kotlinx.coroutines.flow.Flow
@Dao
interface MetadataSnapshotDao {
@Insert
suspend fun insert(snapshot: MetadataSnapshot): Long
@Query("SELECT * FROM metadata_snapshots WHERE stationId = :stationId ORDER BY timestamp DESC LIMIT :limit")
fun getByStation(stationId: Long, limit: Int = 50): Flow<List<MetadataSnapshot>>
@Query("SELECT * FROM metadata_snapshots ORDER BY timestamp DESC LIMIT :limit")
fun getRecent(limit: Int = 100): Flow<List<MetadataSnapshot>>
@Query("SELECT * FROM metadata_snapshots WHERE artist LIKE '%' || :query || '%' OR title LIKE '%' || :query || '%' ORDER BY timestamp DESC")
fun search(query: String): Flow<List<MetadataSnapshot>>
}

View File

@@ -0,0 +1,33 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import xyz.cottongin.radio247.data.model.Playlist
import kotlinx.coroutines.flow.Flow
@Dao
interface PlaylistDao {
@Query("SELECT * FROM playlists ORDER BY starred DESC, sortOrder ASC")
fun getAllPlaylists(): Flow<List<Playlist>>
@Query("SELECT * FROM playlists WHERE id = :id")
suspend fun getPlaylistById(id: Long): Playlist?
@Insert
suspend fun insert(playlist: Playlist): Long
@Update
suspend fun update(playlist: Playlist)
@Delete
suspend fun delete(playlist: Playlist)
@Query("UPDATE playlists SET sortOrder = :sortOrder WHERE id = :id")
suspend fun updateSortOrder(id: Long, sortOrder: Int)
@Query("UPDATE playlists SET starred = :starred WHERE id = :id")
suspend fun toggleStarred(id: Long, starred: Boolean)
}

View File

@@ -0,0 +1,28 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import xyz.cottongin.radio247.data.model.ConnectionSpan
import xyz.cottongin.radio247.data.model.ListeningSession
import xyz.cottongin.radio247.data.model.MetadataSnapshot
import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
@Database(
entities = [
Station::class,
Playlist::class,
MetadataSnapshot::class,
ListeningSession::class,
ConnectionSpan::class
],
version = 1,
exportSchema = true
)
abstract class RadioDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun playlistDao(): PlaylistDao
abstract fun metadataSnapshotDao(): MetadataSnapshotDao
abstract fun listeningSessionDao(): ListeningSessionDao
abstract fun connectionSpanDao(): ConnectionSpanDao
}

View File

@@ -0,0 +1,39 @@
package xyz.cottongin.radio247.data.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import xyz.cottongin.radio247.data.model.Station
import kotlinx.coroutines.flow.Flow
@Dao
interface StationDao {
@Query("SELECT * FROM stations ORDER BY starred DESC, sortOrder ASC")
fun getAllStations(): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId = :playlistId ORDER BY starred DESC, sortOrder ASC")
fun getStationsByPlaylist(playlistId: Long): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE playlistId IS NULL ORDER BY starred DESC, sortOrder ASC")
fun getUnsortedStations(): Flow<List<Station>>
@Query("SELECT * FROM stations WHERE id = :id")
suspend fun getStationById(id: Long): Station?
@Insert
suspend fun insert(station: Station): Long
@Update
suspend fun update(station: Station)
@Delete
suspend fun delete(station: Station)
@Query("UPDATE stations SET sortOrder = :sortOrder WHERE id = :id")
suspend fun updateSortOrder(id: Long, sortOrder: Int)
@Query("UPDATE stations SET starred = :starred WHERE id = :id")
suspend fun toggleStarred(id: Long, starred: Boolean)
}

View File

@@ -0,0 +1,23 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "connection_spans",
foreignKeys = [ForeignKey(
entity = ListeningSession::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("sessionId")]
)
data class ConnectionSpan(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val startedAt: Long,
val endedAt: Long? = null
)

View File

@@ -0,0 +1,23 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "listening_sessions",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId")]
)
data class ListeningSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val startedAt: Long,
val endedAt: Long? = null
)

View File

@@ -0,0 +1,25 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "metadata_snapshots",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId"), Index("timestamp")]
)
data class MetadataSnapshot(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val title: String? = null,
val artist: String? = null,
val artworkUrl: String? = null,
val timestamp: Long
)

View File

@@ -0,0 +1,12 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "playlists")
data class Playlist(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val sortOrder: Int = 0,
val starred: Boolean = false
)

View File

@@ -0,0 +1,26 @@
package xyz.cottongin.radio247.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "stations",
foreignKeys = [ForeignKey(
entity = Playlist::class,
parentColumns = ["id"],
childColumns = ["playlistId"],
onDelete = ForeignKey.SET_NULL
)],
indices = [Index("playlistId")]
)
data class Station(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val url: String,
val playlistId: Long? = null,
val sortOrder: Int = 0,
val starred: Boolean = false,
val defaultArtworkUrl: String? = null
)

View File

@@ -0,0 +1,37 @@
package xyz.cottongin.radio247.data.prefs
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RadioPreferences(private val context: Context) {
private val dataStore = context.dataStore
val stayConnected: Flow<Boolean> = dataStore.data.map { it[STAY_CONNECTED] ?: false }
val bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] }
suspend fun setStayConnected(value: Boolean) {
dataStore.edit { it[STAY_CONNECTED] = value }
}
suspend fun setBufferMs(value: Int) {
dataStore.edit { it[BUFFER_MS] = value }
}
suspend fun setLastStationId(value: Long) {
dataStore.edit { it[LAST_STATION_ID] = value }
}
companion object {
private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
private val BUFFER_MS = intPreferencesKey("buffer_ms")
private val LAST_STATION_ID = longPreferencesKey("last_station_id")
}
}