Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ dependencies {
implementation("io.coil-kt:coil-svg:2.5.0")
implementation("com.google.zxing:core:3.5.3")

// Local metadata persistence
implementation("androidx.room:room-runtime:2.6.0")
implementation("androidx.room:room-ktx:2.6.0")
ksp("androidx.room:room-compiler:2.6.0")

// Supabase (optional - for cloud sync)
implementation("io.github.jan-tennert.supabase:postgrest-kt:2.0.4")
implementation("io.github.jan-tennert.supabase:gotrue-kt:2.0.4")
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.arflix.tv.util.CrashlyticsProvider
import com.arflix.tv.util.DeviceType
import com.arflix.tv.util.SentryCrashReporter
import com.arflix.tv.util.detectDeviceType
import com.arflix.tv.worker.MediaMetadataRefreshWorker
import com.arflix.tv.worker.TraktSyncWorker
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -132,6 +133,8 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact
runCatching { appUsageAnalyticsRepository.recordAppOpen() }
}

scheduleMediaMetadataRefreshIfNeeded()

// Observe auth state: start realtime on login, stop on logout
appScope.launch {
authRepository.authState.collectLatest { state ->
Expand Down Expand Up @@ -274,6 +277,23 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact
)
}

fun scheduleMediaMetadataRefreshIfNeeded() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()

val refreshRequest = PeriodicWorkRequestBuilder<MediaMetadataRefreshWorker>(24, TimeUnit.HOURS)
.setConstraints(constraints)
.build()

WorkManager.getInstance(this).enqueueUniquePeriodicWork(
MediaMetadataRefreshWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
refreshRequest
)
}

companion object {
lateinit var instance: ArflixApplication
private set
Expand Down
154 changes: 154 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataCache.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.arflix.tv.data.local

import com.arflix.tv.data.model.CastMember
import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class OfflineMetadataCache @Inject constructor(
private val database: OfflineMetadataDatabase
) {

companion object {
const val RECORD_KIND_DETAILS = "details"
const val RECORD_KIND_CAST = "cast"
const val RECORD_KIND_SEARCH = "search"

private val gson = Gson()
private val mediaItemListType = object : TypeToken<List<MediaItem>>() {}.type
private val castMemberListType = object : TypeToken<List<CastMember>>() {}.type
}

private fun metadataKey(mediaType: MediaType, mediaId: Int, kind: String): String {
return "${mediaType.name}_$mediaId_$kind"
}

private fun normalizedSearchQuery(query: String): String {
return URLEncoder.encode(query.trim().lowercase(Locale.ROOT), StandardCharsets.UTF_8.toString())
}

suspend fun getCachedMediaDetails(mediaType: MediaType, mediaId: Int, maxAgeMs: Long): MediaItem? {
val entity = database.offlineMetadataDao().get(
mediaType.name,
mediaId,
RECORD_KIND_DETAILS
) ?: return null
return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) {
gson.fromJson(entity.payloadJson, MediaItem::class.java)
} else {
null
}
}

suspend fun getCachedMediaDetailsStale(mediaType: MediaType, mediaId: Int): MediaItem? {
val entity = database.offlineMetadataDao().get(
mediaType.name,
mediaId,
RECORD_KIND_DETAILS
) ?: return null
return gson.fromJson(entity.payloadJson, MediaItem::class.java)
}

suspend fun putCachedMediaDetails(mediaType: MediaType, mediaId: Int, item: MediaItem) {
val entity = OfflineMetadataEntity(
key = metadataKey(mediaType, mediaId, RECORD_KIND_DETAILS),
mediaType = mediaType.name,
mediaId = mediaId,
recordKind = RECORD_KIND_DETAILS,
payloadJson = gson.toJson(item),
lastUpdatedMillis = System.currentTimeMillis()
)
database.offlineMetadataDao().upsert(entity)
}

suspend fun getCachedCast(mediaType: MediaType, mediaId: Int, maxAgeMs: Long): List<CastMember>? {
val entity = database.offlineMetadataDao().get(
mediaType.name,
mediaId,
RECORD_KIND_CAST
) ?: return null
return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) {
gson.fromJson(entity.payloadJson, castMemberListType)
} else {
null
}
}

suspend fun getCachedCastStale(mediaType: MediaType, mediaId: Int): List<CastMember>? {
val entity = database.offlineMetadataDao().get(
mediaType.name,
mediaId,
RECORD_KIND_CAST
) ?: return null
return gson.fromJson(entity.payloadJson, castMemberListType)
}

