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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/release-notes/2.0.4-nightly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Convx 2.0.4 Nightly

Pre-release build from `main`. Nightly builds may be unstable. Please report issues in the Convx GitHub issue tracker.

### Which APK?
- **`convx-2.0.4-nightly.apk`**: for almost every phone (64-bit).
- **`convx-arm32-2.0.4-nightly.apk`**: for 32-bit-only phones.

### New in 2.0.4
- Lyrics: the active line has a seekbar-inspired chrome highlight, timed words glow as they animate, and the style is named Convx Lyrics. Custom fonts and lyric sizing are available in settings.
- Add-ons request the chosen audio quality before using a stream URL attached to search results, so lossless formats can be selected when the provider offers them, with playable fallbacks when it does not.
- Apple Music import opens in a dialog from Accounts & Services, and settings search includes every settings page and its keywords.
- Player icon presets can be reused across controls without reopening the file picker.

### Also included from 2.0.3
- Spotify login no longer identifies the sign-in page as an embedded WebView.
- Discord login reads the token through an iframe when page storage is unavailable.
- A 32-bit (arm32) APK is available for phones that cannot install the regular build.

### Also included from 2.0.2 and 2.0.1
- Fixed a playback crash caused by the playback-stats tracker.
- Playlist import and library fixes, feed refresh and pagination fixes, and reliability improvements across playback, navigation, lyrics, and appearance settings.
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ NEW-UI/

# Reference checkouts read during the BitChord/ArchiveTune parity work.
# Cloned repos with their own .git — never ours to commit.
BitChord/
/BitChord/
ArchiveTune/
Flamingo/
/Flamingo/
News/
fastlane/
merge_strings.py
Expand Down
12 changes: 12 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
Convx bundles source vendored from third-party open-source projects.

## Lyrics providers

