diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts index a752dc17..654af0e2 100644 --- a/app-tv/build.gradle.kts +++ b/app-tv/build.gradle.kts @@ -53,6 +53,7 @@ kotlin { } dependencies { + implementation(project(":data")) implementation(project(":core")) implementation(project(":core-model")) implementation(project(":core-ui")) diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt index 2d94057f..af8f7d73 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt @@ -2,6 +2,12 @@ package hu.bbara.purefin.tv import android.app.Application import dagger.hilt.android.HiltAndroidApp +import hu.bbara.purefin.core.logging.PurefinLogger @HiltAndroidApp -class TvApplication : Application() +class TvApplication : Application() { + override fun onCreate() { + super.onCreate() + PurefinLogger.initialize(this) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt index cd74a4e2..641aa136 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt @@ -2,8 +2,12 @@ package hu.bbara.purefin import android.app.Application import dagger.hilt.android.HiltAndroidApp +import hu.bbara.purefin.core.logging.PurefinLogger @HiltAndroidApp class PurefinApplication : Application() { - -} \ No newline at end of file + override fun onCreate() { + super.onCreate() + PurefinLogger.initialize(this) + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index df7b9b6e..7c53db68 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -41,5 +41,6 @@ dependencies { implementation(libs.media3.datasource.okhttp) implementation(libs.datastore) implementation(libs.okhttp) + implementation(libs.timber) testImplementation(libs.junit) } diff --git a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt index 116fe5fa..9e472252 100644 --- a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt @@ -1,7 +1,6 @@ package hu.bbara.purefin.core.download import android.content.Context -import android.util.Log import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.media3.common.MediaItem @@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -52,16 +52,16 @@ class MediaDownloadManager @Inject constructor( ) { val contentId = download.request.id val state = download.toDownloadState() - Log.d(TAG, "Download changed: $contentId -> $state (${download.percentDownloaded}%)") + Timber.tag(TAG).d("Download changed: $contentId -> $state (${download.percentDownloaded}%)") if (finalException != null) { - Log.e(TAG, "Download exception for $contentId", finalException) + Timber.tag(TAG).e(finalException, "Download exception for $contentId") } getOrCreateStateFlow(contentId).value = state } override fun onDownloadRemoved(manager: DownloadManager, download: Download) { val contentId = download.request.id - Log.d(TAG, "Download removed: $contentId") + Timber.tag(TAG).d("Download removed: $contentId") getOrCreateStateFlow(contentId).value = DownloadState.NotDownloaded } }) @@ -91,7 +91,7 @@ class MediaDownloadManager @Inject constructor( } emit(result) } catch (e: Exception) { - Log.e(TAG, "Failed to poll active downloads", e) + Timber.tag(TAG).e(e, "Failed to poll active downloads") emit(emptyMap()) } delay(500) @@ -120,13 +120,13 @@ class MediaDownloadManager @Inject constructor( withContext(Dispatchers.IO) { try { val source = downloadMediaSourceResolver.resolveMovieDownload(movieId) ?: run { - Log.e(TAG, "No downloadable movie source for $movieId") + Timber.tag(TAG).e("No downloadable movie source for $movieId") return@withContext } offlineCatalogStore.saveMovies(listOf(source.movie)) - Log.d(TAG, "Starting download for '${source.movie.title}' from: ${source.playbackUrl}") + Timber.tag(TAG).d("Starting download for '${source.movie.title}' from: ${source.playbackUrl}") val request = buildDownloadRequest( mediaId = movieId, playbackUrl = source.playbackUrl, @@ -134,9 +134,9 @@ class MediaDownloadManager @Inject constructor( customCacheKey = source.customCacheKey, ) PurefinDownloadService.sendAddDownload(context, request) - Log.d(TAG, "Download request sent for $movieId") + Timber.tag(TAG).d("Download request sent for $movieId") } catch (e: Exception) { - Log.e(TAG, "Failed to start download for $movieId", e) + Timber.tag(TAG).e(e, "Failed to start download for $movieId") getOrCreateStateFlow(movieId.toString()).value = DownloadState.Failed } } @@ -148,7 +148,7 @@ class MediaDownloadManager @Inject constructor( try { offlineCatalogStore.deleteMovie(movieId) } catch (e: Exception) { - Log.e(TAG, "Failed to remove movie from offline DB", e) + Timber.tag(TAG).e(e, "Failed to remove movie from offline DB") } } } @@ -157,7 +157,7 @@ class MediaDownloadManager @Inject constructor( withContext(Dispatchers.IO) { try { val source = downloadMediaSourceResolver.resolveEpisodeDownload(episodeId) ?: run { - Log.e(TAG, "No downloadable episode source for $episodeId") + Timber.tag(TAG).e("No downloadable episode source for $episodeId") return@withContext } @@ -171,7 +171,7 @@ class MediaDownloadManager @Inject constructor( offlineCatalogStore.saveEpisode(source.episode) - Log.d(TAG, "Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}") + Timber.tag(TAG).d("Starting download for episode '${source.episode.title}' from: ${source.playbackUrl}") val request = buildDownloadRequest( mediaId = episodeId, playbackUrl = source.playbackUrl, @@ -179,9 +179,9 @@ class MediaDownloadManager @Inject constructor( customCacheKey = source.customCacheKey, ) PurefinDownloadService.sendAddDownload(context, request) - Log.d(TAG, "Download request sent for episode $episodeId") + Timber.tag(TAG).d("Download request sent for episode $episodeId") } catch (e: Exception) { - Log.e(TAG, "Failed to start download for episode $episodeId", e) + Timber.tag(TAG).e(e, "Failed to start download for episode $episodeId") getOrCreateStateFlow(episodeId.toString()).value = DownloadState.Failed } } @@ -201,7 +201,7 @@ class MediaDownloadManager @Inject constructor( try { offlineCatalogStore.deleteEpisodeAndCleanup(episodeId) } catch (e: Exception) { - Log.e(TAG, "Failed to remove episode from offline DB", e) + Timber.tag(TAG).e(e, "Failed to remove episode from offline DB") } } } @@ -231,7 +231,7 @@ class MediaDownloadManager @Inject constructor( try { syncSmartDownloadsForSeries(seriesId) } catch (e: Exception) { - Log.e(TAG, "Smart download sync failed for series $seriesId", e) + Timber.tag(TAG).e(e, "Smart download sync failed for series $seriesId") } } } @@ -246,7 +246,7 @@ class MediaDownloadManager @Inject constructor( val unwatchedDownloaded = mutableListOf() for (episode in downloadedEpisodes) { if (downloadMediaSourceResolver.isEpisodeWatched(episode.id)) { - Log.d(TAG, "Smart download: removing watched episode ${episode.title}") + Timber.tag(TAG).d("Smart download: removing watched episode ${episode.title}") cancelEpisodeDownload(episode.id) } else { unwatchedDownloaded.add(episode.id) @@ -264,7 +264,7 @@ class MediaDownloadManager @Inject constructor( ) if (toDownload.isNotEmpty()) { - Log.d(TAG, "Smart download: queuing ${toDownload.size} episodes for series $seriesId") + Timber.tag(TAG).d("Smart download: queuing ${toDownload.size} episodes for series $seriesId") downloadEpisodes(toDownload) } } diff --git a/core/src/main/java/hu/bbara/purefin/core/logging/PurefinLogger.kt b/core/src/main/java/hu/bbara/purefin/core/logging/PurefinLogger.kt new file mode 100644 index 00000000..b3c057cc --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/logging/PurefinLogger.kt @@ -0,0 +1,214 @@ +package hu.bbara.purefin.core.logging + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Process +import android.util.Log +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.system.exitProcess + +object PurefinLogger { + private val lock = Any() + private var logStore: LogFileStore? = null + private var initialized = false + + fun initialize(context: Context) { + synchronized(lock) { + if (initialized) { + return + } + + val store = LogFileStore(File(context.filesDir, LOG_DIRECTORY)) + logStore = store + val isDebuggable = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + if (isDebuggable) { + Timber.plant(Timber.DebugTree()) + } + Timber.plant(FileLogTree(store)) + installUncaughtExceptionHandler(store) + initialized = true + } + } + + fun prepareFilesForUpload(): List { + return logStore?.prepareFilesForUpload().orEmpty() + } + + fun deleteUploadedFile(uploadLogFile: UploadLogFile) { + if (uploadLogFile.file.exists() && !uploadLogFile.file.delete()) { + error("Uploaded log file could not be deleted: ${uploadLogFile.name}") + } + } + + private fun installUncaughtExceptionHandler(store: LogFileStore) { + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + Timber.e(throwable, "Uncaught exception in %s", thread.name) + store.flush() + } finally { + if (previousHandler != null) { + previousHandler.uncaughtException(thread, throwable) + } else { + Process.killProcess(Process.myPid()) + exitProcess(2) + } + } + } + } + + private const val LOG_DIRECTORY = "purefin-logs" +} + +data class UploadLogFile( + val name: String, + val data: String, + internal val file: File, +) + +private class FileLogTree( + private val logStore: LogFileStore, +) : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + logStore.write(priority = priority, tag = tag, message = message, throwable = t) + } +} + +private class LogFileStore( + private val directory: File, +) { + private val activeFile = File(directory, ACTIVE_LOG_FILE) + private val timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + private val uploadNameFormatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss") + + @Synchronized + fun write(priority: Int, tag: String?, message: String, throwable: Throwable?) { + directory.mkdirs() + val entry = buildEntry(priority, tag, message, throwable).toByteArray(Charsets.UTF_8) + if (activeFile.exists() && activeFile.length() + entry.size > MAX_LOG_BYTES) { + rotate() + } + FileOutputStream(activeFile, true).use { output -> + output.write(entry) + output.flush() + } + } + + @Synchronized + fun flush() { + directory.mkdirs() + } + + @Synchronized + fun prepareFilesForUpload(): List { + directory.mkdirs() + val candidates = uploadFiles() + rotatedFilesOldestFirst() + listOfNotNull(activeFile.takeIf { it.isLogFile() }) + if (candidates.isEmpty()) { + return emptyList() + } + + val preparedFiles = renameForUpload(candidates) + return preparedFiles.map { file -> + UploadLogFile( + name = file.name, + data = file.readText(Charsets.UTF_8), + file = file, + ) + } + } + + private fun buildEntry(priority: Int, tag: String?, message: String, throwable: Throwable?): String { + return buildString { + append(timestampFormatter.format(LocalDateTime.now())) + append(' ') + append(priorityLabel(priority)) + append('/') + append(tag ?: "Purefin") + append(" [") + append(Thread.currentThread().name) + append("] ") + append(message) + append('\n') + if (throwable != null) { + append(Log.getStackTraceString(throwable)) + append('\n') + } + } + } + + private fun priorityLabel(priority: Int): String = when (priority) { + Log.VERBOSE -> "V" + Log.DEBUG -> "D" + Log.INFO -> "I" + Log.WARN -> "W" + Log.ERROR -> "E" + Log.ASSERT -> "A" + else -> priority.toString() + } + + private fun rotate() { + File(directory, "purefin.${MAX_LOG_FILES - 1}.log").delete() + for (index in MAX_LOG_FILES - 2 downTo 1) { + val file = File(directory, "purefin.$index.log") + if (file.exists()) { + file.renameTo(File(directory, "purefin.${index + 1}.log")) + } + } + if (activeFile.exists()) { + activeFile.renameTo(File(directory, "purefin.1.log")) + } + } + + private fun renameForUpload(files: List): List { + val uploadTimestamp = uploadNameFormatter.format(LocalDateTime.now()) + val tempFiles = files.mapIndexed { index, file -> + val tempFile = File(directory, ".upload-$uploadTimestamp-$index.tmp") + tempFile.delete() + if (!file.renameTo(tempFile)) { + error("Log file could not be prepared for upload: ${file.name}") + } + tempFile + } + return tempFiles.mapIndexed { index, file -> + val uploadName = if (tempFiles.size == 1) { + "$uploadTimestamp.log" + } else { + "${uploadTimestamp}_${(index + 1).toString().padStart(3, '0')}.log" + } + val uploadFile = File(directory, uploadName) + uploadFile.delete() + if (!file.renameTo(uploadFile)) { + error("Log file could not be named for upload: $uploadName") + } + uploadFile + } + } + + private fun uploadFiles(): List { + return directory.listFiles() + .orEmpty() + .filter { it.isFile && UPLOAD_LOG_FILE_REGEX.matches(it.name) } + .sortedWith(compareBy { it.lastModified() }.thenBy { it.name }) + } + + private fun rotatedFilesOldestFirst(): List { + return (MAX_LOG_FILES - 1 downTo 1) + .map { File(directory, "purefin.$it.log") } + .filter { it.isLogFile() } + } + + private fun File.isLogFile(): Boolean { + return isFile && length() > 0L + } + + private companion object { + const val ACTIVE_LOG_FILE = "purefin.log" + const val MAX_LOG_BYTES = 512 * 1024 + const val MAX_LOG_FILES = 4 + val UPLOAD_LOG_FILE_REGEX = Regex("""\d{8}_\d{6}(?:_\d{3})?\.log""") + } +} diff --git a/core/src/main/java/hu/bbara/purefin/core/player/manager/MediaSegmentManager.kt b/core/src/main/java/hu/bbara/purefin/core/player/manager/MediaSegmentManager.kt index edb4c717..100e96c1 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/manager/MediaSegmentManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/manager/MediaSegmentManager.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.core.player.manager -import android.util.Log import androidx.media3.exoplayer.ExoPlayer import hu.bbara.purefin.core.player.model.SegmentStatus import hu.bbara.purefin.model.MediaSegment @@ -11,6 +10,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber class MediaSegmentManager(private val player: ExoPlayer) { @@ -30,7 +30,7 @@ class MediaSegmentManager(private val player: ExoPlayer) { @Synchronized fun registerListener(listener: MediaSegmentListener) { this.listener?.let { - Log.w("MediaSegmentManager", "Listener was already register") + Timber.tag(TAG).w("Listener was already register") return } this.listener = listener @@ -74,10 +74,14 @@ class MediaSegmentManager(private val player: ExoPlayer) { private fun notifyListener(mediaSegment: MediaSegment, status: SegmentStatus) { val listener = listener if (listener == null) { - Log.w("MediaSegmentManager", "Listener was not register therefore it cannot notify") + Timber.tag(TAG).w("Listener was not register therefore it cannot notify") return } - Log.d("MediaSegmentManager", "Notify listener about $mediaSegment with status $status") + Timber.tag(TAG).d("Notify listener about $mediaSegment with status $status") listener.onEvent(mediaSegment, status) } + + private companion object { + const val TAG = "MediaSegmentManager" + } } diff --git a/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt b/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt index 0cd0be8f..2c6b398d 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/manager/ProgressManager.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.core.player.manager -import android.util.Log import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.LocalMediaUpdater import hu.bbara.purefin.core.data.PlaybackProgressReporter @@ -18,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -82,7 +82,7 @@ class ProgressManager @Inject constructor( try { localMediaUpdater.updateWatchProgress(itemId, lastPositionMs, lastDurationMs) } catch (e: Exception) { - Log.e("ProgressManager", "Local cache update failed", e) + Timber.tag(TAG).e(e, "Local cache update failed") } } } @@ -109,9 +109,9 @@ class ProgressManager @Inject constructor( isStop -> playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext) else -> playbackProgressReporter.reportPlaybackProgress(itemId, ticks, isPaused, reportContext) } - Log.d("ProgressManager", "${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused") + Timber.tag(TAG).d("${if (isStart) "Start" else if (isStop) "Stop" else "Progress"}: $itemId at ${positionMs}ms, paused=$isPaused") } catch (e: Exception) { - Log.e("ProgressManager", "Report failed", e) + Timber.tag(TAG).e(e, "Report failed") } } } @@ -128,12 +128,16 @@ class ProgressManager @Inject constructor( playbackProgressReporter.reportPlaybackStopped(itemId, ticks, reportContext) } localMediaUpdater.updateWatchProgress(itemId, posMs, durMs) - Log.d("ProgressManager", "Stop: $itemId at ${posMs}ms") + Timber.tag(TAG).d("Stop: $itemId at ${posMs}ms") } catch (e: Exception) { - Log.e("ProgressManager", "Report failed", e) + Timber.tag(TAG).e(e, "Report failed") } } } scope.cancel() } + + private companion object { + const val TAG = "ProgressManager" + } } diff --git a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt index 3d2a934a..c1a90f62 100644 --- a/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlayerViewModel.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.core.player.viewmodel -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,6 +22,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber import kotlinx.serialization.InternalSerializationApi import java.util.UUID import javax.inject.Inject @@ -36,6 +36,7 @@ class PlayerViewModel @Inject constructor( ) : ViewModel() { companion object { private const val DEFAULT_CONTROLS_AUTO_HIDE_MS = 3_500L + private const val TAG = "PlayerViewModel" } val player get() = playerManager.player @@ -282,7 +283,7 @@ class PlayerViewModel @Inject constructor( is PlayableMedia.Movie -> { val movie = mediaCatalogReader.getMovie(id).first() if (movie == null) { - Log.e("PlayerViewModel", "Movie not found for playlist item: $id") + Timber.tag(TAG).e("Movie not found for playlist item: $id") null } else { PlaylistElementUiModel( @@ -300,7 +301,7 @@ class PlayerViewModel @Inject constructor( is PlayableMedia.Series -> { val series = mediaCatalogReader.getSeries(id).first() if (series == null) { - Log.e("PlayerViewModel", "Series not found for playlist item: $id") + Timber.tag(TAG).e("Series not found for playlist item: $id") null } else { PlaylistElementUiModel( @@ -318,7 +319,7 @@ class PlayerViewModel @Inject constructor( is PlayableMedia.Episode -> { val episode = mediaCatalogReader.getEpisode(id).first() if (episode == null) { - Log.e("PlayerViewModel", "Episode not found for playlist item: $id") + Timber.tag(TAG).e("Episode not found for playlist item: $id") null } else { PlaylistElementUiModel( diff --git a/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateInstallReceiver.kt b/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateInstallReceiver.kt index b4be1572..98c11da7 100644 --- a/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateInstallReceiver.kt +++ b/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateInstallReceiver.kt @@ -6,7 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build -import android.util.Log +import timber.log.Timber class AppUpdateInstallReceiver : BroadcastReceiver() { @@ -18,13 +18,13 @@ class AppUpdateInstallReceiver : BroadcastReceiver() { try { context.startActivity(confirmIntent) } catch (e: ActivityNotFoundException) { - Log.e(TAG, "Install confirmation activity missing", e) + Timber.tag(TAG).e(e, "Install confirmation activity missing") } } PackageInstaller.STATUS_SUCCESS -> Unit else -> { val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - Log.e(TAG, "Install failed: $status $message") + Timber.tag(TAG).e("Install failed: $status $message") } } } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 1641bf70..9c7bf50c 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.jellyfin.core) implementation(libs.kotlinx.serialization.json) implementation(libs.slf4j.api) + implementation(libs.timber) implementation(libs.hilt) ksp(libs.hilt.compiler) implementation(libs.datastore) diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index 60f6f531..964c3852 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.data.catalog -import android.util.Log import androidx.datastore.core.DataStore import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.NetworkMonitor @@ -41,6 +40,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import timber.log.Timber @Singleton class InMemoryAppContentRepository @Inject constructor( @@ -82,7 +82,7 @@ class InMemoryAppContentRepository @Inject constructor( if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { return } - Log.d(TAG, "Initializing home repository") + Timber.tag(TAG).d("Initializing home repository") scope.launch { loadHomeCache() } scope.launch { refreshHomeData() } } @@ -91,7 +91,7 @@ class InMemoryAppContentRepository @Inject constructor( val job = synchronized(this) { refreshJob?.takeIf { it.isActive } ?: scope.launch { runCatching { - Log.d(TAG, "Refreshing home data") + Timber.tag(TAG).d("Refreshing home data") if (!networkMonitor.isOnline.first()) { return@runCatching } @@ -100,10 +100,10 @@ class InMemoryAppContentRepository @Inject constructor( loadContinueWatching() loadNextUp() loadLatestLibraryContent() - Log.d(TAG, "Home refresh successful") + Timber.tag(TAG).d("Home refresh successful") persistHomeCache() }.onFailure { error -> - Log.w(TAG, "Home refresh failed; keeping cached content", error) + Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content") } }.also { refreshJob = it } } @@ -111,7 +111,7 @@ class InMemoryAppContentRepository @Inject constructor( } private suspend fun loadHomeCache() { - Log.d(TAG, "Loading home cache") + Timber.tag(TAG).d("Loading home cache") val cache = homeCacheDataStore.data.first() if (cache.libraries.isNotEmpty()) { val libraries = cache.libraries.mapNotNull { it.toLibrary() } @@ -147,7 +147,7 @@ class InMemoryAppContentRepository @Inject constructor( if (cache.episodes.isNotEmpty()) { onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() }) } - Log.d(TAG, "Home cache loaded") + Timber.tag(TAG).d("Home cache loaded") } private suspend fun persistHomeCache() { @@ -200,7 +200,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLibraries() { val librariesItem = runCatching { jellyfinApiClient.getLibraries() } .getOrElse { error -> - Log.w(TAG, "Unable to load libraries", error) + Timber.tag(TAG).w(error, "Unable to load libraries") return } val filteredLibraries = librariesItem.filter { @@ -222,7 +222,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLibrary(library: Library): Library { val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } .getOrElse { error -> - Log.w(TAG, "Unable to load library ${library.id}", error) + Timber.tag(TAG).w(error, "Unable to load library ${library.id}") return library } return when (library.type) { @@ -240,7 +240,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadSuggestions() { val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } .getOrElse { error -> - Log.w(TAG, "Unable to load suggestions", error) + Timber.tag(TAG).w(error, "Unable to load suggestions") return } suggestionsState.value = suggestionsItems.mapNotNull { item -> @@ -261,7 +261,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadContinueWatching() { val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } .getOrElse { error -> - Log.w(TAG, "Unable to load continue watching", error) + Timber.tag(TAG).w(error, "Unable to load continue watching") return } continueWatchingState.value = continueWatchingItems.mapNotNull { item -> @@ -282,7 +282,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadNextUp() { val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } .getOrElse { error -> - Log.w(TAG, "Unable to load next up", error) + Timber.tag(TAG).w(error, "Unable to load next up") return } nextUpState.value = nextUpItems.map { item -> @@ -297,7 +297,7 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadLatestLibraryContent() { val librariesItem = runCatching { jellyfinApiClient.getLibraries() } .getOrElse { error -> - Log.w(TAG, "Unable to load latest library content", error) + Timber.tag(TAG).w(error, "Unable to load latest library content") return } val filteredLibraries = librariesItem.filter { @@ -306,7 +306,7 @@ class InMemoryAppContentRepository @Inject constructor( val latestLibraryContents = filteredLibraries.associate { library -> val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } .getOrElse { error -> - Log.w(TAG, "Unable to load latest items for library ${library.id}", error) + Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}") emptyList() } library.id to when (library.collectionType) { diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt index 36ce9434..c5745e08 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.data.catalog -import android.util.Log import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.data.converter.toEpisode @@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -52,7 +52,7 @@ class InMemoryLocalMediaRepository @Inject constructor( if (!moviesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> if (item.type != BaseItemKind.MOVIE) { - Log.d("InMemoryMediaRepository", "Item is not an movie: ${item.type}") + Timber.tag(TAG).d("Item is not an movie: ${item.type}") return flowOf(null) } val movie = item.toMovie(serverUrl.first()) @@ -66,7 +66,7 @@ class InMemoryLocalMediaRepository @Inject constructor( if (!seriesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> if (item.type != BaseItemKind.SERIES) { - Log.d("InMemoryMediaRepository", "Item is not an series: ${item.type}") + Timber.tag(TAG).d("Item is not an series: ${item.type}") return flowOf(null) } val series = item.toSeries(serverUrl.first()) @@ -80,7 +80,7 @@ class InMemoryLocalMediaRepository @Inject constructor( if (!episodesState.value.containsKey(id)) { jellyfinApiClient.getItemInfo(id)?.let { item -> if (item.type != BaseItemKind.EPISODE) { - Log.d("InMemoryMediaRepository", "Item is not an episode: ${item.type}") + Timber.tag(TAG).d("Item is not an episode: ${item.type}") return flowOf(null) } val episode = item.toEpisode(serverUrl.first()) @@ -110,7 +110,7 @@ class InMemoryLocalMediaRepository @Inject constructor( } catch (error: CancellationException) { throw error } catch (error: Exception) { - Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error) + Timber.tag(TAG).e(error, "Failed to load content for series $seriesId") throw error } } @@ -121,7 +121,7 @@ class InMemoryLocalMediaRepository @Inject constructor( } catch (error: CancellationException) { throw error } catch (error: Exception) { - Log.w("InMemoryMediaRepository", "Unable to preload seasons for episode series $seriesId", error) + Timber.tag(TAG).w(error, "Unable to preload seasons for episode series $seriesId") } } @@ -231,4 +231,8 @@ class InMemoryLocalMediaRepository @Inject constructor( } } } + + private companion object { + const val TAG = "InMemoryMediaRepository" + } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index a036ee0c..561a2e10 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.data.jellyfin -import android.util.Log import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.media3.common.MediaItem @@ -36,6 +35,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentType.OUTRO import org.jellyfin.sdk.model.api.MediaSegmentType.PREVIEW import org.jellyfin.sdk.model.api.MediaSegmentType.RECAP import org.jellyfin.sdk.model.api.MediaSourceInfo +import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -72,7 +72,7 @@ class DefaultPlayableMediaRepository @Inject constructor( ?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) }.getOrElse { error -> if (error is CancellationException) throw error - Log.w("PlayableMediaRepo", "Unable to load online metadata for downloaded media $mediaId", error) + Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId") getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) } } @@ -232,7 +232,7 @@ class DefaultPlayableMediaRepository @Inject constructor( getPlayableMedia(id) } }.getOrElse { error -> - Log.w("PlayableMediaRepo", "Unable to load next-up items for $episodeId", error) + Timber.tag(TAG).w(error, "Unable to load next-up items for $episodeId") emptyList() } } @@ -325,4 +325,8 @@ class DefaultPlayableMediaRepository @Inject constructor( endMs = endTicks / 10_000L ) } + + private companion object { + const val TAG = "PlayableMediaRepo" + } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index ab348bec..e016902a 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -2,7 +2,6 @@ package hu.bbara.purefin.data.jellyfin.client import android.content.Context import android.os.SystemClock -import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackReportContext @@ -19,6 +18,7 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect import org.jellyfin.sdk.api.client.extensions.authenticateUserByName +import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -57,6 +57,7 @@ import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -194,7 +195,7 @@ class JellyfinApiClient @Inject constructor( searchTerm = searchTerm, recursive = true ) - Log.d("searchBySearchTerm", response.content.toString()) + Timber.tag("searchBySearchTerm").d(response.content.toString()) response.content.items } } @@ -210,7 +211,7 @@ class JellyfinApiClient @Inject constructor( genres = genres, recursive = true ) - Log.d("searchByGenre", response.content.toString()) + Timber.tag("searchByGenre").d(response.content.toString()) response.content.items } } @@ -225,7 +226,7 @@ class JellyfinApiClient @Inject constructor( presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS), includeHidden = false, ) - Log.d("getLibraries", response.content.toString()) + Timber.tag("getLibraries").d(response.content.toString()) response.content.items } } @@ -245,7 +246,7 @@ class JellyfinApiClient @Inject constructor( recursive = true, ) val response = api.itemsApi.getItems(getItemsRequest) - Log.d("getLibraryContent", response.content.toString()) + Timber.tag("getLibraryContent").d(response.content.toString()) response.content.items } } @@ -263,7 +264,7 @@ class JellyfinApiClient @Inject constructor( limit = 8, enableTotalRecordCount = true, ) - Log.d("getSuggestions", response.content.toString()) + Timber.tag("getSuggestions").d(response.content.toString()) response.content.items } } @@ -282,7 +283,7 @@ class JellyfinApiClient @Inject constructor( startIndex = 0, ) val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) - Log.d("getContinueWatching", response.content.toString()) + Timber.tag("getContinueWatching").d(response.content.toString()) response.content.items } } @@ -298,7 +299,7 @@ class JellyfinApiClient @Inject constructor( enableResumable = false, ) val result = api.tvShowsApi.getNextUp(getNextUpRequest) - Log.d("getNextUpEpisodes", result.content.toString()) + Timber.tag("getNextUpEpisodes").d(result.content.toString()) result.content.items } } @@ -315,7 +316,7 @@ class JellyfinApiClient @Inject constructor( includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), limit = 10, ) - Log.d("getLatestFromLibrary", response.content.toString()) + Timber.tag("getLatestFromLibrary").d(response.content.toString()) response.content } } @@ -326,7 +327,7 @@ class JellyfinApiClient @Inject constructor( return@logRequest null } val result = api.userLibraryApi.getItem(itemId = mediaId, userId = getUserId()) - Log.d("getItemInfo", result.content.toString()) + Timber.tag("getItemInfo").d(result.content.toString()) result.content } } @@ -342,7 +343,7 @@ class JellyfinApiClient @Inject constructor( fields = defaultItemFields, enableUserData = true, ) - Log.d("getSeasons", result.content.toString()) + Timber.tag("getSeasons").d(result.content.toString()) result.content.items } } @@ -359,7 +360,7 @@ class JellyfinApiClient @Inject constructor( fields = defaultItemFields + ItemFields.OVERVIEW, enableUserData = true, ) - Log.d("getEpisodesInSeason", result.content.toString()) + Timber.tag("getEpisodesInSeason").d(result.content.toString()) result.content.items } } @@ -379,7 +380,7 @@ class JellyfinApiClient @Inject constructor( limit = count, ) val nextUpEpisodes = nextUpEpisodesResult.content.items - Log.d("getNextEpisodes", nextUpEpisodes.toString()) + Timber.tag("getNextEpisodes").d(nextUpEpisodes.toString()) nextUpEpisodes } } @@ -393,7 +394,7 @@ class JellyfinApiClient @Inject constructor( userId = getUserId(), parentId = id, ) - Log.d("getGenres", result.toString()) + Timber.tag("getGenres").d(result.toString()) result.content.items } } @@ -435,7 +436,7 @@ class JellyfinApiClient @Inject constructor( maxStreamingBitrate = 100_000_000, ), ) - Log.d("getMediaSources", result.toString()) + Timber.tag("getMediaSources").d(result.toString()) result.content.mediaSources } } @@ -449,7 +450,7 @@ class JellyfinApiClient @Inject constructor( itemId = mediaId, //includeSegmentTypes = listOf(MediaSegmentType.INTRO) ) - Log.d("getMediaSegments", result.toString()) + Timber.tag("getMediaSegments").d(result.toString()) result.content.items } } @@ -496,10 +497,19 @@ class JellyfinApiClient @Inject constructor( liveStreamId = liveStreamId, ) } catch (error: Exception) { - Log.e(TAG, "getVideoStreamUrl($itemId) failed", error) + Timber.tag(TAG).e(error, "getVideoStreamUrl($itemId) failed") throw error } + suspend fun uploadLogFile(data: String): String? = withContext(Dispatchers.IO) { + logRequest("uploadLogFile") { + if (!ensureConfigured()) { + return@logRequest null + } + api.clientLogApi.logFile(data).content.fileName + } + } + suspend fun getPublicSystemInfoVersion(): String? = withContext(Dispatchers.IO) { logRequest("getPublicSystemInfoVersion") { if (!ensureConfigured()) { @@ -589,8 +599,7 @@ class JellyfinApiClient @Inject constructor( return try { val result = block() val elapsedMs = SystemClock.elapsedRealtime() - startedAt - Log.d( - TAG, + Timber.tag(TAG).d( "$operation finished in ${elapsedMs}ms, " + "fetched ${result.approximateSizeBytes()} bytes${result.itemCountLogText()}" ) @@ -599,7 +608,7 @@ class JellyfinApiClient @Inject constructor( throw error } catch (error: Exception) { val elapsedMs = SystemClock.elapsedRealtime() - startedAt - Log.e(TAG, "$operation failed after ${elapsedMs}ms", error) + Timber.tag(TAG).e(error, "$operation failed after ${elapsedMs}ms") throw error } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt index 9d636316..4c038dcf 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/CodecDebugHelper.kt @@ -1,7 +1,7 @@ package hu.bbara.purefin.data.jellyfin.playback import android.media.MediaCodecList -import android.util.Log +import timber.log.Timber /** * Helper to debug available audio/video codecs on the device. @@ -17,34 +17,34 @@ object CodecDebugHelper { fun logAvailableDecoders() { try { val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) - Log.d(TAG, "=== Available Audio Decoders ===") + Timber.tag(TAG).d("=== Available Audio Decoders ===") codecList.codecInfos .filter { !it.isEncoder } .forEach { codecInfo -> codecInfo.supportedTypes.forEach { mimeType -> if (mimeType.startsWith("audio/")) { - Log.d(TAG, "${codecInfo.name}: $mimeType") + Timber.tag(TAG).d("${codecInfo.name}: $mimeType") if (mimeType.contains("dts", ignoreCase = true) || mimeType.contains("truehd", ignoreCase = true)) { - Log.w(TAG, " ^^^ DTS/TrueHD decoder found! ^^^") + Timber.tag(TAG).w(" ^^^ DTS/TrueHD decoder found! ^^^") } } } } - Log.d(TAG, "=== Available Video Decoders ===") + Timber.tag(TAG).d("=== Available Video Decoders ===") codecList.codecInfos .filter { !it.isEncoder } .forEach { codecInfo -> codecInfo.supportedTypes.forEach { mimeType -> if (mimeType.startsWith("video/")) { - Log.d(TAG, "${codecInfo.name}: $mimeType") + Timber.tag(TAG).d("${codecInfo.name}: $mimeType") } } } } catch (e: Exception) { - Log.e(TAG, "Failed to list codecs", e) + Timber.tag(TAG).e(e, "Failed to list codecs") } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt index e21b860b..861d2424 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/playback/JellyfinPlaybackResolver.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.data.jellyfin.playback -import android.util.Log import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import java.util.UUID @@ -11,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.ServerVersion +import timber.log.Timber @Singleton class JellyfinPlaybackResolver @Inject constructor( @@ -33,7 +33,7 @@ class JellyfinPlaybackResolver @Inject constructor( ) ?: return@withContext null if (playbackInfo.errorCode != null) { - Log.w(TAG, "Playback info failed for $mediaId with ${playbackInfo.errorCode}") + Timber.tag(TAG).w("Playback info failed for $mediaId with ${playbackInfo.errorCode}") return@withContext null } @@ -54,9 +54,9 @@ class JellyfinPlaybackResolver @Inject constructor( ) if (decision == null) { - Log.w(TAG, "No compatible playback path for $mediaId") + Timber.tag(TAG).w("No compatible playback path for $mediaId") } else { - Log.d(TAG, "Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}") + Timber.tag(TAG).d("Playback decision for $mediaId resolved as ${decision.reportContext.playMethod}") } decision } @@ -67,7 +67,7 @@ class JellyfinPlaybackResolver @Inject constructor( val resolvedVersion = runCatching { jellyfinApiClient.getPublicSystemInfoVersion()?.let(ServerVersion::fromString) }.onFailure { error -> - Log.w(TAG, "Unable to fetch server version for $serverUrl", error) + Timber.tag(TAG).w(error, "Unable to fetch server version for $serverUrl") }.getOrNull() ?: PlaybackProfileDefaults.fallbackServerVersion serverVersionCache[serverUrl] = resolvedVersion diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt index 4475dadf..db8d2a67 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/session/JellyfinAuthenticationRepository.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.data.jellyfin.session -import android.util.Log import hu.bbara.purefin.core.data.AuthenticationRepository import hu.bbara.purefin.core.data.JellyfinServerCandidate import hu.bbara.purefin.core.data.QuickConnectSession @@ -8,6 +7,7 @@ import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import kotlinx.coroutines.flow.Flow import org.jellyfin.sdk.model.api.AuthenticationResult +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -21,22 +21,22 @@ class JellyfinAuthenticationRepository @Inject constructor( override suspend fun findServer(input: String): JellyfinServerCandidate? = runCatching { jellyfinApiClient.findServer(input) } - .onFailure { Log.e(TAG, "Server search failed", it) } + .onFailure { Timber.tag(TAG).e(it, "Server search failed") } .getOrNull() override suspend fun isQuickConnectEnabled(url: String): Boolean = runCatching { jellyfinApiClient.isQuickConnectEnabled(url) } - .onFailure { Log.e(TAG, "Quick Connect check failed", it) } + .onFailure { Timber.tag(TAG).e(it, "Quick Connect check failed") } .getOrDefault(false) override suspend fun initiateQuickConnect(url: String): QuickConnectSession? = runCatching { jellyfinApiClient.initiateQuickConnect(url) } - .onFailure { Log.e(TAG, "Quick Connect initiation failed", it) } + .onFailure { Timber.tag(TAG).e(it, "Quick Connect initiation failed") } .getOrNull() override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? = runCatching { jellyfinApiClient.getQuickConnectState(url, secret) } - .onFailure { Log.e(TAG, "Quick Connect state check failed", it) } + .onFailure { Timber.tag(TAG).e(it, "Quick Connect state check failed") } .getOrNull() override suspend fun login(url: String, username: String, password: String): Boolean { @@ -45,7 +45,7 @@ class JellyfinAuthenticationRepository @Inject constructor( ?: return false saveAuthenticationResult(authResult) } catch (e: Exception) { - Log.e(TAG, "Login failed", e) + Timber.tag(TAG).e(e, "Login failed") false } } @@ -56,7 +56,7 @@ class JellyfinAuthenticationRepository @Inject constructor( ?: return false saveAuthenticationResult(authResult) } catch (e: Exception) { - Log.e(TAG, "Quick Connect login failed", e) + Timber.tag(TAG).e(e, "Quick Connect login failed") false } } diff --git a/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsModule.kt b/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsModule.kt new file mode 100644 index 00000000..fa7427e8 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsModule.kt @@ -0,0 +1,17 @@ +package hu.bbara.purefin.data.logging + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import hu.bbara.purefin.core.settings.SettingsGroupProvider + +@Module +@InstallIn(SingletonComponent::class) +abstract class LogUploadSettingsModule { + + @Binds + @IntoSet + abstract fun bindLogUploadSettingsProvider(impl: LogUploadSettingsProvider): SettingsGroupProvider +} diff --git a/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsProvider.kt b/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsProvider.kt new file mode 100644 index 00000000..77689396 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/logging/LogUploadSettingsProvider.kt @@ -0,0 +1,58 @@ +package hu.bbara.purefin.data.logging + +import hu.bbara.purefin.core.logging.PurefinLogger +import hu.bbara.purefin.core.settings.SettingGroup +import hu.bbara.purefin.core.settings.SettingsGroupProvider +import hu.bbara.purefin.core.settings.VoidSetting +import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LogUploadSettingsProvider @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, +) : SettingsGroupProvider { + private val uploadMutex = Mutex() + + override val settingGroups: Flow> = flowOf( + listOf( + SettingGroup( + title = "Logs", + options = listOf( + VoidSetting( + key = UPLOAD_LOGS_KEY, + title = "Upload logs", + onClick = { uploadLogs() } + ) + ) + ) + ) + ) + + private suspend fun uploadLogs() { + uploadMutex.withLock { + val logFiles = PurefinLogger.prepareFilesForUpload() + if (logFiles.isEmpty()) { + Timber.tag(TAG).d("No log files to upload") + return + } + + logFiles.forEach { logFile -> + val uploadedName = jellyfinApiClient.uploadLogFile(logFile.data) + ?: error("Log upload failed") + PurefinLogger.deleteUploadedFile(logFile) + Timber.tag(TAG).d("Uploaded log file ${logFile.name} as $uploadedName and deleted it locally") + } + } + } + + private companion object { + const val TAG = "LogUploadSettings" + const val UPLOAD_LOGS_KEY = "upload_logs" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac4e2647..2841c714 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ media3FfmpegDecoder = "1.9.0+1" nav3Core = "1.0.0" room = "2.7.0" slf4j = "2.0.17" +timber = "5.0.1" firebaseCrashlytics = "20.0.5" googleGmsGoogleServices = "4.4.4" googleFirebaseCrashlytics = "3.0.7" @@ -72,6 +73,7 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }