diff --git a/.editorconfig b/.editorconfig index 5abd6a3..2072f44 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,5 +19,10 @@ charset = utf-8 indent_style = tab tab_width = 4 +[*.sq] +charset = utf-8 +indent_style = tab +tab_width = 4 + [*.md] trim_trailing_whitespace = false diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 921e693..ac18264 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,7 @@ plugins { alias(libs.plugins.android.app) alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.sqldelight) } java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) @@ -34,6 +34,14 @@ android { } } +sqldelight { + databases { + create("Database") { + packageName.set("nl.ndat.tvlauncher.data.sqldelight") + } + } +} + dependencies { // System implementation(libs.androidx.core) @@ -44,8 +52,8 @@ dependencies { implementation(libs.timber) // Data - implementation(libs.androidx.room.ktx) - ksp(libs.androidx.room.compiler) + implementation(libs.sqldelight.android) + implementation(libs.sqldelight.coroutines) // UI implementation(libs.androidx.appcompat) diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/LauncherApplication.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/LauncherApplication.kt index 6a1a6d1..c188c09 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/LauncherApplication.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/LauncherApplication.kt @@ -4,7 +4,7 @@ import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory import coil.util.DebugLogger -import nl.ndat.tvlauncher.data.SharedDatabase +import nl.ndat.tvlauncher.data.DatabaseContainer import nl.ndat.tvlauncher.data.repository.AppRepository import nl.ndat.tvlauncher.data.repository.ChannelRepository import nl.ndat.tvlauncher.data.repository.InputRepository @@ -23,13 +23,13 @@ import timber.log.Timber private val launcherModule = module { single { DefaultLauncherHelper(get()) } - single { AppRepository(get(), get(), get(), get()) } + single { AppRepository(get(), get(), get()) } single { AppResolver() } - single { ChannelRepository(get(), get(), get(), get(), get()) } + single { ChannelRepository(get(), get(), get()) } single { ChannelResolver() } - single { InputRepository(get(), get(), get(), get()) } + single { InputRepository(get(), get(), get()) } single { InputResolver() } single { PreferenceRepository() } @@ -37,13 +37,10 @@ private val launcherModule = module { private val databaseModule = module { // Create database(s) - single { SharedDatabase.build(get()) } + single { DatabaseContainer(get()) } // Add DAOs for easy access - single { get().appDao() } - single { get().channelDao() } - single { get().channelProgramDao() } - single { get().inputDao() } + } class LauncherApplication : Application(), ImageLoaderFactory { diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/DatabaseContainer.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/DatabaseContainer.kt new file mode 100644 index 0000000..6cbd9cf --- /dev/null +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/DatabaseContainer.kt @@ -0,0 +1,53 @@ +package nl.ndat.tvlauncher.data + +import android.content.Context +import app.cash.sqldelight.ColumnAdapter +import app.cash.sqldelight.EnumColumnAdapter +import app.cash.sqldelight.TransactionWithReturn +import app.cash.sqldelight.TransactionWithoutReturn +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import nl.ndat.tvlauncher.data.sqldelight.Channel +import nl.ndat.tvlauncher.data.sqldelight.ChannelProgram +import nl.ndat.tvlauncher.data.sqldelight.Database +import nl.ndat.tvlauncher.data.sqldelight.Input + +class DatabaseContainer( + context: Context +) { + companion object { + const val DB_FILE = "data.db" + } + + private val driver = AndroidSqliteDriver(Database.Schema, context, DB_FILE) + private val database = Database( + driver = driver, + ChannelAdapter = Channel.Adapter( + typeAdapter = EnumColumnAdapter(), + ), + ChannelProgramAdapter = ChannelProgram.Adapter( + weightAdapter = IntColumnAdapter(), + typeAdapter = EnumColumnAdapter(), + posterArtAspectRatioAdapter = EnumColumnAdapter(), + lastPlaybackPositionMillisAdapter = IntColumnAdapter(), + durationMillisAdapter = IntColumnAdapter(), + itemCountAdapter = IntColumnAdapter(), + interactionTypeAdapter = EnumColumnAdapter(), + ), + InputAdapter = Input.Adapter( + typeAdapter = EnumColumnAdapter() + ), + ) + + val apps = database.appQueries + val inputs = database.inputQueries + val channels = database.channelQueries + val channelPrograms = database.channelProgramQueries + + fun transaction(body: TransactionWithoutReturn.() -> Unit) = database.transaction { body() } + fun transactionForResult(body: TransactionWithReturn.() -> T) = database.transactionWithResult { body() } +} + +class IntColumnAdapter : ColumnAdapter { + override fun decode(databaseValue: Long): Int = databaseValue.toInt() + override fun encode(value: Int): Long = value.toLong() +} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/SharedDatabase.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/SharedDatabase.kt deleted file mode 100644 index c8bcf79..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/SharedDatabase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package nl.ndat.tvlauncher.data - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import nl.ndat.tvlauncher.data.dao.AppDao -import nl.ndat.tvlauncher.data.dao.ChannelDao -import nl.ndat.tvlauncher.data.dao.ChannelProgramDao -import nl.ndat.tvlauncher.data.dao.InputDao -import nl.ndat.tvlauncher.data.entity.App -import nl.ndat.tvlauncher.data.entity.Channel -import nl.ndat.tvlauncher.data.entity.ChannelProgram -import nl.ndat.tvlauncher.data.entity.Input - -/** - * Primary database of the app. - */ -@Database( - version = 1, - entities = [ - App::class, - Channel::class, - ChannelProgram::class, - Input::class, - ], -) -abstract class SharedDatabase : RoomDatabase() { - companion object { - const val name = "shared" - - fun build(context: Context) = Room - .databaseBuilder(context, SharedDatabase::class.java, name) - // TODO add proper migrations on release - .fallbackToDestructiveMigration() - .build() - } - - abstract fun appDao(): AppDao - abstract fun channelDao(): ChannelDao - abstract fun channelProgramDao(): ChannelProgramDao - abstract fun inputDao(): InputDao -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/AppDao.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/AppDao.kt deleted file mode 100644 index a4378ec..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/AppDao.kt +++ /dev/null @@ -1,32 +0,0 @@ -package nl.ndat.tvlauncher.data.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update -import kotlinx.coroutines.flow.Flow -import nl.ndat.tvlauncher.data.entity.App - -@Dao -interface AppDao { - @Query("SELECT * FROM app") - fun getAll(): Flow> - - @Query("SELECT * FROM app WHERE id = :id LIMIT 1") - suspend fun getById(id: String): App? - - @Query("SELECT * FROM app WHERE packageName = :packageName LIMIT 1") - suspend fun getByPackageName(packageName: String): App? - - @Insert - suspend fun insert(vararg inputs: App) - - @Update - suspend fun update(vararg inputs: App) - - @Query("DELETE FROM app WHERE packageName = :packageName") - suspend fun removeByPackageName(packageName: String) - - @Query("DELETE FROM app WHERE id NOT IN (:ids)") - suspend fun removeNotIn(ids: List) -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelDao.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelDao.kt deleted file mode 100644 index 5ba0ddc..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelDao.kt +++ /dev/null @@ -1,27 +0,0 @@ -package nl.ndat.tvlauncher.data.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update -import kotlinx.coroutines.flow.Flow -import nl.ndat.tvlauncher.data.entity.Channel -import nl.ndat.tvlauncher.data.model.ChannelType - -@Dao -interface ChannelDao { - @Query("SELECT * FROM channel") - fun getAll(): Flow> - - @Query("SELECT * FROM channel WHERE id = :id LIMIT 1") - suspend fun getById(id: String): Channel? - - @Insert - suspend fun insert(vararg channels: Channel) - - @Update - suspend fun update(vararg channels: Channel) - - @Query("DELETE FROM channel WHERE type = :type AND id NOT IN (:ids)") - suspend fun removeNotIn(type: ChannelType, ids: List) -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelProgramDao.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelProgramDao.kt deleted file mode 100644 index 7d09deb..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/ChannelProgramDao.kt +++ /dev/null @@ -1,29 +0,0 @@ -package nl.ndat.tvlauncher.data.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update -import kotlinx.coroutines.flow.Flow -import nl.ndat.tvlauncher.data.entity.ChannelProgram - -@Dao -interface ChannelProgramDao { - @Query("SELECT * FROM channel_program") - fun getAll(): Flow> - - @Query("SELECT * FROM channel_program WHERE id = :id LIMIT 1") - suspend fun getById(id: String): ChannelProgram? - - @Query("SELECT * FROM channel_program WHERE channelId = :channelId") - fun getByChannel(channelId: String): Flow> - - @Insert - suspend fun insert(vararg channelPrograms: ChannelProgram) - - @Update - suspend fun update(vararg channelPrograms: ChannelProgram) - - @Query("DELETE FROM channel_program WHERE channelId = :channelId AND id NOT IN (:ids)") - suspend fun removeNotIn(channelId: String, ids: List) -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/InputDao.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/InputDao.kt deleted file mode 100644 index 2b36c2c..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/dao/InputDao.kt +++ /dev/null @@ -1,29 +0,0 @@ -package nl.ndat.tvlauncher.data.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update -import kotlinx.coroutines.flow.Flow -import nl.ndat.tvlauncher.data.entity.Input - -@Dao -interface InputDao { - @Query("SELECT * FROM input") - fun getAll(): Flow> - - @Query("SELECT * FROM input WHERE id = :id LIMIT 1") - suspend fun getById(id: String): Input? - - @Insert - suspend fun insert(vararg inputs: Input) - - @Update - suspend fun update(vararg inputs: Input) - - @Query("DELETE FROM input WHERE packageName = :packageName") - suspend fun removeByPackageName(packageName: String) - - @Query("DELETE FROM input WHERE id NOT IN (:ids)") - suspend fun removeNotIn(ids: List) -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/App.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/App.kt deleted file mode 100644 index a3b9cc5..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/App.kt +++ /dev/null @@ -1,18 +0,0 @@ -package nl.ndat.tvlauncher.data.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity( - tableName = "app", -) -data class App( - @PrimaryKey var id: String, - - val displayName: String, - @ColumnInfo(index = true) val packageName: String, - - val launchIntentUriDefault: String?, - val launchIntentUriLeanback: String?, -) diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Channel.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Channel.kt deleted file mode 100644 index 379311a..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Channel.kt +++ /dev/null @@ -1,19 +0,0 @@ -package nl.ndat.tvlauncher.data.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import nl.ndat.tvlauncher.data.model.ChannelType - -@Entity( - tableName = "channel", -) -data class Channel( - @PrimaryKey var id: String, - @ColumnInfo(index = true) val type: ChannelType, - val channelId: Long, - val displayName: String, - val description: String?, - @ColumnInfo(index = true) val packageName: String, - val appLinkIntentUri: String?, -) diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/ChannelProgram.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/ChannelProgram.kt deleted file mode 100644 index bdf03c5..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/ChannelProgram.kt +++ /dev/null @@ -1,48 +0,0 @@ -package nl.ndat.tvlauncher.data.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import nl.ndat.tvlauncher.data.model.ChannelProgramAspectRatio -import nl.ndat.tvlauncher.data.model.ChannelProgramInteractionType -import nl.ndat.tvlauncher.data.model.ChannelProgramType - -@Entity( - tableName = "channel_program", - foreignKeys = [ - ForeignKey( - entity = Channel::class, - parentColumns = ["id"], - childColumns = ["channelId"], - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - ) - ], -) -data class ChannelProgram( - @PrimaryKey var id: String, - @ColumnInfo(index = true) val channelId: String, - @ColumnInfo(index = true) val packageName: String, - val weight: Int, - @ColumnInfo(index = true) val type: ChannelProgramType?, - val posterArtUri: String?, - val posterArtAspectRatio: ChannelProgramAspectRatio?, - val lastPlaybackPositionMillis: Int?, - val durationMillis: Int?, - val releaseDate: String?, - val itemCount: Int?, - val interactionType: ChannelProgramInteractionType?, - val interactionCount: Long, - val author: String?, - val genre: String?, - val live: Boolean, - val startTimeUtcMillis: Long, - val endTimeUtcMillis: Long, - val title: String?, - val episodeTitle: String?, - val seasonNumber: String?, - val episodeNumber: String?, - val description: String?, - val intentUri: String?, -) diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Input.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Input.kt deleted file mode 100644 index 9a11ab4..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/entity/Input.kt +++ /dev/null @@ -1,19 +0,0 @@ -package nl.ndat.tvlauncher.data.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey -import nl.ndat.tvlauncher.data.model.InputType - -@Entity( - tableName = "input", -) -data class Input( - @PrimaryKey var id: String, - val inputId: String, - - val displayName: String, - val packageName: String?, - val type: InputType, - - val switchIntentUri: String?, -) diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/queryExtensions.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/queryExtensions.kt new file mode 100644 index 0000000..a4a8f04 --- /dev/null +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/queryExtensions.kt @@ -0,0 +1,7 @@ +package nl.ndat.tvlauncher.data + +import app.cash.sqldelight.Query +import app.cash.sqldelight.coroutines.asFlow +import kotlinx.coroutines.flow.map + +fun Query.executeAsListFlow() = asFlow().map { it.executeAsList() } diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/AppRepository.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/AppRepository.kt index e5d3cdf..6a7b1a4 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/AppRepository.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/AppRepository.kt @@ -1,32 +1,33 @@ package nl.ndat.tvlauncher.data.repository import android.content.Context -import nl.ndat.tvlauncher.data.SharedDatabase -import nl.ndat.tvlauncher.data.dao.AppDao -import nl.ndat.tvlauncher.data.entity.App +import nl.ndat.tvlauncher.data.DatabaseContainer +import nl.ndat.tvlauncher.data.executeAsListFlow import nl.ndat.tvlauncher.data.resolver.AppResolver -import nl.ndat.tvlauncher.util.withSingleTransaction +import nl.ndat.tvlauncher.data.sqldelight.App class AppRepository( private val context: Context, private val appResolver: AppResolver, - private val database: SharedDatabase, - private val appDao: AppDao, + private val database: DatabaseContainer, ) { - private suspend fun commitApps(apps: Collection) = database.withSingleTransaction { + private suspend fun commitApps(apps: Collection) = database.transaction { // Remove missing apps from database val currentIds = apps.map { it.id } - appDao.removeNotIn(currentIds) + database.apps.removeNotIn(currentIds) // Upsert apps apps.map { app -> commitApp(app) } } - private suspend fun commitApp(app: App) { - val current = appDao.getById(app.id) - - if (current != null) appDao.update(app) - else appDao.insert(app) + private fun commitApp(app: App) { + database.apps.upsert( + displayName = app.displayName, + packageName = app.packageName, + launchIntentUriDefault = app.launchIntentUriDefault, + launchIntentUriLeanback = app.launchIntentUriLeanback, + id = app.id + ) } suspend fun refreshAllApplications() { @@ -37,10 +38,10 @@ class AppRepository( suspend fun refreshApplication(packageName: String) { val app = appResolver.getApplication(context, packageName) - if (app == null) appDao.removeByPackageName(packageName) + if (app == null) database.apps.removeByPackageName(packageName) else commitApp(app) } - fun getApps() = appDao.getAll() - suspend fun getByPackageName(packageName: String) = appDao.getByPackageName(packageName) + fun getApps() = database.apps.getAll().executeAsListFlow() + suspend fun getByPackageName(packageName: String) = database.apps.getByPackageName(packageName).executeAsOneOrNull() } diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/ChannelRepository.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/ChannelRepository.kt index a0c727e..e512a62 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/ChannelRepository.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/ChannelRepository.kt @@ -1,56 +1,79 @@ package nl.ndat.tvlauncher.data.repository import android.content.Context -import nl.ndat.tvlauncher.data.SharedDatabase -import nl.ndat.tvlauncher.data.dao.ChannelDao -import nl.ndat.tvlauncher.data.dao.ChannelProgramDao -import nl.ndat.tvlauncher.data.entity.Channel -import nl.ndat.tvlauncher.data.entity.ChannelProgram +import nl.ndat.tvlauncher.data.DatabaseContainer +import nl.ndat.tvlauncher.data.executeAsListFlow import nl.ndat.tvlauncher.data.model.ChannelType import nl.ndat.tvlauncher.data.resolver.ChannelResolver -import nl.ndat.tvlauncher.util.withSingleTransaction +import nl.ndat.tvlauncher.data.sqldelight.Channel +import nl.ndat.tvlauncher.data.sqldelight.ChannelProgram class ChannelRepository( private val context: Context, private val channelResolver: ChannelResolver, - private val database: SharedDatabase, - private val channelDao: ChannelDao, - private val channelProgramDao: ChannelProgramDao, + private val database: DatabaseContainer, ) { private suspend fun commitChannels(type: ChannelType, channels: Collection) = - database.withSingleTransaction { + database.transaction { // Remove missing channels from database val currentIds = channels.map { it.id } - channelDao.removeNotIn(type, currentIds) + database.channels.removeNotIn(type, currentIds) // Upsert channels channels.map { channel -> commitChannel(channel) } } - private suspend fun commitChannel(channel: Channel) { - val current = channelDao.getById(channel.id) - - if (current != null) channelDao.update(channel) - else channelDao.insert(channel) + private fun commitChannel(channel: Channel) { + database.channels.upsert( + id = channel.id, + type = channel.type, + channelId = channel.channelId, + displayName = channel.displayName, + description = channel.description, + packageName = channel.packageName, + appLinkIntentUri = channel.appLinkIntentUri, + ) } private suspend fun commitChannelPrograms( channelId: String, programs: Collection, - ) = database.withSingleTransaction { + ) = database.transaction { // Remove missing channels from database val currentIds = programs.map { it.id } - channelProgramDao.removeNotIn(channelId, currentIds) + database.channelPrograms.removeNotIn(channelId, currentIds) // Upsert channels programs.map { program -> commitChannelProgram(program) } } - private suspend fun commitChannelProgram(program: ChannelProgram) { - val current = channelProgramDao.getById(program.id) - - if (current != null) channelProgramDao.update(program) - else channelProgramDao.insert(program) + private fun commitChannelProgram(program: ChannelProgram) { + database.channelPrograms.upsert( + id = program.id, + channelId = program.channelId, + packageName = program.packageName, + weight = program.weight, + type = program.type, + posterArtUri = program.posterArtUri, + posterArtAspectRatio = program.posterArtAspectRatio, + lastPlaybackPositionMillis = program.lastPlaybackPositionMillis, + durationMillis = program.durationMillis, + releaseDate = program.releaseDate, + itemCount = program.itemCount, + interactionType = program.interactionType, + interactionCount = program.interactionCount, + author = program.author, + genre = program.genre, + live = program.live, + startTimeUtcMillis = program.startTimeUtcMillis, + endTimeUtcMillis = program.endTimeUtcMillis, + title = program.title, + episodeTitle = program.episodeTitle, + seasonNumber = program.seasonNumber, + episodeNumber = program.episodeNumber, + description = program.description, + intentUri = program.intentUri, + ) } suspend fun refreshAllChannels() { @@ -86,6 +109,6 @@ class ChannelRepository( commitChannelPrograms(channel.id, programs) } - fun getChannels() = channelDao.getAll() - fun getProgramsByChannel(channel: Channel) = channelProgramDao.getByChannel(channel.id) + fun getChannels() = database.channels.getAll().executeAsListFlow() + fun getProgramsByChannel(channel: Channel) = database.channelPrograms.getByChannel(channel.id).executeAsListFlow() } diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/InputRepository.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/InputRepository.kt index 47cf8da..3613b80 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/InputRepository.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/repository/InputRepository.kt @@ -1,32 +1,34 @@ package nl.ndat.tvlauncher.data.repository import android.content.Context -import nl.ndat.tvlauncher.data.SharedDatabase -import nl.ndat.tvlauncher.data.dao.InputDao -import nl.ndat.tvlauncher.data.entity.Input +import nl.ndat.tvlauncher.data.DatabaseContainer +import nl.ndat.tvlauncher.data.executeAsListFlow import nl.ndat.tvlauncher.data.resolver.InputResolver -import nl.ndat.tvlauncher.util.withSingleTransaction +import nl.ndat.tvlauncher.data.sqldelight.Input class InputRepository( private val context: Context, private val inputResolver: InputResolver, - private val database: SharedDatabase, - private val inputDao: InputDao, + private val database: DatabaseContainer ) { - private suspend fun commitInputs(inputs: Collection) = database.withSingleTransaction { + private suspend fun commitInputs(inputs: Collection) = database.transaction { // Remove missing inputs from database val currentIds = inputs.map { it.id } - inputDao.removeNotIn(currentIds) + database.inputs.removeNotIn(currentIds) // Upsert inputs inputs.map { input -> commitInput(input) } } - private suspend fun commitInput(input: Input) { - val current = inputDao.getById(input.id) - - if (current != null) inputDao.update(input) - else inputDao.insert(input) + private fun commitInput(input: Input) { + database.inputs.upsert( + id = input.id, + inputId = input.id, + displayName = input.displayName, + packageName = input.packageName, + type = input.type, + switchIntentUri = input.switchIntentUri + ) } suspend fun refreshAllInputs() { @@ -34,5 +36,5 @@ class InputRepository( commitInputs(inputs) } - fun getInputs() = inputDao.getAll() + fun getInputs() = database.inputs.getAll().executeAsListFlow() } diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/AppResolver.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/AppResolver.kt index 321031d..a0d6d7f 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/AppResolver.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/AppResolver.kt @@ -6,7 +6,7 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.ResolveInfoFlags import android.content.pm.ResolveInfo import android.os.Build -import nl.ndat.tvlauncher.data.entity.App +import nl.ndat.tvlauncher.data.sqldelight.App class AppResolver { companion object { diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/ChannelResolver.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/ChannelResolver.kt index 9329b63..f397d8c 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/ChannelResolver.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/ChannelResolver.kt @@ -1,5 +1,6 @@ package nl.ndat.tvlauncher.data.resolver +import android.annotation.SuppressLint import android.content.ContentResolver import android.content.Context import android.database.Cursor @@ -11,14 +12,15 @@ import androidx.tvprovider.media.tv.PreviewChannel import androidx.tvprovider.media.tv.PreviewProgram import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.WatchNextProgram -import nl.ndat.tvlauncher.data.entity.Channel -import nl.ndat.tvlauncher.data.entity.ChannelProgram import nl.ndat.tvlauncher.data.model.ChannelProgramAspectRatio import nl.ndat.tvlauncher.data.model.ChannelProgramInteractionType import nl.ndat.tvlauncher.data.model.ChannelProgramType import nl.ndat.tvlauncher.data.model.ChannelType +import nl.ndat.tvlauncher.data.sqldelight.Channel +import nl.ndat.tvlauncher.data.sqldelight.ChannelProgram import timber.log.Timber +@SuppressLint("RestrictedApi") class ChannelResolver { companion object { const val CHANNEL_ID_PREFIX = "channel:" diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/InputResolver.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/InputResolver.kt index fe38777..9b01b73 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/InputResolver.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/data/resolver/InputResolver.kt @@ -4,7 +4,7 @@ import android.content.Context import android.media.tv.TvInputInfo import android.media.tv.TvInputManager import androidx.core.content.getSystemService -import nl.ndat.tvlauncher.data.entity.Input +import nl.ndat.tvlauncher.data.sqldelight.Input import nl.ndat.tvlauncher.util.createSwitchIntent import nl.ndat.tvlauncher.util.getInputType import nl.ndat.tvlauncher.util.loadPreferredLabel diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/AppCard.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/AppCard.kt index 526a7b4..0680faa 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/AppCard.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/AppCard.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import nl.ndat.tvlauncher.R -import nl.ndat.tvlauncher.data.entity.App +import nl.ndat.tvlauncher.data.sqldelight.App import nl.ndat.tvlauncher.ui.indication.FocusScaleIndication import nl.ndat.tvlauncher.util.createDrawable import nl.ndat.tvlauncher.util.ifElse diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/ChannelProgramCard.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/ChannelProgramCard.kt index 32dd0d7..693d27a 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/ChannelProgramCard.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/card/ChannelProgramCard.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import nl.ndat.tvlauncher.R -import nl.ndat.tvlauncher.data.entity.ChannelProgram +import nl.ndat.tvlauncher.data.sqldelight.ChannelProgram import nl.ndat.tvlauncher.ui.indication.FocusScaleIndication import nl.ndat.tvlauncher.util.ifElse diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/row/ChannelProgramCardRow.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/row/ChannelProgramCardRow.kt index 310f493..04a2ff4 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/row/ChannelProgramCardRow.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/ui/component/row/ChannelProgramCardRow.kt @@ -10,11 +10,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.tv.foundation.lazy.list.items import nl.ndat.tvlauncher.R -import nl.ndat.tvlauncher.data.entity.App -import nl.ndat.tvlauncher.data.entity.Channel import nl.ndat.tvlauncher.data.model.ChannelType import nl.ndat.tvlauncher.data.repository.AppRepository import nl.ndat.tvlauncher.data.repository.ChannelRepository +import nl.ndat.tvlauncher.data.sqldelight.App +import nl.ndat.tvlauncher.data.sqldelight.Channel import nl.ndat.tvlauncher.ui.component.card.ChannelProgramCard import org.koin.compose.rememberKoinInject diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/util/DatabaseExtensions.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/util/DatabaseExtensions.kt deleted file mode 100644 index 21d6e52..0000000 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/util/DatabaseExtensions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package nl.ndat.tvlauncher.util - -import androidx.room.RoomDatabase -import androidx.room.withTransaction - -suspend fun RoomDatabase.withSingleTransaction(body: suspend () -> Unit) { - if (inTransaction()) body() - else withTransaction { body() } -} diff --git a/app/src/main/kotlin/nl/ndat/tvlauncher/util/TileExtensions.kt b/app/src/main/kotlin/nl/ndat/tvlauncher/util/TileExtensions.kt index 809a550..27f78a7 100644 --- a/app/src/main/kotlin/nl/ndat/tvlauncher/util/TileExtensions.kt +++ b/app/src/main/kotlin/nl/ndat/tvlauncher/util/TileExtensions.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Drawable -import nl.ndat.tvlauncher.data.entity.App +import nl.ndat.tvlauncher.data.sqldelight.App fun App.createDrawable(context: Context): Drawable { val packageManager = context.packageManager diff --git a/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/App.sq b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/App.sq new file mode 100644 index 0000000..cbe5d4f --- /dev/null +++ b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/App.sq @@ -0,0 +1,48 @@ +CREATE TABLE App ( + id TEXT NOT NULL PRIMARY KEY, + displayName TEXT NOT NULL, + packageName TEXT NOT NULL, + + launchIntentUriDefault TEXT, + launchIntentUriLeanback TEXT +); + +CREATE INDEX appPackageName ON App(packageName); + +getAll: +SELECT * FROM App; + +getById: +SELECT * FROM App WHERE id = :id LIMIT 1; + +getByPackageName: +SELECT * FROM App WHERE packageName = :packageName; + +upsert { + UPDATE App SET + displayName = :displayName, + packageName = :packageName, + launchIntentUriDefault = :launchIntentUriDefault, + launchIntentUriLeanback = :launchIntentUriLeanback + WHERE id = :id; + + INSERT OR IGNORE INTO App ( + id, + displayName, + packageName, + launchIntentUriDefault, + launchIntentUriLeanback + ) VALUES ( + :id, + :displayName, + :packageName, + :launchIntentUriDefault, + :launchIntentUriLeanback + ); +} + +removeByPackageName: +DELETE FROM App WHERE packageName = :packageName; + +removeNotIn: +DELETE FROM App WHERE id NOT IN :ids; diff --git a/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Channel.sq b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Channel.sq new file mode 100644 index 0000000..ed04057 --- /dev/null +++ b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Channel.sq @@ -0,0 +1,53 @@ +import kotlin.Long; +import nl.ndat.tvlauncher.data.model.ChannelType; + +CREATE TABLE Channel ( + id TEXT NOT NULL PRIMARY KEY, + type TEXT AS ChannelType NOT NULL, + channelId INTEGER NOT NULL, + displayName TEXT NOT NULL, + description TEXT, + packageName TEXT NOT NULL, + appLinkIntentUri TEXT +); + +CREATE INDEX channelType ON Channel(type); +CREATE INDEX channelPackageName ON Channel(packageName); + +getAll: +SELECT * FROM Channel; + +getById: +SELECT * FROM Channel WHERE id = :id LIMIT 1; + +upsert { + UPDATE Channel SET + type = :type, + channelId = :channelId, + displayName = :displayName, + description = :description, + packageName = :packageName, + appLinkIntentUri = :appLinkIntentUri + WHERE id = :id; + + INSERT OR IGNORE INTO Channel ( + id, + type, + channelId, + displayName, + description, + packageName, + appLinkIntentUri + ) VALUES ( + :id, + :type, + :channelId, + :displayName, + :description, + :packageName, + :appLinkIntentUri + ); +} + +removeNotIn: +DELETE FROM Channel WHERE type = :type AND id NOT IN :ids; diff --git a/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/ChannelProgram.sq b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/ChannelProgram.sq new file mode 100644 index 0000000..3edc509 --- /dev/null +++ b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/ChannelProgram.sq @@ -0,0 +1,130 @@ +import kotlin.Boolean; +import kotlin.Int; +import kotlin.Long; +import nl.ndat.tvlauncher.data.model.ChannelProgramAspectRatio; +import nl.ndat.tvlauncher.data.model.ChannelProgramInteractionType; +import nl.ndat.tvlauncher.data.model.ChannelProgramType; +import nl.ndat.tvlauncher.data.model.ChannelType; + +CREATE TABLE ChannelProgram ( + id TEXT NOT NULL PRIMARY KEY, + channelId TEXT NOT NULL, + packageName TEXT NOT NULL, + weight INTEGER AS Int NOT NULL, + type TEXT AS ChannelProgramType, + posterArtUri TEXT, + posterArtAspectRatio TEXT AS ChannelProgramAspectRatio, + lastPlaybackPositionMillis INTEGER AS Int, + durationMillis INTEGER AS Int, + releaseDate TEXT, + itemCount INTEGER AS Int, + interactionType TEXT AS ChannelProgramInteractionType, + interactionCount INTEGER AS Long, + author TEXT, + genre TEXT, + live INTEGER AS Boolean, + startTimeUtcMillis INTEGER AS Long, + endTimeUtcMillis INTEGER AS Long, + title TEXT, + episodeTitle TEXT, + seasonNumber TEXT, + episodeNumber TEXT, + description TEXT, + intentUri TEXT +); + +CREATE INDEX channelProgramChannelId ON ChannelProgram(channelId); +CREATE INDEX channelProgramPackageName ON ChannelProgram(packageName); +CREATE INDEX channelProgramType ON ChannelProgram(type); + +getAll: +SELECT * FROM ChannelProgram; + +getById: +SELECT * FROM ChannelProgram WHERE id = :id LIMIT 1; + +getByChannel: +SELECT * FROM ChannelProgram WHERE channelId = :channelId; + +upsert { + UPDATE ChannelProgram SET + channelId = :channelId, + packageName = :packageName, + weight = :weight, + type = :type, + posterArtUri = :posterArtUri, + posterArtAspectRatio = :posterArtAspectRatio, + lastPlaybackPositionMillis = :lastPlaybackPositionMillis, + durationMillis = :durationMillis, + releaseDate = :releaseDate, + itemCount = :itemCount, + interactionType = :interactionType, + interactionCount = :interactionCount, + author = :author, + genre = :genre, + live = :live, + startTimeUtcMillis = :startTimeUtcMillis, + endTimeUtcMillis = :endTimeUtcMillis, + title = :title, + episodeTitle = :episodeTitle, + seasonNumber = :seasonNumber, + episodeNumber = :episodeNumber, + description = :description, + intentUri = :intentUri + WHERE id = :id; + + INSERT OR IGNORE INTO ChannelProgram ( + id, + channelId, + packageName, + weight, + type, + posterArtUri, + posterArtAspectRatio, + lastPlaybackPositionMillis, + durationMillis, + releaseDate, + itemCount, + interactionType, + interactionCount, + author, + genre, + live, + startTimeUtcMillis, + endTimeUtcMillis, + title, + episodeTitle, + seasonNumber, + episodeNumber, + description, + intentUri + ) VALUES ( + :id, + :channelId, + :packageName, + :weight, + :type, + :posterArtUri, + :posterArtAspectRatio, + :lastPlaybackPositionMillis, + :durationMillis, + :releaseDate, + :itemCount, + :interactionType, + :interactionCount, + :author, + :genre, + :live, + :startTimeUtcMillis, + :endTimeUtcMillis, + :title, + :episodeTitle, + :seasonNumber, + :episodeNumber, + :description, + :intentUri + ); +} + +removeNotIn: +DELETE FROM ChannelProgram WHERE channelId = :channelId AND id NOT IN :ids; diff --git a/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Input.sq b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Input.sq new file mode 100644 index 0000000..3b0282c --- /dev/null +++ b/app/src/main/sqldelight/nl/ndat/tvlauncher/data/sqldelight/Input.sq @@ -0,0 +1,50 @@ +import nl.ndat.tvlauncher.data.model.InputType; + +CREATE TABLE Input ( + id TEXT NOT NULL PRIMARY KEY, + inputId TEXT NOT NULL, + + displayName TEXT NOT NULL, + packageName TEXT, + type TEXT AS InputType NOT NULL, + + switchIntentUri TEXT +); + +getAll: +SELECT * FROM Input; + +getById: +SELECT * FROM Input WHERE id = :id LIMIT 1; + +upsert { + UPDATE Input SET + inputId = :inputId, + displayName = :displayName, + packageName = :packageName, + type = :type, + switchIntentUri = :switchIntentUri + WHERE id = :id; + + INSERT OR IGNORE INTO Input ( + id, + inputId, + displayName, + packageName, + type, + switchIntentUri + ) VALUES ( + :id, + :inputId, + :displayName, + :packageName, + :type, + :switchIntentUri + ); +} + +removeByPackageName: +DELETE FROM Input WHERE packageName = :packageName; + +removeNotIn: +DELETE FROM Input WHERE id NOT IN :ids; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de2ce53..308fe39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,20 +8,19 @@ androidx-compose-material3 = "1.2.0-rc01" androidx-compose-ui = "1.6.0" androidx-core = "1.12.0" androidx-core-role = "1.0.0" -androidx-room = "2.6.1" androidx-tv = "1.0.0-alpha10" androidx-tvprovider = "1.1.0-alpha01" coil = "2.5.0" koin-android = "3.5.3" koin-compose = "3.5.3" kotlin = "1.9.22" -kotlin-ksp = "1.9.22-1.0.17" +sqldelight = "2.0.1" timber = "5.0.1" [plugins] android-app = { id = "com.android.application", version.ref = "android-plugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -33,12 +32,12 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core-role = { module = "androidx.core:core-role", version.ref = "androidx-core-role" } -androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } -androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } androidx-tv-foundation = { module = "androidx.tv:tv-foundation", version.ref = "androidx-tv" } androidx-tv-material = { module = "androidx.tv:tv-material", version.ref = "androidx.tv" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "androidx-tvprovider" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin-android" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin-compose" } +sqldelight-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }