From 9a262d91427d50e2a338d9b7ebaa9ef08aa5f5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=D0=B0=D0=B5=20=D0=95u=D0=BD=D1=88=D0=B0?= <134977461+daedaevibin@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:08:10 +0000 Subject: [PATCH 1/4] Security hardening, error handling, AI client refactoring, test coverage Security: - Fix OkHttp response body leaks in AI clients by using .use{} blocks - All HTTP responses now properly closed in OpenAiCompatibleClient base Error handling: - Add Timber logging to silent catch blocks in EqualizerPreferencesRepository, PlaylistViewModel, LyricsStateHolder, FileExplorerStateHolder, DeviceCapabilitiesViewModel, PhoneDirectWatchTransferCoordinator Refactoring: - Extract OpenAiCompatibleClient base class from DeepSeek, Groq, Mistral, GenericOpenAi clients (~600 lines of duplicated code removed) - Extract ServerUrlUtils for shared URL normalization/validation logic used by NavidromeCredentials and JellyfinCredentials Tests: - Add AiClientFactoryTest (all providers, blank key validation) - Add OpenAiCompatibleClientTest (config, model filtering, token counting) - Add CloudMusicUtilsTest (JSON parsing, artist name parsing) - Add ServerUrlUtilsTest (URL normalization, validation, local network checks) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../data/ai/provider/DeepSeekAiClient.kt | 172 +---------------- .../data/ai/provider/GenericOpenAiClient.kt | 171 ++--------------- .../data/ai/provider/GroqAiClient.kt | 175 ++---------------- .../data/ai/provider/MistralAiClient.kt | 175 +----------------- .../ai/provider/OpenAiCompatibleClient.kt | 168 +++++++++++++++++ .../jellyfin/model/JellyfinCredentials.kt | 35 +--- .../navidrome/model/NavidromeCredentials.kt | 35 ++-- .../EqualizerPreferencesRepository.kt | 13 +- .../PhoneDirectWatchTransferCoordinator.kt | 3 +- .../viewmodel/DeviceCapabilitiesViewModel.kt | 4 +- .../viewmodel/FileExplorerStateHolder.kt | 4 +- .../viewmodel/LyricsStateHolder.kt | 4 +- .../viewmodel/PlaylistViewModel.kt | 5 +- .../pixelplay/utils/ServerUrlUtils.kt | 44 +++++ .../data/ai/provider/AiClientFactoryTest.kt | 83 +++++++++ .../ai/provider/OpenAiCompatibleClientTest.kt | 69 +++++++ .../data/stream/CloudMusicUtilsTest.kt | 85 +++++++++ .../pixelplay/utils/ServerUrlUtilsTest.kt | 104 +++++++++++ 18 files changed, 633 insertions(+), 716 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactoryTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClientTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/ServerUrlUtilsTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt index afb84b3ea..f6b830452 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt @@ -1,171 +1,9 @@ package com.theveloper.pixelplay.data.ai.provider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit +class DeepSeekAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { -/** - * DeepSeek AI provider implementation - * Uses OpenAI-compatible API - */ -class DeepSeekAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_DEEPSEEK_MODEL = "deepseek-chat" - private const val BASE_URL = "https://api.deepseek.com" - } - - @Serializable - data class ChatMessage(val role: String, val content: String) - - @Serializable - data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - data class ChatChoice(val message: ChatMessage) - - @Serializable - data class ChatResponse(val choices: List) - - @Serializable - data class ModelItem(val id: String) - - @Serializable - data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_DEEPSEEK_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "DeepSeek", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("DeepSeek", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // DeepSeek estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_DEEPSEEK_MODEL - - private fun getDefaultModels(): List { - return listOf( - "deepseek-chat", - "deepseek-reasoner" - ) - } + override val providerName = "DeepSeek" + override val baseUrl = "https://api.deepseek.com" + override val defaultModel = "deepseek-chat" + override val defaultModels = listOf("deepseek-chat", "deepseek-reasoner") } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index 658906dd2..5f7503873 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -1,171 +1,30 @@ package com.theveloper.pixelplay.data.ai.provider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit /** * A generic AI client for OpenAI-compatible APIs (NVIDIA, Kimi, GLM, etc.) */ class GenericOpenAiClient( - private val apiKey: String, - private val baseUrl: String, + apiKey: String, + override val baseUrl: String, private val defaultModelId: String, - private val providerName: String = "OpenAI" -) : AiClient { - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 - ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { defaultModelId } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val requestBuilder = Request.Builder() - .url("${baseUrl.trimEnd('/')}/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - - if (providerName.equals("OpenRouter", ignoreCase = true)) { - requestBuilder.addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer") - requestBuilder.addHeader("X-Title", "PixelPlayer") - } - - val request = requestBuilder.post(body).build() + override val providerName: String = "OpenAI" +) : OpenAiCompatibleClient(apiKey) { - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() + override val defaultModel: String get() = defaultModelId + override val defaultModels: List get() = listOf(defaultModelId) - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = providerName, - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = providerName, - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable(providerName, e, resolvedModel) - } + override fun decorateRequest(builder: Request.Builder): Request.Builder { + if (providerName.equals("OpenRouter", ignoreCase = true)) { + builder.addHeader("HTTP-Referer", "https://github.com/theovilardo/PixelPlayer") + builder.addHeader("X-Title", "PixelPlayer") } + return builder } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Estimation for generic providers - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("${baseUrl.trimEnd('/')}/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext listOf(defaultModelId) - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { - !it.contains("whisper") && !it.contains("embed") && !it.contains("tts") - } - } catch (e: Exception) { - listOf(defaultModelId) - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - // Try a simple models list check as validation - val request = Request.Builder() - .url("${baseUrl.trimEnd('/')}/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } + + override fun filterModels(models: List): List = + models.filter { + !it.contains("whisper") && !it.contains("embed") && !it.contains("tts") } - } - - override fun getDefaultModel(): String = defaultModelId } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt index 0adf6cf70..bbd5a1f8d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt @@ -1,170 +1,17 @@ package com.theveloper.pixelplay.data.ai.provider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit +class GroqAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { -class GroqAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "llama-3.1-8b-instant" - private const val BASE_URL = "https://api.groq.com/openai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 + override val providerName = "Groq" + override val baseUrl = "https://api.groq.com/openai/v1" + override val defaultModel = "llama-3.1-8b-instant" + override val defaultModels = listOf( + "llama-3.1-8b-instant", + "llama-3.3-70b-versatile", + "mixtral-8x7b-32768", + "gemma2-9b-it" ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Groq", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Groq", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Groq doesn't provide a native token counting endpoint, so we estimate. - // Rule of thumb: 1 token ≈ 4 characters for English text. - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id }.filter { !it.contains("whisper") } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "llama-3.1-8b-instant", - "llama-3.3-70b-versatile", - "mixtral-8x7b-32768", - "gemma2-9b-it" - ) - } + override fun filterModels(models: List): List = + models.filter { !it.contains("whisper") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt index a4d166e2a..666c50540 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt @@ -1,169 +1,14 @@ package com.theveloper.pixelplay.data.ai.provider -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import java.util.concurrent.TimeUnit - -class MistralAiClient(private val apiKey: String) : AiClient { - - companion object { - private const val DEFAULT_MODEL = "mistral-large-latest" - private const val BASE_URL = "https://api.mistral.ai/v1" - } - - @Serializable - private data class ChatMessage(val role: String, val content: String) - - @Serializable - private data class ChatRequest( - val model: String, - val messages: List, - val temperature: Double = 0.7 +class MistralAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { + + override val providerName = "Mistral" + override val baseUrl = "https://api.mistral.ai/v1" + override val defaultModel = "mistral-large-latest" + override val defaultModels = listOf( + "mistral-large-latest", + "mistral-small-latest", + "open-mixtral-8x22b", + "open-mixtral-8x7b" ) - - @Serializable - private data class ChatChoice(val message: ChatMessage) - - @Serializable - private data class ChatResponse(val choices: List) - - @Serializable - private data class ModelItem(val id: String) - - @Serializable - private data class ModelsResponse(val data: List) - - private val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } - - override suspend fun generateContent( - model: String, - systemPrompt: String, - prompt: String, - temperature: Float - ): String { - return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { DEFAULT_MODEL } - val messagesList = mutableListOf() - if (systemPrompt.isNotBlank()) { - messagesList.add(ChatMessage(role = "system", content = systemPrompt)) - } - messagesList.add(ChatMessage(role = "user", content = prompt)) - - val requestBody = ChatRequest( - model = resolvedModel, - messages = messagesList, - temperature = temperature.toDouble() - ) - - val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) - val body = jsonBody.toRequestBody("application/json".toMediaType()) - - val request = Request.Builder() - .url("$BASE_URL/chat/completions") - .addHeader("Authorization", "Bearer $apiKey") - .addHeader("Content-Type", "application/json") - .post(body) - .build() - - try { - client.newCall(request).execute().use { response -> - val responseBody = response.body.string() - - if (!response.isSuccessful) { - throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = response.message, - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - - val chatResponse = json.decodeFromString(responseBody) - chatResponse.choices.firstOrNull()?.message?.content - ?: throw AiProviderSupport.createException( - providerName = "Mistral", - statusCode = response.code, - transportMessage = "Response had no content", - responseBody = responseBody, - requestedModel = resolvedModel - ) - } - } catch (e: Exception) { - throw AiProviderSupport.wrapThrowable("Mistral", e, resolvedModel) - } - } - } - - override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { - // Mistral estimation - return (systemPrompt.length + prompt.length) / 4 - } - - override suspend fun getAvailableModels(apiKey: String): List { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - return@withContext getDefaultModels() - } - - val responseBody = response.body.string() - val modelsResponse = json.decodeFromString(responseBody) - modelsResponse.data.map { it.id } - } catch (e: Exception) { - getDefaultModels() - } - } - } - - override suspend fun validateApiKey(apiKey: String): Boolean { - return withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url("$BASE_URL/models") - .addHeader("Authorization", "Bearer $apiKey") - .get() - .build() - - val response = client.newCall(request).execute() - response.isSuccessful - } catch (e: Exception) { - false - } - } - } - - override fun getDefaultModel(): String = DEFAULT_MODEL - - private fun getDefaultModels(): List { - return listOf( - "mistral-large-latest", - "mistral-small-latest", - "open-mixtral-8x22b", - "open-mixtral-8x7b" - ) - } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt new file mode 100644 index 000000000..ab8c8df95 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt @@ -0,0 +1,168 @@ +package com.theveloper.pixelplay.data.ai.provider + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +/** + * Base class for AI providers that expose an OpenAI-compatible chat/completions API. + * + * Subclasses only need to supply [providerName], [baseUrl], [defaultModel], + * [defaultModels], and optionally [modelFilter] or [decorateRequest]. + */ +abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { + + protected abstract val providerName: String + protected abstract val baseUrl: String + protected abstract val defaultModel: String + protected abstract val defaultModels: List + + @Serializable + protected data class ChatMessage(val role: String, val content: String) + + @Serializable + protected data class ChatRequest( + val model: String, + val messages: List, + val temperature: Double = 0.7 + ) + + @Serializable + protected data class ChatChoice(val message: ChatMessage) + + @Serializable + protected data class ChatResponse(val choices: List) + + @Serializable + protected data class ModelItem(val id: String) + + @Serializable + protected data class ModelsResponse(val data: List) + + protected val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + protected val json: Json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + protected open fun decorateRequest(builder: Request.Builder): Request.Builder = builder + + protected open fun filterModels(models: List): List = models + + override suspend fun generateContent( + model: String, + systemPrompt: String, + prompt: String, + temperature: Float + ): String { + return withContext(Dispatchers.IO) { + val resolvedModel = model.ifBlank { defaultModel } + val messagesList = mutableListOf() + if (systemPrompt.isNotBlank()) { + messagesList.add(ChatMessage(role = "system", content = systemPrompt)) + } + messagesList.add(ChatMessage(role = "user", content = prompt)) + + val requestBody = ChatRequest( + model = resolvedModel, + messages = messagesList, + temperature = temperature.toDouble() + ) + + val jsonBody = json.encodeToString(ChatRequest.serializer(), requestBody) + val body = jsonBody.toRequestBody("application/json".toMediaType()) + + val builder = Request.Builder() + .url("${baseUrl.trimEnd('/')}/chat/completions") + .addHeader("Authorization", "Bearer $apiKey") + .addHeader("Content-Type", "application/json") + + val request = decorateRequest(builder).post(body).build() + + try { + client.newCall(request).execute().use { response -> + val responseBody = response.body.string() + + if (!response.isSuccessful) { + throw AiProviderSupport.createException( + providerName = providerName, + statusCode = response.code, + transportMessage = response.message, + responseBody = responseBody, + requestedModel = resolvedModel + ) + } + + val chatResponse = json.decodeFromString(responseBody) + chatResponse.choices.firstOrNull()?.message?.content + ?: throw AiProviderSupport.createException( + providerName = providerName, + statusCode = response.code, + transportMessage = "Response had no content", + responseBody = responseBody, + requestedModel = resolvedModel + ) + } + } catch (e: Exception) { + throw AiProviderSupport.wrapThrowable(providerName, e, resolvedModel) + } + } + } + + override suspend fun countTokens(model: String, systemPrompt: String, prompt: String): Int { + return (systemPrompt.length + prompt.length) / 4 + } + + override suspend fun getAvailableModels(apiKey: String): List { + return withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("${baseUrl.trimEnd('/')}/models") + .addHeader("Authorization", "Bearer $apiKey") + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext defaultModels + } + + val responseBody = response.body.string() + val modelsResponse = json.decodeFromString(responseBody) + filterModels(modelsResponse.data.map { it.id }) + } + } catch (e: Exception) { + defaultModels + } + } + } + + override suspend fun validateApiKey(apiKey: String): Boolean { + return withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("${baseUrl.trimEnd('/')}/models") + .addHeader("Authorization", "Bearer $apiKey") + .get() + .build() + + client.newCall(request).execute().use { it.isSuccessful } + } catch (e: Exception) { + false + } + } + } + + override fun getDefaultModel(): String = defaultModel +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/jellyfin/model/JellyfinCredentials.kt b/app/src/main/java/com/theveloper/pixelplay/data/jellyfin/model/JellyfinCredentials.kt index 84514af91..bf472a68a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/jellyfin/model/JellyfinCredentials.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/jellyfin/model/JellyfinCredentials.kt @@ -1,8 +1,7 @@ package com.theveloper.pixelplay.data.jellyfin.model -import com.theveloper.pixelplay.data.stream.CloudStreamSecurity +import com.theveloper.pixelplay.utils.ServerUrlUtils import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull data class JellyfinCredentials( val serverUrl: String, @@ -29,35 +28,11 @@ data class JellyfinCredentials( get() = !accessToken.isNullOrBlank() && !userId.isNullOrBlank() val normalizedHttpUrlOrNull: HttpUrl? - get() { - val trimmed = serverUrl.trim().trimEnd('/') - // Auto-prepend https:// if no scheme is provided - val withScheme = if (!trimmed.startsWith("http://", ignoreCase = true) && - !trimmed.startsWith("https://", ignoreCase = true) - ) { - "https://$trimmed" - } else { - trimmed - } - return withScheme.toHttpUrlOrNull() - } + get() = ServerUrlUtils.normalizeHttpUrl(serverUrl) val normalizedServerUrl: String - get() = normalizedHttpUrlOrNull?.toString()?.trimEnd('/') ?: serverUrl.trim().trimEnd('/') + get() = ServerUrlUtils.normalizeServerUrl(serverUrl) - fun connectionValidationError(): String? { - val parsed = normalizedHttpUrlOrNull - ?: return "Invalid server URL format" - - if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) { - return "Server URL must not contain embedded credentials" - } - - // Warn about cleartext HTTP on public hosts - if (!parsed.isHttps && !CloudStreamSecurity.isLocalOrPrivateHost(parsed.host)) { - return "Use https:// for remote Jellyfin servers. HTTP is only allowed for local network addresses." - } - - return null - } + fun connectionValidationError(): String? = + ServerUrlUtils.connectionValidationError(serverUrl, "Jellyfin") } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/navidrome/model/NavidromeCredentials.kt b/app/src/main/java/com/theveloper/pixelplay/data/navidrome/model/NavidromeCredentials.kt index 2742fd5bd..8a4db443a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/navidrome/model/NavidromeCredentials.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/navidrome/model/NavidromeCredentials.kt @@ -1,8 +1,7 @@ package com.theveloper.pixelplay.data.navidrome.model +import com.theveloper.pixelplay.utils.ServerUrlUtils import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import com.theveloper.pixelplay.data.stream.CloudStreamSecurity /** * Represents authentication credentials for a Navidrome/Subsonic server. @@ -49,38 +48,26 @@ data class NavidromeCredentials( * Returns the parsed and normalized server URL, or null if it is invalid. */ val normalizedHttpUrlOrNull: HttpUrl? - get() { - val trimmed = serverUrl.trim().trimEnd('/') - // Auto-prepend https:// if no scheme is provided - val withScheme = if (!trimmed.startsWith("http://", ignoreCase = true) && - !trimmed.startsWith("https://", ignoreCase = true) - ) { - "https://$trimmed" - } else { - trimmed - } - return withScheme.toHttpUrlOrNull() - } + get() = ServerUrlUtils.normalizeHttpUrl(serverUrl) /** * Returns the normalized server URL (without trailing slash). */ val normalizedServerUrl: String - get() = normalizedHttpUrlOrNull?.toString()?.trimEnd('/') ?: serverUrl.trim().trimEnd('/') + get() = ServerUrlUtils.normalizeServerUrl(serverUrl) /** * Returns a validation error for connection setup, or null when the URL is acceptable. */ fun connectionValidationError(requireHttps: Boolean = true): String? { - val httpUrl = normalizedHttpUrlOrNull ?: return "Enter a valid server URL." - if (httpUrl.username.isNotEmpty() || httpUrl.password.isNotEmpty()) { - return "Server URL must not include embedded credentials." - } - if (requireHttps && !httpUrl.isHttps && - !CloudStreamSecurity.isLocalOrPrivateHost(httpUrl.host) - ) { - return "Use an https:// server URL for remote Navidrome/Subsonic servers. HTTP is only allowed for local network addresses." + if (!requireHttps) { + val httpUrl = normalizedHttpUrlOrNull ?: return "Enter a valid server URL." + if (httpUrl.username.isNotEmpty() || httpUrl.password.isNotEmpty()) { + return "Server URL must not include embedded credentials." + } + return null } - return null + return ServerUrlUtils.connectionValidationError(serverUrl, "Navidrome/Subsonic") + ?.let { if (it == "Invalid server URL format") "Enter a valid server URL." else it } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/EqualizerPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/EqualizerPreferencesRepository.kt index 9fbe6a162..f4f1a89b1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/EqualizerPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/EqualizerPreferencesRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.map import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -44,7 +45,8 @@ class EqualizerPreferencesRepository @Inject constructor( if (modeString != null) { try { EqualizerViewMode.valueOf(modeString) - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse equalizer view mode") EqualizerViewMode.SLIDERS } } else { @@ -71,7 +73,8 @@ class EqualizerPreferencesRepository @Inject constructor( decoded.isEmpty() -> List(10) { 0 } else -> decoded + List(10 - decoded.size) { 0 } } - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse equalizer custom bands") List(10) { 0 } } } else { @@ -120,7 +123,8 @@ class EqualizerPreferencesRepository @Inject constructor( if (jsonString != null) { try { json.decodeFromString>(jsonString) - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse custom presets") emptyList() } } else { @@ -133,7 +137,8 @@ class EqualizerPreferencesRepository @Inject constructor( if (jsonString != null) { try { json.decodeFromString>(jsonString) - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse pinned presets") EqualizerPreset.ALL_PRESETS.map { it.name } } } else { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt index 7f935a080..35182504f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt @@ -309,7 +309,8 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( contentResolver.openAssetFileDescriptor(uri, "r")?.use { afd -> afd.length != 0L } ?: false - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to verify content URI accessibility") false } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt index 69cb6573e..1ba0cff07 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.app.ActivityManager import android.content.Context import android.content.pm.PackageManager +import timber.log.Timber import android.media.AudioDeviceInfo import android.media.AudioFormat import android.media.AudioManager @@ -313,7 +314,8 @@ class DeviceCapabilitiesViewModel @Inject constructor( val instances = try { codecInfo.getCapabilitiesForType(codecInfo.supportedTypes.first { it.startsWith("audio/") }) .maxSupportedInstances - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to get codec capabilities for %s", codecInfo.name) -1 } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/FileExplorerStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/FileExplorerStateHolder.kt index b43ff8724..e55ab940e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/FileExplorerStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/FileExplorerStateHolder.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.content.Context import android.os.Environment import android.provider.MediaStore +import timber.log.Timber import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.utils.DirectoryRuleResolver import com.theveloper.pixelplay.utils.StorageInfo @@ -576,7 +577,8 @@ class FileExplorerStateHolder( } catch (error: CancellationException) { prefetchedDirectoryKeys.remove(targetKey) throw error - } catch (_: Throwable) { + } catch (e: Throwable) { + Timber.w(e, "Failed to prefetch directory %s", targetKey) prefetchedDirectoryKeys.remove(targetKey) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt index 87985fd6e..5a49929e0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt @@ -13,6 +13,7 @@ import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.repository.NoLyricsFoundException import com.theveloper.pixelplay.utils.LyricsImportSecurity import com.theveloper.pixelplay.utils.LyricsImportValidationResult +import timber.log.Timber import com.theveloper.pixelplay.utils.LyricsUtils import com.theveloper.pixelplay.utils.ValidatedLyricsImport import java.io.File @@ -135,7 +136,8 @@ class LyricsStateHolder @Inject constructor( } } catch (cancellation: CancellationException) { throw cancellation - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to load lyrics for song %s", song.title) null } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 8c5c620ef..489891ae5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers import java.io.OutputStreamWriter import android.content.Context +import timber.log.Timber import android.graphics.Bitmap import android.graphics.ImageDecoder import android.os.Build @@ -615,7 +616,7 @@ class PlaylistViewModel @Inject constructor( // Optional: Delete old file if it was a local file managed by us currentPlaylist.coverImageUri?.let { oldPath -> if (oldPath.contains("playlist_cover_")) { - try { File(oldPath).delete() } catch (e: Exception) {} + try { File(oldPath).delete() } catch (e: Exception) { Timber.w(e, "Failed to delete old playlist cover") } } } savedCoverPath = newPath @@ -624,7 +625,7 @@ class PlaylistViewModel @Inject constructor( // Explicitly removed currentPlaylist.coverImageUri?.let { oldPath -> if (oldPath.contains("playlist_cover_")) { - try { File(oldPath).delete() } catch (e: Exception) {} + try { File(oldPath).delete() } catch (e: Exception) { Timber.w(e, "Failed to delete old playlist cover") } } } savedCoverPath = null diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt b/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt new file mode 100644 index 000000000..5d7ea8f68 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/utils/ServerUrlUtils.kt @@ -0,0 +1,44 @@ +package com.theveloper.pixelplay.utils + +import com.theveloper.pixelplay.data.stream.CloudStreamSecurity +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * Shared URL normalization and validation for self-hosted media server credentials + * (Navidrome/Subsonic, Jellyfin, etc.). + */ +object ServerUrlUtils { + + fun normalizeHttpUrl(serverUrl: String): HttpUrl? { + val trimmed = serverUrl.trim().trimEnd('/') + val withScheme = if (!trimmed.startsWith("http://", ignoreCase = true) && + !trimmed.startsWith("https://", ignoreCase = true) + ) { + "https://$trimmed" + } else { + trimmed + } + return withScheme.toHttpUrlOrNull() + } + + fun normalizeServerUrl(serverUrl: String): String { + return normalizeHttpUrl(serverUrl)?.toString()?.trimEnd('/') + ?: serverUrl.trim().trimEnd('/') + } + + fun connectionValidationError(serverUrl: String, serverLabel: String = "server"): String? { + val parsed = normalizeHttpUrl(serverUrl) + ?: return "Invalid server URL format" + + if (parsed.username.isNotEmpty() || parsed.password.isNotEmpty()) { + return "Server URL must not contain embedded credentials." + } + + if (!parsed.isHttps && !CloudStreamSecurity.isLocalOrPrivateHost(parsed.host)) { + return "Use https:// for remote $serverLabel servers. HTTP is only allowed for local network addresses." + } + + return null + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactoryTest.kt new file mode 100644 index 000000000..0745d5727 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/AiClientFactoryTest.kt @@ -0,0 +1,83 @@ +package com.theveloper.pixelplay.data.ai.provider + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AiClientFactoryTest { + + private val factory = AiClientFactory() + + @Test + fun `createClient returns GeminiAiClient for GEMINI`() { + val client = factory.createClient(AiProvider.GEMINI, "test-key") + assertThat(client).isInstanceOf(GeminiAiClient::class.java) + } + + @Test + fun `createClient returns DeepSeekAiClient for DEEPSEEK`() { + val client = factory.createClient(AiProvider.DEEPSEEK, "test-key") + assertThat(client).isInstanceOf(DeepSeekAiClient::class.java) + } + + @Test + fun `createClient returns GroqAiClient for GROQ`() { + val client = factory.createClient(AiProvider.GROQ, "test-key") + assertThat(client).isInstanceOf(GroqAiClient::class.java) + } + + @Test + fun `createClient returns MistralAiClient for MISTRAL`() { + val client = factory.createClient(AiProvider.MISTRAL, "test-key") + assertThat(client).isInstanceOf(MistralAiClient::class.java) + } + + @Test + fun `createClient returns GenericOpenAiClient for NVIDIA`() { + val client = factory.createClient(AiProvider.NVIDIA, "test-key") + assertThat(client).isInstanceOf(GenericOpenAiClient::class.java) + } + + @Test + fun `createClient returns GenericOpenAiClient for OPENAI`() { + val client = factory.createClient(AiProvider.OPENAI, "test-key") + assertThat(client).isInstanceOf(GenericOpenAiClient::class.java) + } + + @Test + fun `createClient returns GenericOpenAiClient for OPENROUTER`() { + val client = factory.createClient(AiProvider.OPENROUTER, "test-key") + assertThat(client).isInstanceOf(GenericOpenAiClient::class.java) + } + + @Test(expected = IllegalArgumentException::class) + fun `createClient throws for blank API key`() { + factory.createClient(AiProvider.GEMINI, "") + } + + @Test(expected = IllegalArgumentException::class) + fun `createClient throws for whitespace-only API key`() { + factory.createClient(AiProvider.DEEPSEEK, " ") + } + + @Test + fun `all providers return correct default models`() { + for (provider in AiProvider.entries) { + val client = factory.createClient(provider, "test-key") + val defaultModel = client.getDefaultModel() + assertThat(defaultModel).isNotEmpty() + } + } + + @Test + fun `OpenAI-compatible clients inherit from OpenAiCompatibleClient`() { + val deepseek = factory.createClient(AiProvider.DEEPSEEK, "k") + val groq = factory.createClient(AiProvider.GROQ, "k") + val mistral = factory.createClient(AiProvider.MISTRAL, "k") + val generic = factory.createClient(AiProvider.OPENAI, "k") + + assertThat(deepseek).isInstanceOf(OpenAiCompatibleClient::class.java) + assertThat(groq).isInstanceOf(OpenAiCompatibleClient::class.java) + assertThat(mistral).isInstanceOf(OpenAiCompatibleClient::class.java) + assertThat(generic).isInstanceOf(OpenAiCompatibleClient::class.java) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClientTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClientTest.kt new file mode 100644 index 000000000..9f8e90eee --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClientTest.kt @@ -0,0 +1,69 @@ +package com.theveloper.pixelplay.data.ai.provider + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class OpenAiCompatibleClientTest { + + @Test + fun `DeepSeek has correct provider config`() { + val client = DeepSeekAiClient("key") + assertThat(client.getDefaultModel()).isEqualTo("deepseek-chat") + } + + @Test + fun `Groq has correct provider config`() { + val client = GroqAiClient("key") + assertThat(client.getDefaultModel()).isEqualTo("llama-3.1-8b-instant") + } + + @Test + fun `Groq filters out whisper models`() { + val client = GroqAiClient("key") + val filtered = client.filterModels( + listOf("llama-3.1-8b-instant", "whisper-large-v3", "mixtral-8x7b-32768") + ) + assertThat(filtered).containsExactly("llama-3.1-8b-instant", "mixtral-8x7b-32768") + } + + @Test + fun `Mistral has correct provider config`() { + val client = MistralAiClient("key") + assertThat(client.getDefaultModel()).isEqualTo("mistral-large-latest") + } + + @Test + fun `GenericOpenAiClient uses provided default model`() { + val client = GenericOpenAiClient( + apiKey = "key", + baseUrl = "https://api.example.com/v1", + defaultModelId = "custom-model", + providerName = "TestProvider" + ) + assertThat(client.getDefaultModel()).isEqualTo("custom-model") + } + + @Test + fun `GenericOpenAiClient filters embed and tts models`() { + val client = GenericOpenAiClient( + apiKey = "key", + baseUrl = "https://api.example.com/v1", + defaultModelId = "gpt-4o", + providerName = "TestProvider" + ) + val filtered = client.filterModels( + listOf("gpt-4o", "text-embedding-3-small", "tts-1", "whisper-1", "gpt-4o-mini") + ) + assertThat(filtered).containsExactly("gpt-4o", "gpt-4o-mini") + } + + @Test + fun `countTokens returns approximate token count`() { + val client = DeepSeekAiClient("key") + // 8 chars system + 12 chars prompt = 20 chars / 4 = 5 tokens + val tokens = kotlinx.coroutines.runBlocking { + client.countTokens("model", "12345678", "123456789012") + } + assertThat(tokens).isEqualTo(5) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt new file mode 100644 index 000000000..5883d54a1 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/stream/CloudMusicUtilsTest.kt @@ -0,0 +1,85 @@ +package com.theveloper.pixelplay.data.stream + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CloudMusicUtilsTest { + + @Test + fun `jsonToMap parses valid JSON object`() { + val result = CloudMusicUtils.jsonToMap("""{"key1":"val1","key2":"val2"}""") + assertThat(result).containsExactly("key1", "val1", "key2", "val2") + } + + @Test + fun `jsonToMap handles empty object`() { + val result = CloudMusicUtils.jsonToMap("{}") + assertThat(result).isEmpty() + } + + @Test + fun `jsonToMap returns empty string for null values`() { + val result = CloudMusicUtils.jsonToMap("""{"key":null}""") + assertThat(result).containsEntry("key", "") + } + + @Test + fun `parseArtistNames splits on comma`() { + val result = CloudMusicUtils.parseArtistNames("Artist A, Artist B") + assertThat(result).containsExactly("Artist A", "Artist B") + } + + @Test + fun `parseArtistNames splits on ampersand`() { + val result = CloudMusicUtils.parseArtistNames("A & B") + assertThat(result).containsExactly("A", "B") + } + + @Test + fun `parseArtistNames splits on slash`() { + val result = CloudMusicUtils.parseArtistNames("A/B/C") + assertThat(result).containsExactly("A", "B", "C") + } + + @Test + fun `parseArtistNames splits on semicolon`() { + val result = CloudMusicUtils.parseArtistNames("A;B") + assertThat(result).containsExactly("A", "B") + } + + @Test + fun `parseArtistNames splits on CJK comma`() { + val result = CloudMusicUtils.parseArtistNames("A、B") + assertThat(result).containsExactly("A", "B") + } + + @Test + fun `parseArtistNames deduplicates names`() { + val result = CloudMusicUtils.parseArtistNames("A, A, B") + assertThat(result).containsExactly("A", "B") + } + + @Test + fun `parseArtistNames returns Unknown Artist for blank input`() { + assertThat(CloudMusicUtils.parseArtistNames("")).containsExactly("Unknown Artist") + assertThat(CloudMusicUtils.parseArtistNames(" ")).containsExactly("Unknown Artist") + } + + @Test + fun `parseArtistNames returns single artist for simple name`() { + val result = CloudMusicUtils.parseArtistNames("Taylor Swift") + assertThat(result).containsExactly("Taylor Swift") + } + + @Test + fun `parseArtistNames handles mixed delimiters`() { + val result = CloudMusicUtils.parseArtistNames("A, B & C/D") + assertThat(result).containsExactly("A", "B", "C", "D") + } + + @Test + fun `parseArtistNames trims whitespace around names`() { + val result = CloudMusicUtils.parseArtistNames(" A , B ") + assertThat(result).containsExactly("A", "B") + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/ServerUrlUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/ServerUrlUtilsTest.kt new file mode 100644 index 000000000..62150c72c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/ServerUrlUtilsTest.kt @@ -0,0 +1,104 @@ +package com.theveloper.pixelplay.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ServerUrlUtilsTest { + + @Test + fun `normalizeHttpUrl adds https scheme when absent`() { + val result = ServerUrlUtils.normalizeHttpUrl("music.example.com") + assertThat(result).isNotNull() + assertThat(result!!.scheme).isEqualTo("https") + assertThat(result.host).isEqualTo("music.example.com") + } + + @Test + fun `normalizeHttpUrl preserves explicit http scheme`() { + val result = ServerUrlUtils.normalizeHttpUrl("http://192.168.1.100:8096") + assertThat(result).isNotNull() + assertThat(result!!.scheme).isEqualTo("http") + assertThat(result.host).isEqualTo("192.168.1.100") + assertThat(result.port).isEqualTo(8096) + } + + @Test + fun `normalizeHttpUrl preserves https scheme`() { + val result = ServerUrlUtils.normalizeHttpUrl("https://music.example.com") + assertThat(result).isNotNull() + assertThat(result!!.scheme).isEqualTo("https") + } + + @Test + fun `normalizeHttpUrl trims whitespace and trailing slashes`() { + val result = ServerUrlUtils.normalizeHttpUrl(" https://music.example.com/ ") + assertThat(result).isNotNull() + assertThat(result!!.host).isEqualTo("music.example.com") + } + + @Test + fun `normalizeHttpUrl returns null for empty string`() { + assertThat(ServerUrlUtils.normalizeHttpUrl("")).isNull() + } + + @Test + fun `normalizeServerUrl returns clean URL without trailing slash`() { + val result = ServerUrlUtils.normalizeServerUrl("https://music.example.com/") + assertThat(result).doesNotEndWith("/") + assertThat(result).startsWith("https://") + } + + @Test + fun `normalizeServerUrl falls back to trimmed input for invalid URLs`() { + val result = ServerUrlUtils.normalizeServerUrl("") + assertThat(result).isEmpty() + } + + @Test + fun `connectionValidationError returns null for valid https URL`() { + val error = ServerUrlUtils.connectionValidationError("https://music.example.com") + assertThat(error).isNull() + } + + @Test + fun `connectionValidationError rejects embedded credentials`() { + val error = ServerUrlUtils.connectionValidationError("https://user:pass@music.example.com") + assertThat(error).contains("credentials") + } + + @Test + fun `connectionValidationError rejects http on public hosts`() { + val error = ServerUrlUtils.connectionValidationError("http://music.example.com") + assertThat(error).contains("https") + } + + @Test + fun `connectionValidationError allows http on local network`() { + val error = ServerUrlUtils.connectionValidationError("http://192.168.1.100:8096") + assertThat(error).isNull() + } + + @Test + fun `connectionValidationError allows http on localhost`() { + val error = ServerUrlUtils.connectionValidationError("http://localhost:8096") + assertThat(error).isNull() + } + + @Test + fun `connectionValidationError allows http on 127-0-0-1`() { + val error = ServerUrlUtils.connectionValidationError("http://127.0.0.1:4533") + assertThat(error).isNull() + } + + @Test + fun `connectionValidationError uses serverLabel in message`() { + val error = ServerUrlUtils.connectionValidationError("http://public.example.com", "Jellyfin") + assertThat(error).contains("Jellyfin") + } + + @Test + fun `connectionValidationError rejects invalid URL`() { + val error = ServerUrlUtils.connectionValidationError("not a url at all ://") + assertThat(error).contains("Invalid") + } +} From 478d795ba351957296362b61518166aea5e16919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=D0=B0=D0=B5=20=D0=95u=D0=BD=D1=88=D0=B0?= <134977461+daedaevibin@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:13:58 +0000 Subject: [PATCH 2/4] Fix JVM signature clash: rename defaultModel to providerDefaultModel The abstract val 'defaultModel' generated a getDefaultModel() JVM getter that clashed with the AiClient interface's getDefaultModel() function. Renamed to 'providerDefaultModel' and 'providerDefaultModels' to avoid the accidental override. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../pixelplay/data/ai/provider/DeepSeekAiClient.kt | 4 ++-- .../data/ai/provider/GenericOpenAiClient.kt | 4 ++-- .../pixelplay/data/ai/provider/GroqAiClient.kt | 4 ++-- .../pixelplay/data/ai/provider/MistralAiClient.kt | 4 ++-- .../data/ai/provider/OpenAiCompatibleClient.kt | 12 ++++++------ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt index f6b830452..4ad6a1962 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/DeepSeekAiClient.kt @@ -4,6 +4,6 @@ class DeepSeekAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { override val providerName = "DeepSeek" override val baseUrl = "https://api.deepseek.com" - override val defaultModel = "deepseek-chat" - override val defaultModels = listOf("deepseek-chat", "deepseek-reasoner") + override val providerDefaultModel = "deepseek-chat" + override val providerDefaultModels = listOf("deepseek-chat", "deepseek-reasoner") } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt index 5f7503873..bd676cc05 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GenericOpenAiClient.kt @@ -12,8 +12,8 @@ class GenericOpenAiClient( override val providerName: String = "OpenAI" ) : OpenAiCompatibleClient(apiKey) { - override val defaultModel: String get() = defaultModelId - override val defaultModels: List get() = listOf(defaultModelId) + override val providerDefaultModel: String get() = defaultModelId + override val providerDefaultModels: List get() = listOf(defaultModelId) override fun decorateRequest(builder: Request.Builder): Request.Builder { if (providerName.equals("OpenRouter", ignoreCase = true)) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt index bbd5a1f8d..ee4f55b38 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GroqAiClient.kt @@ -4,8 +4,8 @@ class GroqAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { override val providerName = "Groq" override val baseUrl = "https://api.groq.com/openai/v1" - override val defaultModel = "llama-3.1-8b-instant" - override val defaultModels = listOf( + override val providerDefaultModel = "llama-3.1-8b-instant" + override val providerDefaultModels = listOf( "llama-3.1-8b-instant", "llama-3.3-70b-versatile", "mixtral-8x7b-32768", diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt index 666c50540..6fda0230a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/MistralAiClient.kt @@ -4,8 +4,8 @@ class MistralAiClient(apiKey: String) : OpenAiCompatibleClient(apiKey) { override val providerName = "Mistral" override val baseUrl = "https://api.mistral.ai/v1" - override val defaultModel = "mistral-large-latest" - override val defaultModels = listOf( + override val providerDefaultModel = "mistral-large-latest" + override val providerDefaultModels = listOf( "mistral-large-latest", "mistral-small-latest", "open-mixtral-8x22b", diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt index ab8c8df95..881f453d0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt @@ -20,8 +20,8 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { protected abstract val providerName: String protected abstract val baseUrl: String - protected abstract val defaultModel: String - protected abstract val defaultModels: List + protected abstract val providerDefaultModel: String + protected abstract val providerDefaultModels: List @Serializable protected data class ChatMessage(val role: String, val content: String) @@ -67,7 +67,7 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { temperature: Float ): String { return withContext(Dispatchers.IO) { - val resolvedModel = model.ifBlank { defaultModel } + val resolvedModel = model.ifBlank { providerDefaultModel } val messagesList = mutableListOf() if (systemPrompt.isNotBlank()) { messagesList.add(ChatMessage(role = "system", content = systemPrompt)) @@ -135,7 +135,7 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { client.newCall(request).execute().use { response -> if (!response.isSuccessful) { - return@withContext defaultModels + return@withContext providerDefaultModels } val responseBody = response.body.string() @@ -143,7 +143,7 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { filterModels(modelsResponse.data.map { it.id }) } } catch (e: Exception) { - defaultModels + providerDefaultModels } } } @@ -164,5 +164,5 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { } } - override fun getDefaultModel(): String = defaultModel + override fun getDefaultModel(): String = providerDefaultModel } From 801d9ae1a6441f19dfa10b75f186a1322953a20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=D0=B0=D0=B5=20=D0=95u=D0=BD=D1=88=D0=B0?= <134977461+daedaevibin@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:30:58 +0000 Subject: [PATCH 3/4] Additional quality, security, and performance improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Fix OkHttp response body leaks in GDriveApiService (4 methods) - Fix JSON injection in GDriveApiService.createFolder — use JSONObject builder instead of string interpolation - Harden GDriveApiService.getAuthHeader to require a token instead of silently sending 'Bearer ' with empty token Performance: - Cache 6 Regex patterns in LyricsRepositoryImpl companion object that were recompiled on every call to normalizeForMatch/timingVariantTokens/ looksLikeFlattenedWordByWordCache Thread safety: - Add @Volatile to GDriveStreamProxy's server, actualPort, startJob fields accessed from multiple threads Error handling: - Add Timber.w logging to 12 more silent catch blocks across backup (BackupManager, BackupWriter, BackupHistoryRepository, LegacyPayloadAdapter), database (PixelPlayDatabase migrations, SongEntity), network (NeteaseApiService, QQSignGenerator, QqMusicRepository), presentation (ThemeStateHolder, SongRemovalStateHolder), and utils (MediaMetadataRetrieverPool) Code quality: - Remove dead try/catch in ChangelogBottomSheet.openUrl that caught and redid the exact same operation - Make filterModels internal for testability Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ai/provider/OpenAiCompatibleClient.kt | 2 +- .../pixelplay/data/backup/BackupManager.kt | 7 +- .../data/backup/format/BackupWriter.kt | 4 +- .../backup/format/LegacyPayloadAdapter.kt | 3 +- .../backup/history/BackupHistoryRepository.kt | 7 +- .../data/database/PixelPlayDatabase.kt | 10 +-- .../pixelplay/data/database/SongEntity.kt | 4 +- .../pixelplay/data/gdrive/GDriveApiService.kt | 68 +++++++++++-------- .../data/gdrive/GDriveStreamProxy.kt | 6 +- .../data/network/netease/NeteaseApiService.kt | 3 +- .../data/qqmusic/QqMusicRepository.kt | 3 +- .../data/remote/qqmusic/QQSignGenerator.kt | 3 +- .../data/repository/LyricsRepositoryImpl.kt | 18 +++-- .../components/ChangelogBottomSheet.kt | 2 +- .../viewmodel/SongRemovalStateHolder.kt | 4 +- .../viewmodel/ThemeStateHolder.kt | 5 +- .../utils/MediaMetadataRetrieverPool.kt | 5 +- 17 files changed, 95 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt index 881f453d0..daa5a6e83 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/OpenAiCompatibleClient.kt @@ -58,7 +58,7 @@ abstract class OpenAiCompatibleClient(private val apiKey: String) : AiClient { protected open fun decorateRequest(builder: Request.Builder): Request.Builder = builder - protected open fun filterModels(models: List): List = models + internal open fun filterModels(models: List): List = models override suspend fun generateContent( model: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/BackupManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/BackupManager.kt index 6472ab11a..27afc146b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/BackupManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/BackupManager.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.data.backup import android.content.Context import android.net.Uri import android.os.Build +import timber.log.Timber import com.theveloper.pixelplay.data.backup.format.BackupReader import com.theveloper.pixelplay.data.backup.format.BackupWriter import com.theveloper.pixelplay.data.backup.history.BackupHistoryRepository @@ -66,7 +67,7 @@ class BackupManager @Inject constructor( // Build manifest val packageInfo = try { context.packageManager.getPackageInfo(context.packageName, 0) - } catch (_: Exception) { null } + } catch (e: Exception) { Timber.w(e, "Failed to get package info"); null } val manifest = BackupManifest( schemaVersion = BackupManifest.CURRENT_SCHEMA_VERSION, @@ -183,8 +184,8 @@ class BackupManager @Inject constructor( appVersion = plan.manifest.appVersion ) ) - } catch (_: Exception) { - // Non-critical; don't fail restore because of history persistence + } catch (e: Exception) { + Timber.w(e, "Failed to persist restore history entry") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/format/BackupWriter.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/format/BackupWriter.kt index 92b01734f..c9d5a36f1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/format/BackupWriter.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/format/BackupWriter.kt @@ -5,6 +5,7 @@ import android.net.Uri import com.google.gson.Gson import com.theveloper.pixelplay.data.backup.model.BackupManifest import com.theveloper.pixelplay.data.backup.model.BackupModuleInfo +import timber.log.Timber import com.theveloper.pixelplay.di.BackupGson import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -85,7 +86,8 @@ class BackupWriter @Inject constructor( } else { 1 } - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to count items in backup module JSON") 0 } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/format/LegacyPayloadAdapter.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/format/LegacyPayloadAdapter.kt index 486462f7c..96fd3771c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/format/LegacyPayloadAdapter.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/format/LegacyPayloadAdapter.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser +import timber.log.Timber import com.theveloper.pixelplay.data.backup.model.BackupManifest import com.theveloper.pixelplay.data.backup.model.BackupModuleInfo import com.theveloper.pixelplay.data.backup.model.DeviceInfo @@ -120,7 +121,7 @@ class LegacyPayloadAdapter @Inject constructor() { } else { 1 } - } catch (_: Exception) { 0 } + } catch (e: Exception) { Timber.w(e, "Failed to count legacy backup entries"); 0 } } private fun sha256(data: ByteArray): String { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/backup/history/BackupHistoryRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/backup/history/BackupHistoryRepository.kt index 2d1b03374..4deec0c1d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/backup/history/BackupHistoryRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/backup/history/BackupHistoryRepository.kt @@ -8,6 +8,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.theveloper.pixelplay.data.backup.model.BackupHistoryEntry import com.theveloper.pixelplay.di.BackupGson +import timber.log.Timber import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -30,7 +31,8 @@ class BackupHistoryRepository @Inject constructor( if (json != null) { try { gson.fromJson>(json, listType) - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse backup history") emptyList() } } else { @@ -68,7 +70,8 @@ class BackupHistoryRepository @Inject constructor( val json = preferences[BACKUP_HISTORY_KEY] ?: return emptyList() return try { gson.fromJson(json, listType) - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to read backup history") emptyList() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt index 094929dd9..947f17208 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.data.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.migration.Migration +import timber.log.Timber import androidx.sqlite.db.SupportSQLiteDatabase @Database( @@ -759,9 +760,8 @@ abstract class PixelPlayDatabase : RoomDatabase() { try { db.execSQL("ALTER TABLE songs ADD COLUMN date_added INTEGER NOT NULL DEFAULT 0") - } catch (_: Exception) { - // Some restored databases report the right version but still carry - // a drifted songs table. If ALTER TABLE did not stick, rebuild it. + } catch (e: Exception) { + Timber.w(e, "ALTER TABLE songs ADD date_added failed; will recreate table") } if ("date_added" !in getTableColumns(db, "songs")) { @@ -1133,8 +1133,8 @@ abstract class PixelPlayDatabase : RoomDatabase() { if ("disc_number" !in columns) { try { db.execSQL("ALTER TABLE songs ADD COLUMN disc_number INTEGER DEFAULT null") - } catch (_: Exception) { - // Restored/drifted databases may already contain a partially applied column. + } catch (e: Exception) { + Timber.w(e, "ALTER TABLE songs ADD disc_number failed; may already exist") } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt index 19f948b79..d0df39b26 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.data.database import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey +import timber.log.Timber import androidx.room.Index import androidx.room.PrimaryKey import com.theveloper.pixelplay.data.model.ArtistRef @@ -171,7 +172,8 @@ private fun parseArtistsJson(json: String?): List { isPrimary = obj.optBoolean("primary", false) ) } - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to parse artist refs JSON") emptyList() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveApiService.kt index e6ffdf81c..dd64dad4a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveApiService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveApiService.kt @@ -6,6 +6,8 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -31,7 +33,11 @@ class GDriveApiService @Inject constructor( fun hasToken(): Boolean = !accessToken.isNullOrBlank() - fun getAuthHeader(): String = "Bearer ${accessToken ?: ""}" + fun getAuthHeader(): String { + val token = accessToken + require(!token.isNullOrBlank()) { "GDrive access token not set" } + return "Bearer $token" + } fun getStreamUrl(fileId: String): String { return "${GDriveConstants.DRIVE_API_BASE}/files/$fileId?alt=media" @@ -94,8 +100,12 @@ class GDriveApiService @Inject constructor( */ suspend fun createFolder(name: String, parentId: String = "root"): String { return withContext(Dispatchers.IO) { - val json = """{"name":"$name","mimeType":"application/vnd.google-apps.folder","parents":["$parentId"]}""" - val body = json.toRequestBody("application/json".toMediaType()) + val jsonBody = JSONObject().apply { + put("name", name) + put("mimeType", "application/vnd.google-apps.folder") + put("parents", JSONArray().put(parentId)) + }.toString() + val body = jsonBody.toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("${GDriveConstants.DRIVE_API_BASE}/files") @@ -103,14 +113,15 @@ class GDriveApiService @Inject constructor( .post(body) .build() - val response = okHttpClient.newCall(request).execute() - val responseBody = response.body.string() - Timber.d("GDriveApi createFolder: code=${response.code}, body=${responseBody.take(200)}") + okHttpClient.newCall(request).execute().use { response -> + val responseBody = response.body.string() + Timber.d("GDriveApi createFolder: code=${response.code}, body=${responseBody.take(200)}") - if (!response.isSuccessful) { - throw Exception("Drive API error ${response.code}: $responseBody") + if (!response.isSuccessful) { + throw Exception("Drive API error ${response.code}: $responseBody") + } + responseBody } - responseBody } } @@ -136,14 +147,15 @@ class GDriveApiService @Inject constructor( .post(formBody) .build() - val response = okHttpClient.newCall(request).execute() - val responseBody = response.body.string() - Timber.d("GDriveApi exchangeAuthCode: code=${response.code}") + okHttpClient.newCall(request).execute().use { response -> + val responseBody = response.body.string() + Timber.d("GDriveApi exchangeAuthCode: code=${response.code}") - if (!response.isSuccessful) { - throw Exception("Token exchange failed ${response.code}: $responseBody") + if (!response.isSuccessful) { + throw Exception("Token exchange failed ${response.code}: $responseBody") + } + responseBody } - responseBody } } @@ -168,14 +180,15 @@ class GDriveApiService @Inject constructor( .post(formBody) .build() - val response = okHttpClient.newCall(request).execute() - val responseBody = response.body.string() - Timber.d("GDriveApi refreshToken: code=${response.code}") + okHttpClient.newCall(request).execute().use { response -> + val responseBody = response.body.string() + Timber.d("GDriveApi refreshToken: code=${response.code}") - if (!response.isSuccessful) { - throw Exception("Token refresh failed ${response.code}: $responseBody") + if (!response.isSuccessful) { + throw Exception("Token refresh failed ${response.code}: $responseBody") + } + responseBody } - responseBody } } @@ -194,14 +207,15 @@ class GDriveApiService @Inject constructor( .get() .build() - val response = okHttpClient.newCall(request).execute() - val responseBody = response.body.string() - Timber.d("GDriveApi GET ${url.take(80)}: code=${response.code}") + okHttpClient.newCall(request).execute().use { response -> + val responseBody = response.body.string() + Timber.d("GDriveApi GET ${url.take(80)}: code=${response.code}") - if (!response.isSuccessful) { - throw Exception("Drive API error ${response.code}: $responseBody") + if (!response.isSuccessful) { + throw Exception("Drive API error ${response.code}: $responseBody") + } + responseBody } - responseBody } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveStreamProxy.kt b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveStreamProxy.kt index 7014ddd09..9ad245b58 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveStreamProxy.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveStreamProxy.kt @@ -51,10 +51,10 @@ class GDriveStreamProxy @Inject constructor( ) } - private var server: EmbeddedServer? = null - private var actualPort: Int = 0 + @Volatile private var server: EmbeddedServer? = null + @Volatile private var actualPort: Int = 0 private val proxyScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var startJob: Job? = null + @Volatile private var startJob: Job? = null fun isReady(): Boolean = actualPort > 0 diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/netease/NeteaseApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/netease/NeteaseApiService.kt index 5c5b4da22..26370d178 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/network/netease/NeteaseApiService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/netease/NeteaseApiService.kt @@ -313,7 +313,8 @@ class NeteaseApiService @Inject constructor() { resp = call() } resp - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "$TAG: retry after session warm-up failed") resp } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/qqmusic/QqMusicRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/qqmusic/QqMusicRepository.kt index 32487f396..697489281 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/qqmusic/QqMusicRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/qqmusic/QqMusicRepository.kt @@ -613,7 +613,8 @@ class QqMusicRepository @Inject constructor( val result = String(decoded, Charsets.UTF_8) // Verify the decoded result contains actual readable text if (result.isNotBlank() && !result.contains('\u0000')) result else input - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to decode base64 artist name") input } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt b/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt index 3a1995d00..7563c0619 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/remote/qqmusic/QQSignGenerator.kt @@ -118,7 +118,8 @@ class QQSignGenerator(private val context: Context) { if (raw == null || raw == "null" || raw.isBlank()) return null return try { if (raw.startsWith('"')) JSONArray("[$raw]").getString(0) else raw - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to decode evaluate result") raw } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt index 05e357d05..b1941fdef 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt @@ -136,6 +136,12 @@ class LyricsRepositoryImpl @Inject constructor( private val BRACKETED_QUALIFIER_REGEX = Regex("""[\(\[\{\uFF08\uFF3B\uFF5B\u3010\u300E\u300C\u3014\u3008\u300A]([^)\]\}\uFF09\uFF3D\uFF5D\u3011\u300F\u300D\u3015\u3009\u300B]*)[\)\]\}\uFF09\uFF3D\uFF5D\u3011\u300F\u300D\u3015\u3009\u300B]""") private val FEATURE_QUALIFIER_REGEX = Regex("""\b(feat(?:uring)?|ft)\.?\b""", RegexOption.IGNORE_CASE) private val TITLE_SEPARATOR_REGEX = Regex("""\s*[-\u2013\u2014:\uFF0D\u00B7\u30FB]\s*""") + private val MASH_UP_REGEX = Regex("""\bmash\s+up\b""") + private val DIACRITICS_REGEX = Regex("""\p{Mn}+""") + private val APOSTROPHE_REGEX = Regex("""[\u2019'`]""") + private val NON_ALNUM_REGEX = Regex("""[^\p{L}\p{N}]+""") + private val WHITESPACE_COLLAPSE_REGEX = Regex("""\s+""") + private val LONG_LATIN_RUN_REGEX = Regex("""[A-Za-z]{10,}""") private val TIMING_VARIANT_KEYWORDS = setOf( "remix", "mix", @@ -799,7 +805,7 @@ class LyricsRepositoryImpl @Inject constructor( .filter { it in TIMING_VARIANT_KEYWORDS } .toMutableSet() - if (Regex("""\bmash\s+up\b""").containsMatchIn(normalized)) { + if (MASH_UP_REGEX.containsMatchIn(normalized)) { variants += "mashup" } if ("versus" in tokens || "vs" in tokens) { @@ -854,14 +860,14 @@ class LyricsRepositoryImpl @Inject constructor( private fun normalizeForMatch(value: String): String { val withoutDiacritics = Normalizer.normalize(value.lowercase(Locale.ROOT), Normalizer.Form.NFD) - .replace(Regex("""\p{Mn}+"""), "") + .replace(DIACRITICS_REGEX, "") return withoutDiacritics .replace("&", " and ") - .replace(Regex("""[\u2019'`]"""), "") - .replace(Regex("""[^\p{L}\p{N}]+"""), " ") + .replace(APOSTROPHE_REGEX, "") + .replace(NON_ALNUM_REGEX, " ") .trim() - .replace(Regex("""\s+"""), " ") + .replace(WHITESPACE_COLLAPSE_REGEX, " ") } private fun isUnknownArtist(value: String): Boolean = @@ -1142,7 +1148,7 @@ class LyricsRepositoryImpl @Inject constructor( val text = line.line if (text.isBlank() || text.any { it.isWhitespace() }) continue - val hasLongLatinRun = Regex("[A-Za-z]{10,}").containsMatchIn(text) + val hasLongLatinRun = LONG_LATIN_RUN_REGEX.containsMatchIn(text) if (hasLongLatinRun) { suspiciousLines += 1 if (suspiciousLines >= 2) return true diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt index 5d8a3860d..0818f90fc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/ChangelogBottomSheet.kt @@ -346,7 +346,7 @@ fun VersionBadge( } private fun openUrl(context: Context, url: String) { - val uri = try { url.toUri() } catch (_: Throwable) { url.toUri() } + val uri = url.toUri() val intent = Intent(Intent.ACTION_VIEW, uri) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt index 91e2d858f..8399c559d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongRemovalStateHolder.kt @@ -2,6 +2,7 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.app.Activity import android.content.IntentSender +import timber.log.Timber import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song @@ -92,7 +93,8 @@ class SongRemovalStateHolder @Inject constructor( dialog.show() userChoice.await() - } catch (_: Exception) { + } catch (e: Exception) { + Timber.w(e, "Failed to show song removal confirmation dialog") false } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt index a2bb2213a..10428c1a4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt @@ -3,6 +3,7 @@ package com.theveloper.pixelplay.presentation.viewmodel import android.net.Uri import android.content.ComponentCallbacks2 import android.os.Trace +import timber.log.Timber import androidx.compose.ui.graphics.Color import com.theveloper.pixelplay.data.preferences.AlbumArtColorAccuracy import com.theveloper.pixelplay.data.preferences.AlbumArtPaletteStyle @@ -181,8 +182,8 @@ class ThemeStateHolder @Inject constructor( paletteStyle = currentPaletteStyle, colorAccuracyLevel = currentPaletteAccuracy ) - } catch (_: Exception) { - // Ignore or log + } catch (e: Exception) { + Timber.w(e, "Failed to generate color scheme for %s", uriString) } finally { val targets = synchronized(pendingAlbumColorSchemeLock) { pendingAlbumColorSchemeTargets.remove(uriString)?.toList().orEmpty() diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/MediaMetadataRetrieverPool.kt b/app/src/main/java/com/theveloper/pixelplay/utils/MediaMetadataRetrieverPool.kt index 8ea8dffb5..c81d500f0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/MediaMetadataRetrieverPool.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/MediaMetadataRetrieverPool.kt @@ -1,6 +1,7 @@ package com.theveloper.pixelplay.utils import android.media.MediaMetadataRetriever +import timber.log.Timber import java.util.concurrent.atomic.AtomicInteger /** @@ -40,8 +41,8 @@ object MediaMetadataRetrieverPool { internal fun release(retriever: MediaMetadataRetriever) { try { retriever.release() - } catch (_: Exception) { - // Ignore release errors + } catch (e: Exception) { + Timber.w(e, "Failed to release MediaMetadataRetriever") } finally { createdCount.decrementAndGet() } From b568ba3521b50568c25a9c592daaa68ae66e922a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?D=D0=B0=D0=B5=20=D0=95u=D0=BD=D1=88=D0=B0?= <134977461+daedaevibin@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:42:31 +0000 Subject: [PATCH 4/4] Fix Netty version, parameter ordering, imports, and Spanish comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Update Netty constraint from non-existent 4.2.28.Final to latest actual release 4.2.15.Final to address Dependabot alerts #36, #37, #38 Bug fix: - Fix AbsoluteSmoothCornerShape parameter ordering in NavBarCornerRadiusScreen non-full-width preview — smoothness params were misaligned with their corner radius params Code quality: - Sort imports alphabetically in GenreCategoriesGrid; add explicit imports for FilledIconButton, Icon, IconButtonDefaults to replace inline FQN usage - Translate all Spanish comments to English in SongEntity, PlaylistViewModel, OtherShapes, GenreCategoriesGrid Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../pixelplay/data/database/SongEntity.kt | 16 ++- .../screens/NavBarCornerRadiusScreen.kt | 10 +- .../search/components/GenreCategoriesGrid.kt | 36 +++---- .../viewmodel/PlaylistViewModel.kt | 5 +- .../pixelplay/utils/shapes/OtherShapes.kt | 99 +++++++------------ gradle/libs.versions.toml | 2 +- 6 files changed, 70 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt index d0df39b26..4368e0f1d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/SongEntity.kt @@ -58,15 +58,13 @@ object SourceType { entity = AlbumEntity::class, parentColumns = ["id"], childColumns = ["album_id"], - onDelete = ForeignKey.CASCADE // Si un álbum se borra, sus canciones también + onDelete = ForeignKey.CASCADE // Deleting an album cascades to its songs ), ForeignKey( entity = ArtistEntity::class, parentColumns = ["id"], childColumns = ["artist_id"], - onDelete = ForeignKey.SET_NULL // Si un artista se borra, el artist_id de la canción se pone a null - // o podrías elegir CASCADE si las canciones no deben existir sin artista. - // SET_NULL es más flexible si las canciones pueden ser de "Artista Desconocido". + onDelete = ForeignKey.SET_NULL // Nullify artist_id when artist is deleted (keeps song as "Unknown Artist") ) ] ) @@ -77,7 +75,7 @@ data class SongEntity( @ColumnInfo(name = "artist_id") val artistId: Long, // Primary artist ID for backward compatibility @ColumnInfo(name = "album_artist") val albumArtist: String? = null, // Album artist from metadata @ColumnInfo(name = "album_name") val albumName: String, - @ColumnInfo(name = "album_id") val albumId: Long, // index = true eliminado + @ColumnInfo(name = "album_id") val albumId: Long, @ColumnInfo(name = "content_uri_string") val contentUriString: String, @ColumnInfo(name = "album_art_uri_string") val albumArtUriString: String?, @ColumnInfo(name = "duration") val duration: Long, @@ -214,9 +212,8 @@ fun List.toSongs(): List { return this.map { it.toSong() } } -// El modelo Song usa id como String, pero la entidad lo necesita como Long (de MediaStore) -// El modelo Song no tiene filePath, así que no se puede mapear desde ahí directamente. -// filePath y parentDirectoryPath se poblarán desde MediaStore en el SyncWorker. +// Song model uses String id but the entity needs Long (from MediaStore). +// filePath and parentDirectoryPath are populated from MediaStore in SyncWorker. fun Song.toEntity(filePathFromMediaStore: String, parentDirFromMediaStore: String): SongEntity { return SongEntity( id = this.id.toLong(), @@ -254,8 +251,7 @@ data class SongSummary( val duration: Long ) -// Sobrecarga o alternativa si los paths no están disponibles o no son necesarios al convertir de Modelo a Entidad -// (menos probable que se use si la entidad siempre requiere los paths) +// Fallback when file paths are unavailable during Song-to-Entity conversion. fun Song.toEntityWithoutPaths(): SongEntity { return SongEntity( id = this.id.toLong(), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/NavBarCornerRadiusScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/NavBarCornerRadiusScreen.kt index 199bbfe47..608709172 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/NavBarCornerRadiusScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/NavBarCornerRadiusScreen.kt @@ -342,13 +342,13 @@ fun NavBarCornerRadiusContent( } else { AbsoluteSmoothCornerShape( cornerRadiusTL = 10.dp, - smoothnessAsPercentBL = 60, - cornerRadiusTR = 10.dp, - smoothnessAsPercentBR = 60, - cornerRadiusBR = sliderValue.dp, smoothnessAsPercentTL = 60, + cornerRadiusTR = 10.dp, + smoothnessAsPercentTR = 60, cornerRadiusBL = sliderValue.dp, - smoothnessAsPercentTR = 60 + smoothnessAsPercentBL = 60, + cornerRadiusBR = sliderValue.dp, + smoothnessAsPercentBR = 60 ) } ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/search/components/GenreCategoriesGrid.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/search/components/GenreCategoriesGrid.kt index a81e6a4ef..7d9a3ec47 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/search/components/GenreCategoriesGrid.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/search/components/GenreCategoriesGrid.kt @@ -1,8 +1,12 @@ package com.theveloper.pixelplay.presentation.screens.search.components -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -22,9 +26,13 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ViewList +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.GridView import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,13 +42,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Genre import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.components.SmartImage @@ -49,18 +62,7 @@ import com.theveloper.pixelplay.presentation.components.resolveNavBarOccupiedHei import com.theveloper.pixelplay.presentation.utils.GenreIconProvider import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import com.theveloper.pixelplay.ui.theme.LocalPixelPlayDarkTheme -import androidx.compose.ui.res.stringResource -import com.theveloper.pixelplay.R import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.ui.draw.scale -import androidx.compose.foundation.border -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material3.Icon -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween @OptIn(UnstableApi::class) @Composable @@ -137,15 +139,15 @@ fun GenreCategoriesGrid( label = "shapeAnimation" ) - androidx.compose.material3.FilledIconButton( + FilledIconButton( onClick = { playerViewModel.toggleGenreViewMode() }, - colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors( + colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), shape = RoundedCornerShape(animatedCornerRadius.value) ) { - androidx.compose.material3.Icon( + Icon( imageVector = if (isGridView) Icons.AutoMirrored.Rounded.ViewList else Icons.Rounded.GridView, contentDescription = "Toggle Grid/List View" ) @@ -274,7 +276,7 @@ private fun GenreCard( ) } - // Imagen del género en esquina inferior derecha + // Genre image in bottom-right corner Box( modifier = Modifier .size(90.dp) @@ -292,7 +294,7 @@ private fun GenreCard( ) } - // Nombre del género en esquina superior izquierda + // Genre name in top-left corner Column( modifier = Modifier .align(Alignment.TopStart) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 489891ae5..45721f166 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt @@ -247,7 +247,7 @@ class PlaylistViewModel @Inject constructor( PlaylistSongsOrderMode.Manual -> songsList } - // La actualización del UI se hace en el hilo principal + // Update UI on the main thread _uiState.update { it.copy( currentPlaylistDetails = playlist, @@ -269,8 +269,7 @@ class PlaylistViewModel @Inject constructor( currentPlaylistDetails = null, currentPlaylistSongs = emptyList() ) - } // Mantener isLoading en false - // Opcional: podrías establecer un error o un estado específico de "no encontrado" + } } } } catch (e: Exception) { diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/shapes/OtherShapes.kt b/app/src/main/java/com/theveloper/pixelplay/utils/shapes/OtherShapes.kt index d5dc24bd3..27243f125 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/shapes/OtherShapes.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/shapes/OtherShapes.kt @@ -33,10 +33,7 @@ fun createHexagonShape() = object : Shape { } } -// Implementaciones similares para createRoundedTriangleShape, createSemiCircleShape -// (Estas pueden ser más complejas dependiendo del diseño exacto que quieras) - -// Ejemplo simple de triángulo redondeado (tendrías que ajustarlo) +// Simple rounded triangle shape fun createRoundedTriangleShape() = object : Shape { override fun createOutline(size: androidx.compose.ui.geometry.Size, layoutDirection: LayoutDirection, density: Density): Outline { return Outline.Generic(Path().apply { @@ -46,21 +43,9 @@ fun createRoundedTriangleShape() = object : Shape { path.lineTo(0f, size.height) path.close() - // Para redondear las esquinas, podrías usar CornerPathEffect en un Modifier.drawBehind, - // o construir la forma con arcos y líneas. Clipping con Shape solo recorta. - // Una forma simple es usar un RoundRect para el clip con radios grandes, pero no es un triángulo real. - // Para un triángulo redondeado preciso, tendrías que dibujar la forma con arcos. - // Por ahora, dejaremos el clip simple o necesitarás una implementación más avanzada. - - // Alternativa simple: clip a un rectángulo con esquinas redondeadas - // return Outline.Rounded(RoundRect(0f, 0f, size.width, size.height, CornerRadius(16f, 16f))) - // Esto no es un triángulo. Necesitas una implementación real de forma de triángulo redondeado. - // Por simplicidad en este ejemplo, usaremos formas más estándar o de la librería. - - // Para el ejemplo, simplemente usaremos un triángulo básico sin redondeo complejo en el clip. - // Si necesitas triángulos redondeados reales, busca implementaciones más avanzadas. + // Basic triangle without rounded corners for clipping. moveTo(size.width / 2f, 0f) - lineTo(size.width, size.height * 0.8f) // Ajuste para que la base no llegue hasta abajo + lineTo(size.width, size.height * 0.8f) lineTo(0f, size.height * 0.8f) close() @@ -68,25 +53,23 @@ fun createRoundedTriangleShape() = object : Shape { } } -// Ejemplo simple de Semicírculo (tendrías que ajustarlo) +// Simple semicircle shape fun createSemiCircleShape() = object : Shape { override fun createOutline(size: androidx.compose.ui.geometry.Size, layoutDirection: LayoutDirection, density: Density): Outline { return Outline.Generic(Path().apply { arcTo( - rect = Rect(0f, 0f, size.width, size.width), // Un círculo basado en el ancho + rect = Rect(0f, 0f, size.width, size.width), startAngleDegrees = 0f, sweepAngleDegrees = 180f, forceMoveTo = false ) - lineTo(size.width / 2f, size.width / 2f) // Dibuja una línea hacia el centro si necesitas cerrarlo como pastel - close() // Opcional: cierra la forma + lineTo(size.width / 2f, size.width / 2f) + close() }) } } -/** - * Crea una forma de hexágono con esquinas redondeadas. - */ +/** Hexagon shape with rounded corners. */ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { override fun createOutline( size: Size, @@ -99,7 +82,7 @@ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { val radius = min(width, height) / 2f val cornerRadiusPx = with(density) { cornerRadius.toPx() } - // Puntos del hexágono sin redondear + // Unrounded hexagon vertices val points = (0..5).map { i -> val angle = PI / 3 * i Offset( @@ -108,7 +91,7 @@ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { ) } - // Movemos al primer punto con un offset para empezar el arco + // Move to first point offset for arc start moveTo(points[0].x + cornerRadiusPx * cos(PI / 3.0).toFloat(), points[0].y + cornerRadiusPx * sin(PI / 3.0).toFloat()) for (i in 0..5) { @@ -116,10 +99,10 @@ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { val p2 = points[(i + 1) % 6] val p3 = points[(i + 2) % 6] - // Línea hacia el punto de inicio del arco + // Line to arc start point lineTo(p2.x - cornerRadiusPx * cos(PI / 3.0).toFloat(), p2.y - cornerRadiusPx * sin(PI / 3.0).toFloat()) - // Arco en la esquina + // Corner arc arcTo( rect = Rect( left = p2.x - cornerRadiusPx, @@ -127,8 +110,8 @@ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { right = p2.x + cornerRadiusPx, bottom = p2.y + cornerRadiusPx ), - startAngleDegrees = (i * 60 + 30).toFloat(), // Ángulo de inicio del arco - sweepAngleDegrees = 60f, // Ángulo del arco + startAngleDegrees = (i * 60 + 30).toFloat(), + sweepAngleDegrees = 60f, forceMoveTo = false ) } @@ -137,10 +120,7 @@ fun createRoundedHexagonShape(cornerRadius: Dp) = object : Shape { } } -/** - * Crea una forma de triángulo con esquinas redondeadas. - * Implementación simple para clipping. - */ +/** Rounded-corner triangle shape for clipping. */ fun createRoundedTriangleShape(cornerRadius: Dp) = object : Shape { override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { return Outline.Generic(Path().apply { @@ -148,43 +128,38 @@ fun createRoundedTriangleShape(cornerRadius: Dp) = object : Shape { val height = size.height val cornerRadiusPx = with(density) { cornerRadius.toPx() } - // Puntos del triángulo - val p1 = Offset(width / 2f, 0f) // Superior - val p2 = Offset(width, height) // Inferior derecha - val p3 = Offset(0f, height) // Inferior izquierda + // Triangle vertices + val p1 = Offset(width / 2f, 0f) // Top + val p2 = Offset(width, height) // Bottom-right + val p3 = Offset(0f, height) // Bottom-left - // Para simplificar el redondeo en el clip, usaremos arcos. - // Esto no es un triángulo perfecto con arcos tangentes, sino un enfoque práctico para clipping. - - // Calcula puntos de control para los arcos + // Control points for corner arcs val control12 = Offset(p1.x + (p2.x - p1.x) * 0.8f, p1.y + (p2.y - p1.y) * 0.8f) val control23 = Offset(p2.x + (p3.x - p2.x) * 0.2f, p2.y + (p3.y - p2.y) * 0.2f) val control31 = Offset(p3.x + (p1.x - p3.x) * 0.8f, p3.y + (p1.y - p3.y) * 0.8f) - moveTo(p1.x, p1.y + cornerRadiusPx * 2) // Empieza un poco más abajo del vértice superior + moveTo(p1.x, p1.y + cornerRadiusPx * 2) - // Arco superior derecha + // Top-right arc quadraticTo(p1.x, p1.y, p1.x + cornerRadiusPx * sqrt(2f), p1.y + cornerRadiusPx * sqrt(2f)) lineTo(p2.x - cornerRadiusPx * sqrt(2f), p2.y - cornerRadiusPx * sqrt(2f)) - // Arco inferior derecha + // Bottom-right arc quadraticTo(p2.x, p2.y, p2.x - cornerRadiusPx * sqrt(2f), p2.y + cornerRadiusPx * sqrt(2f)) lineTo(p3.x + cornerRadiusPx * sqrt(2f), p3.y + cornerRadiusPx * sqrt(2f)) - // Arco inferior izquierda + // Bottom-left arc quadraticTo(p3.x, p3.y, p3.x + cornerRadiusPx * sqrt(2f), p3.y - cornerRadiusPx * sqrt(2f)) lineTo(p1.x - cornerRadiusPx * sqrt(2f), p1.y + cornerRadiusPx * sqrt(2f)) - close() // Cierra la forma + close() }) } } -/** - * Crea una forma de semicírculo con una base ligeramente redondeada. - */ +/** Semicircle shape with a slightly rounded base. */ fun createSemiCircleShape(cornerRadius: Dp) = object : Shape { override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { return Outline.Generic(Path().apply { @@ -193,40 +168,40 @@ fun createSemiCircleShape(cornerRadius: Dp) = object : Shape { val radius = width / 2f val cornerRadiusPx = with(density) { cornerRadius.toPx() } - // Arco superior (semicírculo) + // Top semicircle arc arcTo( - rect = Rect(0f, 0f, width, width), // Un círculo basado en el ancho + rect = Rect(0f, 0f, width, width), startAngleDegrees = 0f, sweepAngleDegrees = 180f, forceMoveTo = false ) - // Base (línea con arcos en los extremos) + // Base line with arcs at both ends val startBaseX = 0f + cornerRadiusPx val endBaseX = width - cornerRadiusPx - val baseY = width / 2f // La base está a la mitad del diámetro del círculo + val baseY = width / 2f - lineTo(endBaseX, baseY) // Línea hacia el final de la base + lineTo(endBaseX, baseY) - // Arco inferior derecho + // Bottom-right arc arcTo( rect = Rect(endBaseX - cornerRadiusPx, baseY - cornerRadiusPx, endBaseX + cornerRadiusPx, baseY + cornerRadiusPx), startAngleDegrees = 90f, - sweepAngleDegrees = -90f, // Arco hacia abajo + sweepAngleDegrees = -90f, forceMoveTo = false ) - lineTo(startBaseX, baseY + cornerRadiusPx) // Línea inferior + lineTo(startBaseX, baseY + cornerRadiusPx) - // Arco inferior izquierdo + // Bottom-left arc arcTo( rect = Rect(startBaseX - cornerRadiusPx, baseY - cornerRadiusPx, startBaseX + cornerRadiusPx, baseY + cornerRadiusPx), startAngleDegrees = 180f, - sweepAngleDegrees = -90f, // Arco hacia abajo + sweepAngleDegrees = -90f, forceMoveTo = false ) - close() // Cierra la forma + close() }) } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e653436ec..b6efb2156 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ junit5 = "6.1.0" kuromoji = "0.9.0" pinyin4j = "2.5.1" securityCrypto = "1.1.0" -netty = "4.2.28.Final" +netty = "4.2.15.Final" bouncycastle = "1.84" commons-lang3 = "3.20.0" jdom2 = "2.0.6.1"