From 1d17a4964d97c55e5db319dc6643ca678cd4ee16 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 27 Dec 2025 10:16:12 -0800 Subject: [PATCH 1/6] feat: Add CI/CD workflow and initial changelog --- .github/workflows/release.yml | 58 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 7 +++++ gradlew | 0 3 files changed, 65 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md mode change 100644 => 100755 gradlew diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d246ad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 19 + uses: actions/setup-java@v4 + with: + java-version: '19' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew assembleRelease + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: odesli-android + path: odesli/build/outputs/apk/release/*.apk + + - name: Get version from CHANGELOG + id: changelog + uses: mindsers/changelog-reader-action@v2 + with: + validation_level: warn + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create Release + if: github.ref == 'refs/heads/main' + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.changelog.outputs.version }} + name: Release ${{ steps.changelog.outputs.version }} + body: ${{ steps.changelog.outputs.changes }} + artifacts: "artifacts/*.apk" + token: ${{ secrets.GITHUB_TOKEN }} + allowUpdates: true + artifactErrorsFailBuild: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c8b2612 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## v1.0.0 + +- Initial Release +- Support for converting between streaming services +- Automatically copy links to clipboard diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 246e1b0d701cca0e9e4cbc4b423569d0780cea81 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 27 Dec 2025 10:22:10 -0800 Subject: [PATCH 2/6] feat: Add Odesli page URL output support (v1.1.0) --- CHANGELOG.md | 11 ++++++++++- .../java/com/prochy/odesliandroid/activity/Main.kt | 6 +++++- .../java/com/prochy/odesliandroid/activity/Share.kt | 11 ++++++++++- .../com/prochy/odesliandroid/utils/MusicProviders.kt | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b2612..dde6f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## v1.0.0 +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [v1.1.0] + +- Add support for converting links to an Odesli URL + +## [v1.0.0] - Initial Release - Support for converting between streaming services diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt index 572b22a..360073a 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt @@ -343,7 +343,11 @@ fun OdesliLayout() { val title = songData.entitiesByUniqueId[outputService]?.title val artist = songData.entitiesByUniqueId[outputService]?.artistName val service = getLabelFromService(outputService) - val link = songData.linksByPlatform[outputService]?.url + val link = if (outputService == "odesli") { + songData.pageUrl + } else { + songData.linksByPlatform[outputService]?.url + } val type = songData.entitiesByUniqueId[songData.entitiesByUniqueId.keys.first()]?.type ?: "" Utils.SongInfo( diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt index 2b19dec..d3237bc 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt @@ -85,6 +85,11 @@ class Share : ComponentActivity() { receivedLink = it } } + intent?.action == Intent.ACTION_SEND -> { + intent.getStringExtra(Intent.EXTRA_TEXT)?.let { + receivedLink = it + } + } } if (receivedLink.isBlank()) { finish() @@ -125,7 +130,11 @@ class Share : ComponentActivity() { ).show() finish() } - val platformLink = data.linksByPlatform[service]?.url + val platformLink = if (service == "odesli") { + data.pageUrl + } else { + data.linksByPlatform[service]?.url + } if (!platformLink.isNullOrBlank()) { val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Link", platformLink) diff --git a/odesli/src/main/java/com/prochy/odesliandroid/utils/MusicProviders.kt b/odesli/src/main/java/com/prochy/odesliandroid/utils/MusicProviders.kt index 7d7dba7..d426d9b 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/utils/MusicProviders.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/utils/MusicProviders.kt @@ -15,6 +15,7 @@ enum class MusicProviders(val label: String, val service: String) { Deezer("Deezer", "deezer"), Itunes("iTunes", "itunes"), Napster("Napster", "napster"), + Odesli("Odesli Page", "odesli"), Pandora("Pandora", "pandora"), Soundcloud("SoundCloud", "soundcloud"), Spotify("Spotify", "spotify"), From c113e9e7b17994f6b9d271e54cc480d5774a78b9 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 5 Jan 2026 02:47:24 -0800 Subject: [PATCH 3/6] feat(v1.1.2): improve sharing, fix api hangs, and secure signing - Fix: Share to 'Odesli Page' fails when 'Auto Copy Link' is off - Fix: App hangs when Odesli API fails or returns no data - Feat: Add feedback toast when 'Auto Copy Link' matches - Chore: Bump version to v1.1.2 - Build: Configure secure release signing using GitHub Secrets - Build: Ignore keystore files and robustly handle signing config failures - Refactor: Remove unused context parameters in RetrofitClient - Docs: Update README with building and signing instructions --- .github/workflows/release.yml | 14 +++ .gitignore | 4 + CHANGELOG.md | 9 ++ README.md | 96 ++++++++++++++----- odesli/build.gradle.kts | 24 ++++- .../com/prochy/odesliandroid/activity/Main.kt | 5 + .../prochy/odesliandroid/activity/Share.kt | 27 +++++- .../odesliandroid/utils/RetrofitClient.kt | 18 ++-- .../com/prochy/odesliandroid/utils/Utils.kt | 18 +++- odesli/src/main/res/values/strings.xml | 1 + 10 files changed, 173 insertions(+), 43 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d246ad..6d2d019 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,21 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Decode Keystore + if: github.ref == 'refs/heads/main' + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + run: | + if [ -n "$RELEASE_KEYSTORE_BASE64" ]; then + echo "$RELEASE_KEYSTORE_BASE64" | tr -d '\r\n ' | base64 -d -i > release.keystore + fi + - name: Build with Gradle + env: + RELEASE_KEYSTORE_PATH: ../release.keystore + RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: ./gradlew assembleRelease - name: Upload APK diff --git a/.gitignore b/.gitignore index 2cf9b31..182c8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ .cxx local.properties /app/release + +# Keystore files +*.keystore +*.jks diff --git a/CHANGELOG.md b/CHANGELOG.md index dde6f18..7d56813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.1.2] + +- Improvement: Better feedback message when "Auto Copy Link" copies a link ("Link copied") + +## [v1.1.1] + +- Fixed: Share to "Odesli Page" failing when "Auto Copy Link" is off +- Fixed: App hanging when Odesli API fails or returns no data + ## [v1.1.0] - Add support for converting links to an Odesli URL diff --git a/README.md b/README.md index 838eb42..38e0f0d 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,87 @@ # odesli-android - A simple app that uses the Odesli API to convert songs/albums to many streaming services\ + +A simple app that uses the Odesli API to convert songs/albums to many streaming services\ [Download](https://github.com/prochy-exe/odesli-android/releases/latest) # Compatibility - Thanks to the simplicity of this app, it supports all types of devices within the one APK. + +Thanks to the simplicity of this app, it supports all types of devices within the one APK. # How does it work? - This app uses the Odesli API to convert links to any streaming service of your choosing.\ + +This app uses the Odesli API to convert links to any streaming service of your choosing.\ Generally speaking this conversion process lasts max 10 seconds.\ The request to [Odesli API](https://odesli.co/) is sent using the [Retrofit library](https://github.com/square/retrofit), the [Coil library](https://coil-kt.github.io/coil/) is responsible for asynchronously rendering the song/album cover.\ The Odesli API also has support for country codes to make sure the song or album you are trying to convert will be available for you. That means that the app has to figure out your country in some capacity, however as you might have noticed, I have decided to not ask for location directly and obtain the country code using other means which will be explained in the next paragraph.\ This app is can be used in 2 major ways: - * By copying and converting the link directly into the app - * By sharing the link to the app\ - This option allows you to easily and quickly share the song/album you want to convert to another streaming service.\ - Behavior of this option is customizable via the settings - * Preferred service\ + +- By copying and converting the link directly into the app +- By sharing the link to the app\ + This option allows you to easily and quickly share the song/album you want to convert to another streaming service.\ + Behavior of this option is customizable via the settings + - Preferred service\ This service will be used when sharing a song or an album into the app - * Auto-copy\ + - Auto-copy\ This option will automatically copy the link into your clipboard without showing the UI - * Show card when the song or album wasn't found on the preferred service\ - This option will allow you to pick a different service right away, otherwise a toast will be shown informing you that the preferred service doesn't have the song or album you are trying to convert + - Show card when the song or album wasn't found on the preferred service\ + This option will allow you to pick a different service right away, otherwise a toast will be shown informing you that the preferred service doesn't have the song or album you are trying to convert # Obtaining your country code - Instead of relying on your location which might feel unnecessary, and it totally is, I decide to use the TelephonyService, provided by Android, for devices with a SIM card, and other devices will make a request to [IPinfo](https://ipinfo.io), which is a 3rd party service that allows obtaining basic location information about you.\ - **!! I DO NOT GATHER THIS INFORMATION, IT'S BEING DIRECTLY PASSED TO THE ODESLI API !!** + +Instead of relying on your location which might feel unnecessary, and it totally is, I decide to use the TelephonyService, provided by Android, for devices with a SIM card, and other devices will make a request to [IPinfo](https://ipinfo.io), which is a 3rd party service that allows obtaining basic location information about you.\ + **!! I DO NOT GATHER THIS INFORMATION, IT'S BEING DIRECTLY PASSED TO THE ODESLI API !!** # Screenshots - ![Screenshots](./github_assets/screenshots.png "Screenshots") - - Left to right: - * Main UI - * Settings popup - * Main UI convert - * Share UI - -# Libraries/APIs used - * [Odesli/Songlink](https://odesli.co/) - * [Coil](https://coil-kt.github.io/coil/) - * [Retrofit](https://github.com/square/retrofit) - * [IPinfo](https://ipinfo.io) \ No newline at end of file +![Screenshots](./github_assets/screenshots.png "Screenshots") + +Left to right: + +- Main UI +- Settings popup +- Main UI convert +- Share UI + +- [Retrofit](https://github.com/square/retrofit) +- [IPinfo](https://ipinfo.io) + +# Building and Signing + +This project uses a secure signing configuration that supports both local development and CI/CD without committing the keystore to the repository. + +### Local Builds + +To build signed releases locally, you need to generate a keystore file: + +1. Generate the keystore (ensure Java/JDK is installed): + + ```bash + keytool -genkey -v -keystore odesli/release.keystore -alias odesli -keyalg RSA -keysize 2048 -validity 10000 -storepass android -keypass android -dname "CN=Odesli Android, OU=Mobile, O=PixP, L=Mountain View, S=California, C=US" + ``` + + _Note: Place the `release.keystore` file inside the `odesli/` directory._ + +2. Build the release APK: + ```bash + ./gradlew assembleRelease + ``` + The build script will automatically detect the `odesli/release.keystore` file. + +### CI/CD (GitHub Actions) + +For GitHub Actions to build signed APKs, you must configure the following **Repository Secrets** (Settings > Secrets and variables > Actions): + +1. **`RELEASE_KEYSTORE_BASE64`**: The base64 encoded content of your keystore file. + Generate this by running: + + ```bash + base64 -i odesli/release.keystore -o - + ``` + + (Copy the output string) + +2. **`RELEASE_KEYSTORE_PASSWORD`**: `android` +3. **`RELEASE_KEY_ALIAS`**: `odesli` +4. **`RELEASE_KEY_PASSWORD`**: `android` + +The workflow will decode the secret into a file during the build process, ensuring your key remains secure. diff --git a/odesli/build.gradle.kts b/odesli/build.gradle.kts index 063ea2e..47a98df 100644 --- a/odesli/build.gradle.kts +++ b/odesli/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.prochy.odesliandroid" minSdk = 21 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -21,6 +21,24 @@ android { } } + signingConfigs { + create("release") { + val keystoreFile = System.getenv("RELEASE_KEYSTORE_PATH")?.let { file(it) } ?: file("release.keystore") + val keystorePassword = (System.getenv("RELEASE_KEYSTORE_PASSWORD") ?: "android").trim() + val keyAlias = (System.getenv("RELEASE_KEY_ALIAS") ?: "odesli").trim() + val keyPassword = (System.getenv("RELEASE_KEY_PASSWORD") ?: "android").trim() + + if (keystoreFile.exists() && keystoreFile.length() > 0) { + storeFile = keystoreFile + storePassword = keystorePassword + this.keyAlias = keyAlias + this.keyPassword = keyPassword + } else { + println("Signing Config: Release keystore not found or empty. Falling back to debug signing.") + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -28,7 +46,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("release") } debug { isMinifyEnabled = false diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt index 360073a..585ca9a 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt @@ -266,6 +266,11 @@ fun OdesliLayout() { receivedLinks = false triggeredRequest = true Utils.getMusicData(text, context) { data -> + if (data == null) { + triggeredRequest = false + Toast.makeText(context, context.getString(R.string.unexpected_error), Toast.LENGTH_SHORT).show() + return@getMusicData + } songData = data triggeredRequest = false receivedLinks = true diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt index d3237bc..03d487e 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt @@ -116,6 +116,15 @@ class Share : ComponentActivity() { ) if (service.isNotBlank() && autoCopy) { Utils.getMusicData(receivedLink, this) { data -> + if (data == null) { + Toast.makeText( + this, + this.getString(R.string.unexpected_error), + Toast.LENGTH_SHORT + ).show() + finish() + return@getMusicData + } val type = data.entitiesByUniqueId[data.entitiesByUniqueId.keys.first()]?.type ?: "" val errorString: String = if (type == "song") { this.getString(R.string.song_not_found_error_popup) @@ -139,6 +148,12 @@ class Share : ComponentActivity() { val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Link", platformLink) clipboard.setPrimaryClip(clip) + // Feedback for successful copy + Toast.makeText( + this, + this.getString(R.string.link_copied), + Toast.LENGTH_SHORT + ).show() finish() } else { if (showOnFailed) { @@ -285,6 +300,12 @@ fun ShareActivityLayout(receivedLink: String) { ) ) } Utils.getMusicData(receivedLink, LocalContext.current) { data -> + if (data == null) { + triggeredRequest.value = false + Toast.makeText(context, context.getString(R.string.unexpected_error), Toast.LENGTH_SHORT).show() + (context as? ComponentActivity)?.finish() + return@getMusicData + } triggeredRequest.value = false receivedLinks.value = true receivedData.value = data @@ -339,7 +360,11 @@ fun ShareActivityLayout(receivedLink: String) { val title = receivedData.value.entitiesByUniqueId[outputService]?.title val artist = receivedData.value.entitiesByUniqueId[outputService]?.artistName val service = getLabelFromService(outputService) - val link = receivedData.value.linksByPlatform[outputService]?.url + val link = if (outputService == "odesli") { + receivedData.value.pageUrl + } else { + receivedData.value.linksByPlatform[outputService]?.url + } val type = receivedData.value.entitiesByUniqueId[receivedData.value.entitiesByUniqueId.keys.first()]?.type ?: "" Utils.SongInfo( thumbnail = thumbnail.toString(), diff --git a/odesli/src/main/java/com/prochy/odesliandroid/utils/RetrofitClient.kt b/odesli/src/main/java/com/prochy/odesliandroid/utils/RetrofitClient.kt index ae8c809..39c1cf5 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/utils/RetrofitClient.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/utils/RetrofitClient.kt @@ -1,7 +1,5 @@ package com.prochy.odesliandroid.utils -import android.content.Context -import android.widget.Toast import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -10,7 +8,7 @@ import retrofit2.converter.gson.GsonConverterFactory class RetrofitClient { - fun getData(context: Context, link: String, countryCode: String, callback: (OdesliData) -> Unit) { + fun getData(link: String, countryCode: String, callback: (OdesliData?) -> Unit) { val retrofit: Retrofit = Retrofit.Builder().baseUrl("https://api.song.link/").addConverterFactory( GsonConverterFactory.create()).build() @@ -19,17 +17,19 @@ class RetrofitClient { call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if(response.isSuccessful){ - val data: OdesliData = response.body() as OdesliData + val data: OdesliData? = response.body() callback(data) + } else { + callback(null) } } override fun onFailure(call: Call, t: Throwable) { - Toast.makeText(context, "Odesli request Failed", Toast.LENGTH_SHORT).show() + callback(null) } }) } - fun getCountry(context: Context, callback: (LocationData) -> Unit) { + fun getCountry(callback: (LocationData?) -> Unit) { val retrofit: Retrofit = Retrofit.Builder().baseUrl("https://ipinfo.io").addConverterFactory( GsonConverterFactory.create()).build() val service: LocationService = retrofit.create(LocationService::class.java) @@ -37,12 +37,14 @@ class RetrofitClient { call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { if(response.isSuccessful){ - val data: LocationData = response.body() as LocationData + val data: LocationData? = response.body() callback(data) + } else { + callback(null) } } override fun onFailure(call: Call, t: Throwable) { - Toast.makeText(context, t.message, Toast.LENGTH_SHORT).show() + callback(null) } }) } diff --git a/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt b/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt index 529b189..8de0d00 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt @@ -261,11 +261,15 @@ class Utils { startActivity(context, browserIntent, null) } - fun getMusicData(link: String, context: Context, callback: (OdesliData) -> Unit) { + fun getMusicData(link: String, context: Context, callback: (OdesliData?) -> Unit) { fun retroFitRequest(countryCode: String) { var songData: OdesliData - RetrofitClient().getData(context, link, countryCode) { data -> + RetrofitClient().getData(link, countryCode) { data -> + if (data == null) { + callback(null) + return@getData + } val entitiesByUniqueId = mutableMapOf() data.entitiesByUniqueId.forEach { (_, song) -> // Make sure entities are keyed as service names @@ -289,9 +293,13 @@ class Utils { val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager? var countryCode = tm!!.networkCountryIso if (countryCode.isNullOrEmpty()) { //use a 3rd party service to get country code if device doesn't have SIM - RetrofitClient().getCountry(context) { locationData -> - countryCode = locationData.country - retroFitRequest(countryCode) + RetrofitClient().getCountry() { locationData -> + if (locationData == null) { + callback(null) + } else { + countryCode = locationData.country + retroFitRequest(countryCode) + } } } else { retroFitRequest(countryCode) diff --git a/odesli/src/main/res/values/strings.xml b/odesli/src/main/res/values/strings.xml index 9544741..84a3852 100644 --- a/odesli/src/main/res/values/strings.xml +++ b/odesli/src/main/res/values/strings.xml @@ -24,5 +24,6 @@ Copy Open Image failed to load + Link copied \ No newline at end of file From d2eb0c0b0d391c04d9acb1217b70ff6514fb0619 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 5 Jan 2026 03:33:36 -0800 Subject: [PATCH 4/6] fix(v1.1.3): fix missing metadata for odesli page --- CHANGELOG.md | 5 +++++ odesli/build.gradle.kts | 4 ++-- .../java/com/prochy/odesliandroid/activity/Share.kt | 11 ++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d56813..e1636c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.1.3] + +- Fixed: Missing song information (title, artist, artwork) when "Odesli Page" is selected + ## [v1.1.2] - Improvement: Better feedback message when "Auto Copy Link" copies a link ("Link copied") @@ -13,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed: Share to "Odesli Page" failing when "Auto Copy Link" is off - Fixed: App hanging when Odesli API fails or returns no data +- Added: Feedback toast when "Auto Copy Link" successfully copies a link ## [v1.1.0] diff --git a/odesli/build.gradle.kts b/odesli/build.gradle.kts index 47a98df..3f60f12 100644 --- a/odesli/build.gradle.kts +++ b/odesli/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.prochy.odesliandroid" minSdk = 21 targetSdk = 34 - versionCode = 2 - versionName = "1.1.2" + versionCode = 3 + versionName = "1.1.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt index 03d487e..cc1d7db 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt @@ -356,9 +356,14 @@ fun ShareActivityLayout(receivedLink: String) { } val musicServices = MusicProviders.entries.map { it.service } - val thumbnail = receivedData.value.entitiesByUniqueId[outputService]?.thumbnailUrl - val title = receivedData.value.entitiesByUniqueId[outputService]?.title - val artist = receivedData.value.entitiesByUniqueId[outputService]?.artistName + val targetEntity = if (outputService == "odesli") { + receivedData.value.entitiesByUniqueId.values.firstOrNull() + } else { + receivedData.value.entitiesByUniqueId[outputService] + } + val thumbnail = targetEntity?.thumbnailUrl + val title = targetEntity?.title + val artist = targetEntity?.artistName val service = getLabelFromService(outputService) val link = if (outputService == "odesli") { receivedData.value.pageUrl From ce3bcb89177e3e115e69bbc372fadae61d3ce221 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 5 Jan 2026 13:48:46 -0800 Subject: [PATCH 5/6] docs: readme fixup --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38e0f0d..934d96f 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,16 @@ Left to right: - Main UI convert - Share UI +# Libraries/APIs used + +- [Odesli/Songlink](https://odesli.co/) +- [Coil](https://coil-kt.github.io/coil/) - [Retrofit](https://github.com/square/retrofit) - [IPinfo](https://ipinfo.io) # Building and Signing -This project uses a secure signing configuration that supports both local development and CI/CD without committing the keystore to the repository. +To sign your builds, you'll need to generate a keystore file and configure your repository secrets to allow GitHub Actions to sign your releases. ### Local Builds @@ -69,6 +73,8 @@ To build signed releases locally, you need to generate a keystore file: ### CI/CD (GitHub Actions) +You'll need your local signature file to generate the base64 encoded content of your keystore file. + For GitHub Actions to build signed APKs, you must configure the following **Repository Secrets** (Settings > Secrets and variables > Actions): 1. **`RELEASE_KEYSTORE_BASE64`**: The base64 encoded content of your keystore file. From 567f827bb9c30efbcc4f8c999ec88de91205b73e Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 5 Jan 2026 19:42:37 -0800 Subject: [PATCH 6/6] fix(v1.2.0): UI cleanup - fix: overflow in input fields - fix: Odesli/Songlink URL now shows metadata from first available source - fix: add vertical scrolling to main content area - fix: fab padding - fix: settings layout cutoffs - fix: text overflow in popup - fix: remove unnecessary bottom padding in result card - fix: fallback to debug signing in release builds when keystore is missing - fix: add scrolling to share modal - fix: add scrolling to service selection list --- CHANGELOG.md | 9 + odesli/build.gradle.kts | 14 +- .../com/prochy/odesliandroid/activity/Main.kt | 228 +++++++++++------- .../prochy/odesliandroid/activity/Share.kt | 52 ++-- .../com/prochy/odesliandroid/utils/Utils.kt | 23 +- 5 files changed, 203 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1636c0..765f792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.2.0] + +- Added: Fallback metadata for "Odesli Page" output +- Fixed: Input fields layout overflow +- Fixed: Main content scrolling +- Fixed: FAB blocking bottom content +- Fixed: Settings text cutoff +- Fixed: Result card text alignment + ## [v1.1.3] - Fixed: Missing song information (title, artist, artwork) when "Odesli Page" is selected diff --git a/odesli/build.gradle.kts b/odesli/build.gradle.kts index 3f60f12..9836ed6 100644 --- a/odesli/build.gradle.kts +++ b/odesli/build.gradle.kts @@ -8,12 +8,14 @@ android { namespace = "com.prochy.odesliandroid" compileSdk = 34 + buildToolsVersion = "34.0.0" + defaultConfig { applicationId = "com.prochy.odesliandroid" minSdk = 21 targetSdk = 34 - versionCode = 3 - versionName = "1.1.3" + versionCode = 4 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -46,7 +48,12 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("release") + val releaseSigning = signingConfigs.getByName("release") + signingConfig = if (releaseSigning.storeFile != null && releaseSigning.storeFile!!.exists()) { + releaseSigning + } else { + signingConfigs.getByName("debug") + } } debug { isMinifyEnabled = false @@ -58,7 +65,6 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_19 sourceCompatibility = JavaVersion.VERSION_19 targetCompatibility = JavaVersion.VERSION_19 } diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt index 585ca9a..cf13562 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Main.kt @@ -42,6 +42,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.RoundedCornerShape @@ -79,6 +80,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextInputService @@ -104,6 +106,8 @@ import com.prochy.odesliandroid.utils.Utils.Companion.resetButtonColors import com.prochy.odesliandroid.utils.Utils.Companion.resetIconButtonColors import com.prochy.odesliandroid.utils.Utils.Companion.saveBooleanSetting import com.prochy.odesliandroid.utils.Utils.Companion.saveStringSetting +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll class Main : ComponentActivity() { @@ -203,8 +207,7 @@ fun OdesliLayout() { contentWindowInsets = WindowInsets( left = 30, right = 30, - ), - containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { LargeTopAppBar( colors = TopAppBarDefaults.topAppBarColors( @@ -254,14 +257,7 @@ fun OdesliLayout() { exit = fadeOut() ) { ExtendedFloatingActionButton( - content = { - Icon( - painter = painterResource(id = R.drawable.ic_send), - contentDescription = "Convert link" - ) - Utils.ButtonSpacer() - Text(text = stringResource(id = R.string.convert_link)) - }, + modifier = Modifier.padding(bottom = 16.dp), onClick = { receivedLinks = false triggeredRequest = true @@ -275,48 +271,93 @@ fun OdesliLayout() { triggeredRequest = false receivedLinks = true } + }, + content = { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = stringResource(id = R.string.convert_link) + ) + Utils.ButtonSpacer() + Text(text = stringResource(id = R.string.convert_link)) } ) } } ) { innerPadding -> + + Column( modifier = Modifier .padding(innerPadding) - .fillMaxHeight(), + .fillMaxHeight() + .verticalScroll(rememberScrollState()), ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - OutlinedTextField( - modifier = Modifier - .widthIn(1.dp, Dp.Infinity) - .weight(1f), - value = text, - onValueChange = { - text = it - receivedLinks = false - triggeredRequest = false - }, - label = { Text(stringResource(id = R.string.original_url)) }, - singleLine = true - ) - Utils.ButtonSpacer() - DynamicSelectTextField( - modifier = Modifier.weight(1f), - selectedValue = outputService, - options = musicServices, - label = stringResource(id = R.string.output_service), - onValueChangedEvent = { - outputService = it - }, - reset = resetOutputField, - showBackButton = true - ) + val configuration = LocalConfiguration.current + if (configuration.screenWidthDp >= 600) { + // Tablet/Foldable: Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + modifier = Modifier.weight(1f), + value = text, + onValueChange = { + text = it + receivedLinks = false + triggeredRequest = false + }, + label = { Text(stringResource(id = R.string.original_url)) }, + singleLine = true + ) + DynamicSelectTextField( + modifier = Modifier.weight(1f), + selectedValue = outputService, + options = musicServices, + label = stringResource(id = R.string.output_service), + onValueChangedEvent = { + outputService = it + }, + reset = resetOutputField, + showBackButton = true + ) + } + } else { + // Phone: Column + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth(), + value = text, + onValueChange = { + text = it + receivedLinks = false + triggeredRequest = false + }, + label = { Text(stringResource(id = R.string.original_url)) }, + singleLine = true + ) + Utils.ButtonSpacer() + DynamicSelectTextField( + modifier = Modifier + .fillMaxWidth(), + selectedValue = outputService, + options = musicServices, + label = stringResource(id = R.string.output_service), + onValueChangedEvent = { + outputService = it + }, + reset = resetOutputField, + showBackButton = true + ) + } } } Spacer(modifier = Modifier.height(20.dp)) @@ -330,11 +371,11 @@ fun OdesliLayout() { ), shape = RoundedCornerShape(16.dp), modifier = Modifier - .height(600.dp) + .wrapContentHeight() ) { Box( modifier = Modifier - .fillMaxSize(), + .fillMaxWidth(), contentAlignment = Alignment.Center ) { if (triggeredRequest && !receivedLinks) @@ -344,9 +385,10 @@ fun OdesliLayout() { strokeCap = StrokeCap.Round ) if (receivedLinks) { - val thumbnail = songData.entitiesByUniqueId[outputService]?.thumbnailUrl - val title = songData.entitiesByUniqueId[outputService]?.title - val artist = songData.entitiesByUniqueId[outputService]?.artistName + val entity = songData.entitiesByUniqueId[outputService] ?: songData.entitiesByUniqueId.values.firstOrNull() + val thumbnail = entity?.thumbnailUrl + val title = entity?.title + val artist = entity?.artistName val service = getLabelFromService(outputService) val link = if (outputService == "odesli") { songData.pageUrl @@ -368,6 +410,10 @@ fun OdesliLayout() { } } + if (!receivedLinks) { + Spacer(modifier = Modifier.height(100.dp)) + } + Spacer(modifier = Modifier.height(100.dp)) if (showCredits) { Dialog( onDismissRequest = { showCredits = false } @@ -582,33 +628,34 @@ fun OdesliLayout() { showBackButton = true ) Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - preferredService.isNotEmpty() - ) { - saveBooleanSetting( - "autoCopy", - !autoCopy, - context - ) - autoCopy = !autoCopy - if (!autoCopy) { - showOnFailed = false + modifier = Modifier + .fillMaxWidth() + .clickable( + preferredService.isNotEmpty() + ) { saveBooleanSetting( - "showOnFailed", - false, + "autoCopy", + !autoCopy, context ) - } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.auto_copy) - ) - Switch( + autoCopy = !autoCopy + if (!autoCopy) { + showOnFailed = false + saveBooleanSetting( + "showOnFailed", + false, + context + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = R.string.auto_copy), + modifier = Modifier.weight(1f) + ) + Switch( enabled = preferredService.isNotEmpty(), checked = autoCopy, onCheckedChange = { @@ -630,25 +677,26 @@ fun OdesliLayout() { ) } Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - preferredService.isNotEmpty() && autoCopy - ) { - saveBooleanSetting( - "showOnFailed", - !showOnFailed, - context - ) - showOnFailed = !showOnFailed - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.show_on_failed_setting) - ) - Switch( + modifier = Modifier + .fillMaxWidth() + .clickable( + preferredService.isNotEmpty() && autoCopy + ) { + saveBooleanSetting( + "showOnFailed", + !showOnFailed, + context + ) + showOnFailed = !showOnFailed + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = R.string.show_on_failed_setting), + modifier = Modifier.weight(1f) + ) + Switch( enabled = preferredService.isNotEmpty() && autoCopy, checked = showOnFailed, onCheckedChange = { @@ -748,7 +796,9 @@ fun DynamicSelectTextField( shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier.padding(20.dp) + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()) ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt index cc1d7db..55df105 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/activity/Share.kt @@ -13,12 +13,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem @@ -230,7 +234,9 @@ fun DynamicSelectTextFieldPopUp( shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier.padding(20.dp) + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -325,13 +331,13 @@ fun ShareActivityLayout(receivedLink: String) { ), shape = RoundedCornerShape(16.dp), modifier = Modifier - .height(dimensionResource(id = R.dimen.card_height)) + .wrapContentHeight() ) { if (triggeredRequest.value) { Box( contentAlignment = Alignment.Center, modifier = Modifier - .fillMaxSize() + .fillMaxWidth() ) { LinearProgressIndicator( color = MaterialTheme.colorScheme.secondary, @@ -371,25 +377,27 @@ fun ShareActivityLayout(receivedLink: String) { receivedData.value.linksByPlatform[outputService]?.url } val type = receivedData.value.entitiesByUniqueId[receivedData.value.entitiesByUniqueId.keys.first()]?.type ?: "" - Utils.SongInfo( - thumbnail = thumbnail.toString(), - title = title.toString(), - artist = artist.toString(), - service = service.toString(), - link = link.toString(), - odesliType = type, - element = { - DynamicSelectTextFieldPopUp( - modifier = Modifier.width(350.dp), - selectedValue = outputService, - options = musicServices, - label = stringResource(id = R.string.output_service), - onValueChangedEvent = { - outputService = it - } - ) - } - ) + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Utils.SongInfo( + thumbnail = thumbnail.toString(), + title = title.toString(), + artist = artist.toString(), + service = service.toString(), + link = link.toString(), + odesliType = type, + element = { + DynamicSelectTextFieldPopUp( + modifier = Modifier.width(350.dp), + selectedValue = outputService, + options = musicServices, + label = stringResource(id = R.string.output_service), + onValueChangedEvent = { + outputService = it + } + ) + } + ) + } } } } diff --git a/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt b/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt index 8de0d00..24178d8 100644 --- a/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt +++ b/odesli/src/main/java/com/prochy/odesliandroid/utils/Utils.kt @@ -15,10 +15,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton @@ -71,16 +75,18 @@ class Utils { } Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .padding(20.dp) - .fillMaxSize() + .fillMaxWidth() + .wrapContentHeight() + .wrapContentHeight() ) { element() if (link.isEmpty() || link == "null") { Box( modifier = Modifier - .fillMaxSize(), + .fillMaxWidth(), contentAlignment = Alignment.Center ) { Text( @@ -103,16 +109,19 @@ class Utils { Text( artist, style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, ) Text( service, style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, ) Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { FilledTonalButton( colors = regularButtonColors(), - content = { Text(text = stringResource(id = R.string.share )) }, + content = { Text(text = stringResource(id = R.string.share ), maxLines = 1, softWrap = false) }, onClick = { val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND @@ -124,10 +133,9 @@ class Utils { startActivity(context, shareIntent, null) } ) - ButtonSpacer() FilledTonalButton( colors = regularButtonColors(), - content = { Text(text = stringResource(id = R.string.copy)) }, + content = { Text(text = stringResource(id = R.string.copy), maxLines = 1, softWrap = false) }, onClick = { val clipboard: ClipboardManager? = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? @@ -135,10 +143,9 @@ class Utils { clipboard?.setPrimaryClip(clip) } ) - ButtonSpacer() FilledTonalButton( colors = regularButtonColors(), - content = { Text(text = stringResource(id = R.string.open )) }, + content = { Text(text = stringResource(id = R.string.open ), maxLines = 1, softWrap = false) }, onClick = { openLink(link, context) }