suspend fun putCachedCast(mediaType: MediaType, mediaId: Int, cast: List<CastMember>) {
val entity = OfflineMetadataEntity(
key = metadataKey(mediaType, mediaId, RECORD_KIND_CAST),
mediaType = mediaType.name,
mediaId = mediaId,
recordKind = RECORD_KIND_CAST,
payloadJson = gson.toJson(cast),
lastUpdatedMillis = System.currentTimeMillis()
)
database.offlineMetadataDao().upsert(entity)
}

suspend fun getCachedSearchResults(query: String, maxAgeMs: Long): List<MediaItem>? {
val entity = database.offlineSearchDao().get(normalizedSearchQuery(query)) ?: return null
return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) {
gson.fromJson(entity.payloadJson, mediaItemListType)
} else {
null
}
}

suspend fun putCachedSearchResults(query: String, results: List<MediaItem>) {
val entity = OfflineSearchEntity(
query = normalizedSearchQuery(query),
payloadJson = gson.toJson(results),
lastUpdatedMillis = System.currentTimeMillis()
)
database.offlineSearchDao().upsert(entity)
}

suspend fun pruneOldSearchResults(maxAgeMs: Long) {
val staleBefore = System.currentTimeMillis() - maxAgeMs
database.offlineSearchDao().deleteOlderThan(staleBefore)
}

suspend fun getStaleRecordKeys(recordKind: String, maxAgeMs: Long, limit: Int): List<Pair<MediaType, Int>> {
val staleBefore = System.currentTimeMillis() - maxAgeMs
return database.offlineMetadataDao()
.getStaleEntities(recordKind, staleBefore, limit)
.mapNotNull { entity ->
runCatching {
MediaType.valueOf(entity.mediaType) to entity.mediaId
}.getOrNull()
}
}

suspend fun clearAll() {
database.offlineMetadataDao().clearAll()
database.offlineSearchDao().clearAll()
}

suspend fun getCacheStats(): CacheStats {
val metadataCount = database.offlineMetadataDao().count()
val searchCount = database.offlineSearchDao().count()
return CacheStats(metadataCount, searchCount)
}

data class CacheStats(
val metadataCount: Int,
val searchCount: Int
)
}
27 changes: 27 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.arflix.tv.data.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query

@Dao
interface OfflineMetadataDao {
@Query("SELECT * FROM offline_metadata WHERE mediaType = :mediaType AND mediaId = :mediaId AND recordKind = :recordKind LIMIT 1")
suspend fun get(mediaType: String, mediaId: Int, recordKind: String): OfflineMetadataEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: OfflineMetadataEntity)

@Query("DELETE FROM offline_metadata WHERE key = :key")
suspend fun delete(key: String)

@Query("SELECT * FROM offline_metadata WHERE recordKind = :recordKind AND lastUpdatedMillis < :staleBefore LIMIT :limit")
suspend fun getStaleEntities(recordKind: String, staleBefore: Long, limit: Int): List<OfflineMetadataEntity>

@Query("SELECT COUNT(*) FROM offline_metadata")
suspend fun count(): Int

@Query("DELETE FROM offline_metadata")
suspend fun clearAll()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.arflix.tv.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(
entities = [OfflineMetadataEntity::class, OfflineSearchEntity::class],
version = 1,
exportSchema = false
)
abstract class OfflineMetadataDatabase : RoomDatabase() {
abstract fun offlineMetadataDao(): OfflineMetadataDao
abstract fun offlineSearchDao(): OfflineSearchDao
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.arflix.tv.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "offline_metadata")
data class OfflineMetadataEntity(
@PrimaryKey val key: String,
val mediaType: String,
val mediaId: Int,
val recordKind: String,
val payloadJson: String,
val lastUpdatedMillis: Long
)
24 changes: 24 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.arflix.tv.data.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query

@Dao
interface OfflineSearchDao {
@Query("SELECT * FROM offline_search WHERE query = :query LIMIT 1")
suspend fun get(query: String): OfflineSearchEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: OfflineSearchEntity)

@Query("DELETE FROM offline_search WHERE lastUpdatedMillis < :staleBefore")
suspend fun deleteOlderThan(staleBefore: Long)

@Query("SELECT COUNT(*) FROM offline_search")
suspend fun count(): Int

@Query("DELETE FROM offline_search")
suspend fun clearAll()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.arflix.tv.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "offline_search")
data class OfflineSearchEntity(
@PrimaryKey val query: String,
val payloadJson: String,
val lastUpdatedMillis: Long
)
Loading