The provider clients and lyric parsers in lyrics/.../lyrics/bitchord are
adapted from BitChord (https://github.com/kushagrasinghx/BitChord),
Copyright its contributors, licensed under GPL-3.0. See LICENSE.

## Flamingo lyrics and player controls

The renderer in app/.../ui/component/flamingo and player chrome values
are adapted from Flamingo (https://github.com/shouryadixitisverycool/Flamingo),
by Yos-X and contributors, licensed under GPL-3.0. See LICENSE.

## hig module

Portions of the `hig/` module are vendored from compose-hig
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ android {
versionCode = appVersionCode
versionName = appVersionName

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = providers.gradleProperty("convxTestRunner")
.orElse("androidx.test.runner.AndroidJUnitRunner").get()
vectorDrawables.useSupportLibrary = true

// LastFM API keys from local.properties or environment variables
Expand Down Expand Up @@ -354,7 +355,6 @@ dependencies {


implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
Expand Down
7 changes: 7 additions & 0 deletions app/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<instrumentation
android:name="com.convx.music.network.NetworkSecurityInstrumentation"
android:targetPackage="com.convx.music.debug"
android:label="Network security regression checks" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.convx.music.network

import android.app.Activity
import android.app.Instrumentation
import android.os.Bundle
import android.security.NetworkSecurityPolicy
import com.convx.music.artistvideo.ArtistVideoCanvasProvider
import com.convx.music.canvas.AppleMusicCatalog
import com.music.lastfm.LastFM
import com.music.lrclib.LrcLib
import com.music.spotify.Spotify
import io.ktor.client.HttpClient
import io.ktor.client.plugins.expectSuccess
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout

/** Runs against the installed debug app, whose Android trust policy JVM tests cannot exercise. */
class NetworkSecurityInstrumentation : Instrumentation() {
override fun onCreate(arguments: Bundle?) {
super.onCreate(arguments)
start()
}

override fun onStart() {
val report = StringBuilder()
try {
val policy = NetworkSecurityPolicy.getInstance()
check(policy.isCleartextTrafficPermitted("localhost"))
check(!policy.isCleartextTrafficPermitted("open.spotify.com"))
runBlocking {
val targets = listOf(
Triple(Spotify, "getGqlClient", "https://api-partner.spotify.com/"),
Triple(LrcLib, "getClient", "https://lrclib.net/"),
Triple(AppleMusicCatalog, "getClient", "https://amp-api.music.apple.com/"),
Triple(LastFM, "getClient", "https://ws.audioscrobbler.com/2.0/"),
Triple(ArtistVideoCanvasProvider, "getClient", "https://www.apple.com/"),
)
for ((provider, getter, url) in targets) {
val method = provider.javaClass.declaredMethods.first {
it.name == getter || it.name.startsWith(getter + "$")
}.apply { isAccessible = true }
val client = method.invoke(provider) as HttpClient
check(client.engine.javaClass.simpleName == "OkHttpEngine") {
"${provider.javaClass.simpleName} must use Android-aware TLS"
}
withTimeout(20_000) {
val response = client.get(url) { expectSuccess = false }
// Any HTTP response, including an unauthenticated 401/404, proves TLS completed.
response.bodyAsText()
report.appendLine("${provider.javaClass.simpleName}: HTTP ${response.status.value}")
}
}
}
finish(Activity.RESULT_OK, Bundle().apply { putString("stream", report.toString()) })
} catch (failure: Exception) {
finish(Activity.RESULT_CANCELED, Bundle().apply {
putString("stream", report.toString() + "FAILED: ${failure.javaClass.simpleName}: ${failure.message}")
})
}
}
}
1 change: 1 addition & 0 deletions app/src/main/kotlin/com/convx/music/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class App : Application(), SingletonImageLoader.Factory {

override fun onCreate() {
super.onCreate()
com.music.spotify.SpotifyAuth.initialize(this)
// The recovery activity runs in its own process. Do not reopen the database,
// preferences, native caches or playback integrations that may have crashed.
val processName = if (Build.VERSION.SDK_INT >= 28) {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/kotlin/com/convx/music/AppNavItems.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ fun rememberAppNavItems(): AppNavItems {
} else {
screens
}
visible.filter { it != Screens.Radio || it.route !in hiddenRoutes }
visible.filter { it == Screens.Search || it.route !in hiddenRoutes }
}
// Explore is only shown in the floating navigation bar, and is online-only.
val floatingNavigationItems = remember(navigationItems, localOnlyMode, hiddenRoutes) {
Expand Down
13 changes: 11 additions & 2 deletions app/src/main/kotlin/com/convx/music/AppRootContent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,8 @@ internal fun AppRootContent(
}
val tabOpenedFromShortcut = remember {
when (activity.intent?.action) {
MainActivity.ACTION_SEARCH -> NavigationTab.LIBRARY
MainActivity.ACTION_LIBRARY -> NavigationTab.SEARCH
MainActivity.ACTION_SEARCH -> NavigationTab.SEARCH
MainActivity.ACTION_LIBRARY -> NavigationTab.LIBRARY
else -> null
}
}
Expand Down Expand Up @@ -420,6 +420,15 @@ internal fun AppRootContent(
val navigationItemRoutes = remember(floatingNavigationItems) {
floatingNavigationItems.map { it.route }.toSet()
}
LaunchedEffect(currentRoute, navigationItems) {
val route = currentRoute ?: return@LaunchedEffect
if (route in Screens.MainRoutes && navigationItems.none { it.route == route }) {
navController.navigate(navigationItems.first().route) {
popUpTo(route) { inclusive = true }
launchSingleTop = true
}
}
}

val isKeyboardOpen = WindowInsets.isImeVisible
val shouldShowNavigationBar = remember(currentRoute) {
Expand Down
56 changes: 40 additions & 16 deletions app/src/main/kotlin/com/convx/music/ai/AiChatClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import com.convx.music.utils.dataStore
import com.convx.music.utils.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resumeWithException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
Expand All @@ -23,7 +33,7 @@ import org.json.JSONObject

/** Sends chat-completions requests using the configured provider URL, model, and API key. */
object AiChatClient {
private val client = OkHttpClient()
private val client = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).build()

/** Returns the assistant response, or null if the request fails. */
suspend fun complete(
Expand Down Expand Up @@ -62,26 +72,40 @@ object AiChatClient {
})
}.toString().toRequestBody("application/json".toMediaType())

val request = Request.Builder()
.url(baseUrl)
.addHeader("Authorization", "Bearer $apiKey")
.post(requestBody)
.build()

try {
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorBody = response.body.string().take(300)
onLog("AI request failed: ${response.code} $errorBody")
return@withContext null
val request = Request.Builder()
.url(baseUrl)
.addHeader("Authorization", "Bearer $apiKey")
.post(requestBody)
.build()
client.newCall(request).awaitResponse().use { response ->
if (!response.isSuccessful) {
onLog("AI request failed: HTTP ${response.code}")
return@withContext null
}
val responseString = response.body.string()
currentCoroutineContext().ensureActive()
val responseJson = JSONObject(responseString)
val choices = responseJson.optJSONArray("choices") ?: return@withContext null
choices.optJSONObject(0)?.optJSONObject("message")?.optString("content")
}
val responseString = response.body.string()
val responseJson = JSONObject(responseString)
val choices = responseJson.optJSONArray("choices") ?: return@withContext null
choices.optJSONObject(0)?.optJSONObject("message")?.optString("content")
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
onLog("Network error: ${e.message}")
null
}
}

private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(object : Callback {
override fun onFailure(call: Call, error: IOException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) { _, value, _ -> value.close() }
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.convx.music.artistvideo

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.cache.HttpCache
import io.ktor.client.plugins.compression.ContentEncoding
Expand All @@ -29,7 +29,7 @@ object ArtistVideoCanvasProvider {
}

private val client by lazy {
HttpClient(CIO) {
HttpClient(OkHttp) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) {
connectTimeoutMillis = 15_000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ val FetchedModulesKey = stringPreferencesKey("fetchedModules")

val LyricsAnimationStyleKey = stringPreferencesKey("lyricsAnimationStyle")
enum class LyricsAnimationStyle {
FLAMINGO,
NONE,
FADE,
GLOW,
Expand All @@ -798,6 +799,8 @@ enum class LyricsAnimationStyle {
}

val LyricsTextSizeKey = floatPreferencesKey("lyricsTextSize")
val LyricsFontKey = stringPreferencesKey("lyricsFont")
val LyricsCustomFontKey = stringPreferencesKey("lyricsCustomFont")
val LyricsLineSpacingKey = floatPreferencesKey("lyricsLineSpacing")

val PlayerVolumeKey = floatPreferencesKey("playerVolume")
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/kotlin/com/convx/music/eq/data/EQProfileImport.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.convx.music.eq.data

import java.io.InputStream

internal object EQProfileImport {
private const val MAX_BYTES = 64 * 1024

fun read(input: InputStream): ParametricEQ {
val bytes = ByteArray(MAX_BYTES + 1)
var count = 0
while (count < bytes.size) {
val read = input.read(bytes, count, bytes.size - count)
if (read == -1) break
if (read == 0) {
val next = input.read()
if (next == -1) break
bytes[count++] = next.toByte()
} else count += read
}
require(count <= MAX_BYTES) { "EQ file is too large" }
val profile = ParametricEQParser.parseText(String(bytes, 0, count, Charsets.UTF_8).removePrefix("\uFEFF"))
require(ParametricEQParser.validate(profile).isEmpty()) { "Invalid EQ profile" }
return profile
}
}
28 changes: 28 additions & 0 deletions app/src/main/kotlin/com/convx/music/lyrics/LyricsFont.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.convx.music.lyrics

import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import android.graphics.Typeface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/** Imported faces are loaded once, away from the UI thread, and reused by every line. */
@Composable
fun rememberLyricsFont(selection: String): FontFamily {
val context = LocalContext.current
val family by produceState<FontFamily>(FontFamily.Default, selection) {
value = when (selection) {
"serif" -> FontFamily.Serif
"monospace" -> FontFamily.Monospace
"rounded" -> FontFamily(Typeface.create("sans-serif-rounded", Typeface.NORMAL))
else -> if (selection.startsWith("custom-")) withContext(Dispatchers.IO) {
runCatching {
require(selection.matches(Regex("custom-\\d+\\.font")))
FontFamily(Typeface.createFromFile(context.filesDir.resolve("lyrics-fonts").resolve(selection)))
}.getOrDefault(FontFamily.Default)
} else FontFamily.Default
}
}
return family
}
Loading
Loading