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..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 @@ -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 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 658906dd2..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 @@ -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 providerDefaultModel: String get() = defaultModelId + override val providerDefaultModels: 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..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 @@ -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 providerDefaultModel = "llama-3.1-8b-instant" + override val providerDefaultModels = 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..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 @@ -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 providerDefaultModel = "mistral-large-latest" + override val providerDefaultModels = 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..daa5a6e83 --- /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 providerDefaultModel: String + protected abstract val providerDefaultModels: 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 + + internal 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 { providerDefaultModel } + 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 providerDefaultModels + } + + val responseBody = response.body.string() + val modelsResponse = json.decodeFromString(responseBody) + filterModels(modelsResponse.data.map { it.id }) + } + } catch (e: Exception) { + providerDefaultModels + } + } + } + + 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 = providerDefaultModel +} 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..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 @@ -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 @@ -57,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") ) ] ) @@ -76,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, @@ -171,7 +170,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() } } @@ -212,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(), @@ -252,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/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/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/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/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/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/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/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/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/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..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 @@ -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 @@ -246,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, @@ -268,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) { @@ -615,7 +615,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 +624,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/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() } 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/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/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") + } +} 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"