Dev: Move services into their own package (#214)

Moves service classes into their own files and package for easier
organization and discovery

No user facing changes in the PR
This commit is contained in:
damontecres 2025-11-13 19:39:37 -05:00 committed by GitHub
parent 76b7a33328
commit b7cb6efc4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 259 additions and 233 deletions

View file

@ -0,0 +1,104 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.os.Build
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppUpgradeHandler
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>,
) {
suspend fun run() {
val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0)
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
val newVersion = pkgInfo.versionName!!
val newVersionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkgInfo.longVersionCode
} else {
pkgInfo.versionCode.toLong()
}
if (newVersion != previousVersion || newVersionCode != previousVersionCode) {
Timber.i(
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
)
prefs.edit(true) {
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
putString(VERSION_NAME_CURRENT_KEY, newVersion)
putLong(VERSION_CODE_CURRENT_KEY, newVersionCode)
}
try {
upgradeApp(
context,
Version.Companion.fromString(previousVersion ?: "0.0.0"),
Version.Companion.fromString(newVersion),
appPreferences,
)
} catch (ex: Exception) {
Timber.e(ex, "Exception during app upgrade")
}
}
}
companion object {
const val VERSION_NAME_PREVIOUS_KEY = "version.previous.name"
const val VERSION_CODE_PREVIOUS_KEY = "version.previous.code"
const val VERSION_NAME_CURRENT_KEY = "version.current.name"
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
}
}
suspend fun upgradeApp(
context: Context,
previous: Version,
current: Version,
appPreferences: DataStore<AppPreferences>,
) {
if (previous.isEqualOrBefore(Version.Companion.fromString("0.1.0-2-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
downmixStereo = AppPreference.DownMixStereo.defaultValue
directPlayAss = AppPreference.DirectPlayAss.defaultValue
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.3-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.5-11-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
showClock = AppPreference.ShowClock.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.7-1-g0"))) {
PreferencesViewModel.resetSubtitleSettings(appPreferences)
}
}

View file

@ -0,0 +1,43 @@
package com.github.damontecres.wholphin.services
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.UserItemDataDto
import java.util.UUID
interface FavoriteWatchManager {
suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto
suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto
}
class FavoriteWatchManagerImpl(
private val api: ApiClient,
) : FavoriteWatchManager {
override suspend fun setWatched(
itemId: UUID,
played: Boolean,
): UserItemDataDto =
if (played) {
api.playStateApi.markPlayedItem(itemId).content
} else {
api.playStateApi.markUnplayedItem(itemId).content
}
override suspend fun setFavorite(
itemId: UUID,
favorite: Boolean,
): UserItemDataDto =
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId).content
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content
}
}

View file

@ -0,0 +1,77 @@
package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.ui.nav.Destination
import org.acra.ACRA
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages navigating between pages and manages the app's back stack
*/
@Singleton
class NavigationManager
@Inject
constructor() {
var backStack: MutableList<NavKey> = mutableListOf()
/**
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
*/
fun navigateTo(destination: Destination) {
backStack.add(destination)
log()
}
/**
* Go to the specified [Destination], but reset the back stack to Home first
*/
fun navigateToFromDrawer(destination: Destination) {
goToHome()
if (destination == Destination.ServerList || destination is Destination.UserList) {
backStack.add(destination)
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
} else {
backStack.add(destination)
}
log()
}
/**
* Go to the previous page
*/
fun goBack() {
backStack.removeLastOrNull()
log()
}
/**
* Go all the way back to the home page
*/
fun goToHome() {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
if (backStack[0] !is Destination.Home) {
backStack[0] = Destination.Home()
}
log()
}
/**
* Go all the way back to the home page, and reload it from scratch
*/
fun reloadHome() {
goToHome()
val id = (backStack[0] as Destination.Home).id + 1
backStack[0] = Destination.Home(id)
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.Forest.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
}
}

View file

@ -0,0 +1,34 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PeopleFavorites
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List<Person> =
item.data.people.orEmpty().map { it.id }.chunked(50).let { chunks ->
val favorites =
chunks
.map {
api.itemsApi
.getItems(ids = it)
.content.items
}.flatten()
.associateBy({ it.id }, { it.userData?.isFavorite ?: false })
val people =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
}.orEmpty()
people
}
}

View file

@ -0,0 +1,48 @@
package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
/**
* Observes the activity lifecycle in order to pause/resume/stop playback
*/
@ActivityRetainedScoped
class PlaybackLifecycleObserver
@Inject
constructor(
private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer,
) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) {
wasPlaying = null
}
override fun onResume(owner: LifecycleOwner) {
if (wasPlaying == true) {
playerFactory.currentPlayer?.let {
if (!it.isReleased) it.play()
}
}
}
override fun onPause(owner: LifecycleOwner) {
playerFactory.currentPlayer?.let {
wasPlaying = it.isPlaying
it.pause()
}
themeSongPlayer.stop()
}
override fun onStop(owner: LifecycleOwner) {
if (navigationManager.backStack.lastOrNull() is Destination.Playback) {
navigationManager.goBack()
}
themeSongPlayer.stop()
}
}

View file

@ -0,0 +1,73 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
import androidx.datastore.core.DataStore
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Constructs a [Player] instance for video playback
*/
@Singleton
class PlayerFactory
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>,
) {
@Volatile
var currentPlayer: Player? = null
private set
fun createVideoPlayer(): Player {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
currentPlayer?.release()
}
val extensions =
runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled
Timber.v("extensions=$extensions")
val rendererMode =
when (extensions) {
MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
}
val newPlayer =
ExoPlayer
.Builder(context)
.setRenderersFactory(
DefaultRenderersFactory(context)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode),
).build()
.apply {
playWhenReady = true
}
currentPlayer = newPlayer
return newPlayer
}
}
val Player.isReleased: Boolean
get() {
return when (this) {
is ExoPlayer -> isReleased
else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}")
}
}

View file

@ -0,0 +1,135 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PlaylistCreator
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.Companion.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.Companion.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.Companion.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.Companion.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}
return Playlist(baseItems, 0)
}
suspend fun getPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,
): List<PlaylistInfo?> {
val request =
GetItemsRequest(
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
return TransformList(pager) {
it?.let {
PlaylistInfo(
id = it.id,
name = it.name ?: context.getString(R.string.unknown),
count = it.data.childCount ?: 0,
mediaType = it.data.mediaType,
)
}
}
}
suspend fun createPlaylist(
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser.value?.let { user ->
api.playlistsApi
.createPlaylist(
CreatePlaylistDto(
name = name,
ids = initialItems,
users = listOf(PlaylistUserPermissions(user.id, true)),
isPublic = false,
),
).content.id
.toUUIDOrNull()
}
suspend fun addToPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
}
suspend fun removeFromPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.removeItemFromPlaylist(
playlistId.toServerString(),
listOf(itemId.toServerString()),
)
}
}

View file

@ -0,0 +1,109 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.GeneralCommandMessage
import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import javax.inject.Inject
@ActivityScoped
class ServerEventListener
@Inject
constructor(
@param:ActivityContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) : DefaultLifecycleObserver {
private val activity = (context as AppCompatActivity)
private var listenJob: Job? = null
init {
activity.lifecycle.addObserver(this)
serverRepository.current.observe(activity) {
Timber.d("New user/server: %s", it)
listenJob?.cancel()
if (it != null) {
init(it.server, it.user)
}
}
}
fun init(
server: JellyfinServer?,
user: JellyfinUser?,
) {
if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
(context as AppCompatActivity).lifecycleScope.launchIO {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands =
listOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
),
supportsMediaControl = true,
)
setupListeners()
}
}
}
fun setupListeners() {
serverRepository.currentUser
Timber.v("Subscribing to WebSocket")
listenJob?.cancel()
listenJob =
api.webSocket
.subscribe<GeneralCommandMessage>()
.onEach { message ->
if (message.data?.name in
setOf(
GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
)
) {
val header = message.data?.arguments["Header"]
val text =
message.data?.arguments["Text"] ?: message.data?.arguments["String"]
val toast =
listOfNotNull(header, text)
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
}.launchIn(activity.lifecycleScope)
}
override fun onResume(owner: LifecycleOwner) {
serverRepository.current.value?.let { init(it.server, it.user) }
}
override fun onPause(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
}
override fun onStop(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
}
}

View file

@ -0,0 +1,106 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Simple service to play theme song music
*/
@OptIn(UnstableApi::class)
@Singleton
class ThemeSongPlayer
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val api: ApiClient,
) {
private val player: Player by lazy {
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(authOkHttpClient),
),
).build()
}
suspend fun playThemeFor(
itemId: UUID,
volume: ThemeSongVolume,
): Boolean =
withContext(Dispatchers.IO) {
if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) {
return@withContext false
}
val themeSongs by api.libraryApi.getThemeSongs(itemId)
return@withContext themeSongs.items.randomOrNull()?.let { theme ->
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
theme.id,
container =
listOf(
Codec.Audio.OPUS,
Codec.Audio.MP3,
Codec.Audio.AAC,
Codec.Audio.FLAC,
),
)
Timber.v("Found theme song for $itemId")
withContext(Dispatchers.Main) {
play(volume, url)
}
true
} ?: false
}
private fun play(
volumeLevel: ThemeSongVolume,
url: String,
) {
val volumeLevel =
when (volumeLevel) {
ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED,
-> return
ThemeSongVolume.LOWEST -> .05f
ThemeSongVolume.LOW -> .1f
ThemeSongVolume.MEDIUM -> .25f
ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 75f
}
player.apply {
stop()
volume = volumeLevel
setMediaItem(MediaItem.fromUri(url))
prepare()
play()
}
}
fun stop() {
Timber.v("Stopping theme song")
player.stop()
}
}

View file

@ -0,0 +1,81 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrailerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getTrailers(item: BaseItem): List<Trailer> {
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(item.id).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
return localTrailers + remoteTrailers
}
companion object {
/**
* Note: This is explicitly <b>not</b> a member function because the injected Context is not an Activity.
* We want to start the intent without a new task which requires the Activity context
*
* This can be provided by LocalContext.current from Compose
*/
fun onClick(
context: Context,
trailer: Trailer,
navigateTo: (Destination) -> Unit,
) {
when (trailer) {
is LocalTrailer ->
navigateTo.invoke(
Destination.Playback(
itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L,
),
)
is RemoteTrailer -> {
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
context.startActivity(intent)
}
}
}
}
}

View file

@ -0,0 +1,367 @@
package com.github.damontecres.wholphin.services
import android.Manifest
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.headersContentLength
import timber.log.Timber
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class UpdateChecker
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
companion object {
// TODO apk names
private const val ASSET_NAME = "Wholphin.apk"
private const val DEBUG_ASSET_NAME = "Wholphin-debug.apk"
private const val RELEASE_ASSET_NAME = "Wholphin-release.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
}
suspend fun maybeShowUpdateToast(
updateUrl: String,
showNegativeToast: Boolean = false,
) {
val pref = PreferenceManager.getDefaultSharedPreferences(context)
val now = Date()
val lastUpdateCheckThreshold =
pref
.getLong(context.getString(R.string.pref_key_update_last_check_threshold), 12)
.hours
val lastUpdateCheck =
pref.getLong(
context.getString(R.string.pref_key_update_last_check),
0,
)
val timeSince = (now.time - lastUpdateCheck).milliseconds
Timber.v("Last successful update check was $timeSince ago")
val installedVersion = getInstalledVersion()
val latestRelease = getLatestRelease(updateUrl)
if (latestRelease != null && latestRelease.version.isGreaterThan(installedVersion)) {
Timber.v("Update available $installedVersion => ${latestRelease.version}")
pref.edit {
putLong(context.getString(R.string.pref_key_update_last_check), now.time)
}
if (lastUpdateCheckThreshold >= timeSince) {
Timber.i(
"Skipping update notification, threshold is $lastUpdateCheckThreshold",
)
} else {
showToast(
context,
"Update available: $installedVersion => ${latestRelease.version}!",
Toast.LENGTH_LONG,
)
}
} else {
Timber.v("No update available for $installedVersion")
if (showNegativeToast) {
showToast(
context,
"No updates available, $installedVersion is the latest!",
Toast.LENGTH_LONG,
)
}
}
}
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.Companion.fromString(pkgInfo.versionName!!)
}
suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) {
val preferredAsset =
if (PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
) {
RELEASE_ASSET_NAME
} else {
DEBUG_ASSET_NAME
}
val request =
Request
.Builder()
.url(updateUrl)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.Companion.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
val downloadUrl =
result.jsonObject["assets"]
?.jsonArray
?.firstOrNull { asset ->
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
assetName == ASSET_NAME || assetName == preferredAsset
}?.jsonObject
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
if (version != null) {
val notes =
if (body.isNotNullOrBlank()) {
NOTE_REGEX
.findAll(body)
.map { m ->
m.groupValues[1]
}.toList()
} else {
listOf()
}
return@use Release(version, downloadUrl, publishedAt, body, notes)
} else {
Timber.w("Update version parsing failed. name=$name")
}
} else {
Timber.w("Update check failed: ${it.message}")
}
return@use null
}
}
}
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
) {
withContext(Dispatchers.IO) {
cleanup()
val request =
Request
.Builder()
.url(release.downloadUrl!!)
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
Timber.v("Request successful for ${release.downloadUrl}")
withContext(Dispatchers.Main) {
callback.contentLength(it.headersContentLength())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, ASSET_NAME)
put(MediaStore.MediaColumns.MIME_TYPE, APK_MIME_TYPE)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS,
)
}
val resolver = context.contentResolver
val uri =
resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback)
}
}
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = uri
context.startActivity(intent)
} else {
Timber.e("Resolver URI is null, trying fallback")
// showToast(context, "Unable to download the apk")
val targetFile = fallbackDownload(it, callback)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data =
FileProvider.getUriForFile(
context,
context.packageName + ".provider",
targetFile,
)
context.startActivity(intent)
}
} else {
val targetFile = fallbackDownload(it, callback)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data =
FileProvider.getUriForFile(
context,
context.packageName + ".provider",
targetFile,
)
context.startActivity(intent)
} else {
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.fromFile(targetFile), APK_MIME_TYPE)
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}
} else {
Timber.v("Request failed for ${release.downloadUrl}: ${it.code}")
showToast(context, "Error downloading the apk: ${it.code}")
}
}
}
}
private suspend fun fallbackDownload(
response: Response,
callback: DownloadCallback,
): File {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
downloadDir.mkdirs()
val targetFile = File(downloadDir, ASSET_NAME)
targetFile.outputStream().use { output ->
response.body!!.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
}
return targetFile
}
fun hasPermissions(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE,
) == PackageManager.PERMISSION_GRANTED
/**
* Delete previously downloaded APKs
*/
fun cleanup() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
),
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val displayName = cursor.getString(1)
Timber.v("id=$id, displayName=$displayName")
}
}
val deletedRows =
context.contentResolver.delete(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
"${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?",
arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE),
)
Timber.i("Deleted $deletedRows rows")
} else {
val downloadDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val targetFile = File(downloadDir, ASSET_NAME)
if (targetFile.exists()) {
targetFile.delete()
}
}
} catch (ex: Exception) {
Timber.e(ex, "Exception during cleanup")
}
}
}
@Serializable
data class Release(
val version: Version,
val downloadUrl: String?,
val publishedAt: String?,
val body: String?,
val notes: List<String>,
)
interface DownloadCallback {
fun contentLength(contentLength: Long)
fun bytesDownloaded(bytes: Long)
}
suspend fun copyTo(
input: InputStream,
out: OutputStream,
bufferSize: Int = 16 * 1024,
callback: DownloadCallback,
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(Dispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
}
return bytesCopied
}

View file

@ -0,0 +1,192 @@
package com.github.damontecres.wholphin.services.hilt
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.FavoriteWatchManagerImpl
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class StandardOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun clientInfo(
@ApplicationContext context: Context,
): ClientInfo =
ClientInfo(
name = context.getString(R.string.app_name),
version = BuildConfig.VERSION_NAME,
)
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo =
DeviceInfo(
id = @SuppressLint("HardwareIds") Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID,
),
name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME),
)
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides
@Singleton
fun authOkHttpClient(
serverRepository: ServerRepository,
@StandardOkHttpClient okHttpClient: OkHttpClient,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
) = okHttpClient
.newBuilder()
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
"Authorization",
AuthorizationHeaderBuilder.buildHeader(
clientName = clientInfo.name,
clientVersion = clientInfo.version,
deviceId = deviceInfo.id,
deviceName = deviceInfo.name,
accessToken = token,
),
).build()
}
it.proceed(newRequest ?: request)
}.build()
@Provides
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient)
@Provides
@Singleton
fun jellyfin(
okHttpFactory: OkHttpFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
): Jellyfin =
createJellyfin {
this.context = context
this.clientInfo = clientInfo
this.deviceInfo = deviceInfo
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion
}
@Provides
@Singleton
fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi()
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
override fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
return preferences.appPreferences.interfacePreferences
.getRememberedTabsOrDefault(key(itemId), defaultTab)
} else {
return defaultTab
}
}
override fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
) {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) {
appPreference.updateData {
preferences.appPreferences.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex)
}
}
}
}
}
}
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api)
}

View file

@ -0,0 +1,73 @@
package com.github.damontecres.wholphin.services.hilt
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun database(
@ApplicationContext context: Context,
): AppDatabase =
Room
.databaseBuilder(
context,
AppDatabase::class.java,
"wholphin",
).addMigrations(Migrations.Migrate2to3)
// .setQueryCallback({ sqlQuery, args ->
// Timber.v("sqlQuery=$sqlQuery, args=$args")
// }, Dispatchers.IO.asExecutor())
.build()
@Provides
@Singleton
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
@Provides
@Singleton
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
@Provides
@Singleton
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
@Provides
@Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides
@Singleton
fun userPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: AppPreferencesSerializer,
): DataStore<AppPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
produceFile = { context.dataStoreFile("app_preferences.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
}