├── .github └── workflows │ └── build.yml ├── .gitignore ├── .run └── app.run.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ └── res │ ├── drawable │ └── ic_launcher_foreground.xml │ ├── mipmap-anydpi-v26 │ └── ic_launcher.xml │ └── mipmap-xxxhdpi │ └── ic_launcher.webp ├── build.gradle.kts ├── ext ├── .gitignore ├── build.gradle.kts └── src │ ├── main │ └── java │ │ └── dev │ │ └── brahmkshatriya │ │ └── echo │ │ └── extension │ │ └── TestExtension.kt │ └── test │ └── java │ └── dev │ └── brahmkshatriya │ └── echo │ └── extension │ ├── ExtensionUnitTest.kt │ └── MockedSettings.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml └── settings.gradle.kts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup JDK 17 16 | uses: actions/setup-java@v4 17 | with: 18 | distribution: 'zulu' 19 | java-version: 17 20 | cache: 'gradle' 21 | 22 | - name: Cook Variables from gradle.properties 23 | run : | 24 | name=$(grep '^extName=' gradle.properties | cut -d'=' -f2) 25 | echo "NAME=$name Extension" >> $GITHUB_ENV 26 | id=$(grep '^extId=' gradle.properties | cut -d'=' -f2) 27 | echo "TAG=$id" >> $GITHUB_ENV 28 | 29 | - name: Make Environment 30 | run: | 31 | version=$( echo ${{ github.event.head_commit.id }} | cut -c1-7 ) 32 | echo "VERSION=v$version" >> $GITHUB_ENV 33 | echo -e "## ${{ env.NAME }}\n${{ github.event.head_commit.message }}" > commit.txt 34 | echo "APP_PATH=app/build/${{ env.TAG }}-$version.eapk" >> $GITHUB_ENV 35 | echo "${{ secrets.KEYSTORE_B64 }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks 36 | chmod +x ./gradlew 37 | 38 | - name: Build with Gradle 39 | run: | 40 | ./gradlew assembleDebug \ 41 | -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \ 42 | -Pandroid.injected.signing.store.password=${{ secrets.PASSWORD }} \ 43 | -Pandroid.injected.signing.key.alias=key0 \ 44 | -Pandroid.injected.signing.key.password=${{ secrets.PASSWORD }} 45 | 46 | cp app/build/outputs/apk/debug/app-debug.apk ${{ env.APP_PATH }} 47 | 48 | - name: Upload APK 49 | uses: actions/upload-artifact@v4 50 | with: 51 | path: ${{ env.APP_PATH }} 52 | 53 | - name: Create Release 54 | uses: softprops/action-gh-release@v2 55 | with: 56 | make_latest: true 57 | tag_name: ${{ env.VERSION }} 58 | body_path: commit.txt 59 | name: ${{ env.VERSION }} 60 | files: ${{ env.APP_PATH }} 61 | 62 | - name: Delete Old Releases 63 | uses: sgpublic/delete-release-action@master 64 | with: 65 | release-drop: true 66 | release-keep-count: 2 67 | release-drop-tag: true 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .cxx 9 | local.properties 10 | -------------------------------------------------------------------------------- /.run/app.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75 | 76 | 77 | 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Echo Extension Template 2 | 3 | This is a template for creating an Echo extension. It includes a basic structure for the extension, 4 | so you do not have to start from scratch. 5 | 6 | ## Getting Started 7 | 8 | ### 1. you can clone this repository. 9 | Clone this repository and name it as you want. 10 | 11 | ### 2. Configure the [gradle.properties](gradle.properties) 12 | The file will have the following properties: 13 | - `libVersion` - The version of the Echo library, defaults to `main-SNAPSHOT`. 14 | - `extType` - The type of the extension you want to create. It can be `music`, `tracker` 15 | or `lyrics`. More information can be found 16 | in [`Extension<*>`](https://github.com/brahmkshatriya/echo/blob/main/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt#L33-L43) 17 | java doc. 18 | - `extId` - The id of the extension. (Do not use spaces or special characters) 19 | - `extClass` - The class of the extension. This should be the class that you inherit client 20 | interfaces to. For example in this template, it 21 | is [`TestExtension`](ext/src/main/java/dev/brahmkshatriya/echo/extension/TestExtension.kt). 22 | - `extIcon` - (Optional) The icon of the extension. Will be cropped into a circle. 23 | - `extName` - The name of the extension. 24 | - `extDescription` - The description of the extension. 25 | - `extAuthor` - The author of the extension. 26 | - `extAuthorUrl` - (Optional) The author's website. 27 | - `extRepoUrl` - (Optional) The repository URL of the extension. 28 | - `extUpdateUrl` - (Optional) The update URL of the extension. The following urls are supported: 29 | - Github : https://api.github.com/repos/your_username/your_extension_repo/releases 30 | 31 | ### 3. Implement the extension 32 | Here's where the fun begins. Echo checks for `Client` interfaces that your extension implemented to know if your extension supports the feature or not. 33 | 34 | - What are `Client` interfaces? 35 | - These are interfaces that include functions your extension need to implement (`override fun`). 36 | - For example, if you want to create a lyrics extension, you need to implement the `LyricsClient` interface. 37 | - What interfaces are available? 38 | - By default, the [`TestExtension`](ext/src/main/java/dev/brahmkshatriya/echo/extension/TestExtension.kt) implements the `ExtensionClient` interface. 39 | - Pro tip: Hover over the interface to see the documentation, Click on every one things that is clickable to dive deep into the rabbit hole. 40 | - You can find all the available interfaces, for: 41 | - Music Extension - [here](https://github.com/brahmkshatriya/echo/blob/main/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt#L65-L117) 42 | - Tracker Extension - [here](https://github.com/brahmkshatriya/echo/blob/main/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt#L123-L137) 43 | - Lyrics Extension - [here](https://github.com/brahmkshatriya/echo/blob/main/common/src/main/java/dev/brahmkshatriya/echo/common/Extension.kt#L143-L156) 44 | 45 | The best example of how to implement an extension should be the [Spotify Extension](https://github.com/brahmkshatriya/echo-spotify-extension/blob/main/ext/src/main/java/dev/brahmkshatriya/echo/extension/SpotifyExtension.kt). 46 | 47 | ### 4. Making network requests 48 | If your extension needs to make network requests, you can use `OkHttpClient` class provided directly by Echo. For example: 49 | ```kotlin 50 | class TestExtension : ExtensionClient { 51 | private val client = OkHttpClient() 52 | 53 | override suspend fun someNiceFunction() { 54 | val request = Request.Builder().url("https://example.com").build() 55 | val response = client.newCall(request).await() 56 | println(response.body?.string()) 57 | } 58 | } 59 | ``` 60 | If you are using `OkHttpClient`, use the custom `await()` function for suspending the network call. 61 | 62 | ### 5. Testing the extension 63 | There are two ways to test the extension: 64 | - **Local testing**: You can test the extension locally by running the tests in the [`ExtensionUnitTest`](ext/src/test/java/dev/brahmkshatriya/echo/extension/ExtensionUnitTest.kt) class. 65 | - **App testing**: You can test the extension in the Echo app by building & installing the `app` & then opening Echo app. 66 | 67 | ### 6. Publishing the extension 68 | This template includes a GitHub Actions workflow that will automatically build and publish the extension to GitHub releases when you make a new commit. You can find the workflow file [here](.github/workflow/build.yml). 69 | You need to do the following steps to publish the extension: 70 | - Enable `Read & write permissions` for workflows in the repository settings (Settings -> Actions -> General -> Workflow Permissions). 71 | - Generate a keystore file : https://developer.android.com/studio/publish/app-signing#generate-key 72 | - Add action secrets in the repository settings (Settings -> Secrets and variables -> Actions -> New repository secret): 73 | - `KEYSTORE_B64` - The base64 encoded keystore file. [How to](https://stackoverflow.com/a/70396534) 74 | - `PASSWORD` - The password of the keystore file. 75 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.IOException 2 | 3 | plugins { 4 | id("com.android.application") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | dependencies { 9 | implementation(project(":ext")) 10 | val libVersion: String by project 11 | compileOnly("com.github.brahmkshatriya:echo:$libVersion") 12 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") 13 | } 14 | 15 | val extType: String by project 16 | val extId: String by project 17 | val extClass: String by project 18 | 19 | val extIconUrl: String? by project 20 | val extName: String by project 21 | val extDescription: String? by project 22 | 23 | val extAuthor: String by project 24 | val extAuthorUrl: String? by project 25 | 26 | val extRepoUrl: String? by project 27 | val extUpdateUrl: String? by project 28 | 29 | val gitHash = execute("git", "rev-parse", "HEAD").take(7) 30 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt() 31 | val verCode = gitCount 32 | val verName = "v$gitHash" 33 | 34 | 35 | val outputDir = file("${layout.buildDirectory.asFile.get()}/generated/proguard") 36 | val generatedProguard = file("${outputDir}/generated-rules.pro") 37 | 38 | tasks.register("generateProguardRules") { 39 | doLast { 40 | outputDir.mkdirs() 41 | generatedProguard.writeText( 42 | "-dontobfuscate\n-keep,allowoptimization class dev.brahmkshatriya.echo.extension.$extClass" 43 | ) 44 | } 45 | } 46 | 47 | tasks.named("preBuild") { 48 | dependsOn("generateProguardRules") 49 | } 50 | 51 | tasks.register("uninstall") { 52 | android.run { 53 | execute( 54 | adbExecutable.absolutePath, "shell", "pm", "uninstall", defaultConfig.applicationId!! 55 | ) 56 | } 57 | } 58 | 59 | android { 60 | namespace = "dev.brahmkshatriya.echo.extension" 61 | compileSdk = 35 62 | defaultConfig { 63 | applicationId = "dev.brahmkshatriya.echo.extension.$extId" 64 | minSdk = 24 65 | targetSdk = 35 66 | 67 | manifestPlaceholders.apply { 68 | put("type", "dev.brahmkshatriya.echo.${extType}") 69 | put("id", extId) 70 | put("class_path", "dev.brahmkshatriya.echo.extension.${extClass}") 71 | put("version", verName) 72 | put("version_code", verCode.toString()) 73 | put("icon_url", extIconUrl ?: "") 74 | put("app_name", "Echo : $extName Extension") 75 | put("name", extName) 76 | put("description", extDescription ?: "") 77 | put("author", extAuthor) 78 | put("author_url", extAuthorUrl ?: "") 79 | put("repo_url", extRepoUrl ?: "") 80 | put("update_url", extUpdateUrl ?: "") 81 | } 82 | } 83 | 84 | buildTypes { 85 | all { 86 | isMinifyEnabled = true 87 | proguardFiles( 88 | getDefaultProguardFile("proguard-android-optimize.txt"), 89 | generatedProguard.absolutePath 90 | ) 91 | } 92 | } 93 | 94 | compileOptions { 95 | sourceCompatibility = JavaVersion.VERSION_17 96 | targetCompatibility = JavaVersion.VERSION_17 97 | } 98 | 99 | kotlinOptions { 100 | jvmTarget = JavaVersion.VERSION_17.toString() 101 | } 102 | } 103 | 104 | fun execute(vararg command: String): String { 105 | val process = ProcessBuilder(*command) 106 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 107 | .redirectError(ProcessBuilder.Redirect.PIPE) 108 | .start() 109 | 110 | val output = process.inputStream.bufferedReader().readText() 111 | val errorOutput = process.errorStream.bufferedReader().readText() 112 | 113 | val exitCode = process.waitFor() 114 | 115 | if (exitCode != 0) { 116 | throw IOException( 117 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" + 118 | "Stdout:\n$output\n" + 119 | "Stderr:\n$errorOutput" 120 | ) 121 | } 122 | 123 | return output.trim() 124 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 12 | 15 | 16 | 19 | 22 | 23 | 26 | 29 | 32 | 33 | 36 | 39 | 40 | 43 | 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brahmkshatriya/echo-extension-template/f3781428f1c16c264b5fbf4ddf7463bd3f9872e7/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") version "8.9.2" apply false 3 | id("org.jetbrains.kotlin.android") version "2.1.0" apply false 4 | id("org.jetbrains.kotlin.jvm") version "2.1.0" apply false 5 | } -------------------------------------------------------------------------------- /ext/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /ext/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 2 | import java.io.IOException 3 | 4 | plugins { 5 | id("java-library") 6 | id("org.jetbrains.kotlin.jvm") 7 | id("maven-publish") 8 | id("com.gradleup.shadow") version "8.3.0" 9 | kotlin("plugin.serialization") version "1.9.22" 10 | } 11 | 12 | java { 13 | sourceCompatibility = JavaVersion.VERSION_17 14 | targetCompatibility = JavaVersion.VERSION_17 15 | } 16 | 17 | kotlin { 18 | jvmToolchain(17) 19 | } 20 | 21 | dependencies { 22 | val libVersion: String by project 23 | compileOnly("com.github.brahmkshatriya:echo:$libVersion") 24 | compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") 25 | 26 | testImplementation("junit:junit:4.13.2") 27 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") 28 | testImplementation("com.github.brahmkshatriya:echo:$libVersion") 29 | } 30 | 31 | // Extension properties goto `gradle.properties` to set values 32 | 33 | val extType: String by project 34 | val extId: String by project 35 | val extClass: String by project 36 | 37 | val extIconUrl: String? by project 38 | val extName: String by project 39 | val extDescription: String? by project 40 | 41 | val extAuthor: String by project 42 | val extAuthorUrl: String? by project 43 | 44 | val extRepoUrl: String? by project 45 | val extUpdateUrl: String? by project 46 | 47 | val gitHash = execute("git", "rev-parse", "HEAD").take(7) 48 | val gitCount = execute("git", "rev-list", "--count", "HEAD").toInt() 49 | val verCode = gitCount 50 | val verName = "v$gitHash" 51 | 52 | publishing { 53 | publications { 54 | create("mavenJava") { 55 | groupId = "dev.brahmkshatriya.echo.extension" 56 | artifactId = extId 57 | version = verName 58 | 59 | from(components["java"]) 60 | } 61 | } 62 | } 63 | 64 | tasks { 65 | val shadowJar by getting(ShadowJar::class) { 66 | archiveBaseName.set(extId) 67 | archiveVersion.set(verName) 68 | manifest { 69 | attributes( 70 | mapOf( 71 | "Extension-Id" to extId, 72 | "Extension-Type" to extType, 73 | "Extension-Class" to extClass, 74 | 75 | "Extension-Version-Code" to verCode, 76 | "Extension-Version-Name" to verName, 77 | 78 | "Extension-Icon-Url" to extIconUrl, 79 | "Extension-Name" to extName, 80 | "Extension-Description" to extDescription, 81 | 82 | "Extension-Author" to extAuthor, 83 | "Extension-Author-Url" to extAuthorUrl, 84 | 85 | "Extension-Repo-Url" to extRepoUrl, 86 | "Extension-Update-Url" to extUpdateUrl 87 | ) 88 | ) 89 | } 90 | } 91 | } 92 | 93 | fun execute(vararg command: String): String { 94 | val process = ProcessBuilder(*command) 95 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 96 | .redirectError(ProcessBuilder.Redirect.PIPE) 97 | .start() 98 | 99 | val output = process.inputStream.bufferedReader().readText() 100 | val errorOutput = process.errorStream.bufferedReader().readText() 101 | 102 | val exitCode = process.waitFor() 103 | 104 | if (exitCode != 0) { 105 | throw IOException( 106 | "Command failed with exit code $exitCode. Command: ${command.joinToString(" ")}\n" + 107 | "Stdout:\n$output\n" + 108 | "Stderr:\n$errorOutput" 109 | ) 110 | } 111 | 112 | return output.trim() 113 | } -------------------------------------------------------------------------------- /ext/src/main/java/dev/brahmkshatriya/echo/extension/TestExtension.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.clients.ExtensionClient 4 | import dev.brahmkshatriya.echo.common.settings.Setting 5 | import dev.brahmkshatriya.echo.common.settings.Settings 6 | 7 | class TestExtension : ExtensionClient { 8 | override suspend fun onExtensionSelected() {} 9 | 10 | override val settingItems: List = emptyList() 11 | 12 | private lateinit var setting: Settings 13 | override fun setSettings(settings: Settings) { 14 | setting = settings 15 | } 16 | } -------------------------------------------------------------------------------- /ext/src/test/java/dev/brahmkshatriya/echo/extension/ExtensionUnitTest.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.clients.AlbumClient 4 | import dev.brahmkshatriya.echo.common.clients.ExtensionClient 5 | import dev.brahmkshatriya.echo.common.clients.HomeFeedClient 6 | import dev.brahmkshatriya.echo.common.clients.LoginClient 7 | import dev.brahmkshatriya.echo.common.clients.RadioClient 8 | import dev.brahmkshatriya.echo.common.clients.SearchFeedClient 9 | import dev.brahmkshatriya.echo.common.clients.TrackClient 10 | import dev.brahmkshatriya.echo.common.models.EchoMediaItem 11 | import dev.brahmkshatriya.echo.common.models.Shelf 12 | import dev.brahmkshatriya.echo.common.models.Track 13 | import dev.brahmkshatriya.echo.common.models.User 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.DelicateCoroutinesApi 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.ExperimentalCoroutinesApi 18 | import kotlinx.coroutines.newSingleThreadContext 19 | import kotlinx.coroutines.runBlocking 20 | import kotlinx.coroutines.test.resetMain 21 | import kotlinx.coroutines.test.setMain 22 | import org.junit.After 23 | import org.junit.Before 24 | import org.junit.Test 25 | import kotlin.system.measureTimeMillis 26 | 27 | @OptIn(DelicateCoroutinesApi::class) 28 | @ExperimentalCoroutinesApi 29 | class ExtensionUnitTest { 30 | private val extension: ExtensionClient = TestExtension() 31 | private val searchQuery = "Skrillex" 32 | private val user = User("","Test User") 33 | 34 | // Test Setup 35 | private val mainThreadSurrogate = newSingleThreadContext("UI thread") 36 | @Before 37 | fun setUp() { 38 | Dispatchers.setMain(mainThreadSurrogate) 39 | extension.setSettings(MockedSettings()) 40 | runBlocking { 41 | extension.onExtensionSelected() 42 | if (extension is LoginClient) 43 | extension.onSetLoginUser(user) 44 | } 45 | } 46 | 47 | @After 48 | fun tearDown() { 49 | Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher 50 | mainThreadSurrogate.close() 51 | } 52 | 53 | private fun testIn(title: String, block: suspend CoroutineScope.() -> Unit) = runBlocking { 54 | println("\n-- $title --") 55 | block.invoke(this) 56 | println("\n") 57 | } 58 | 59 | // Actual Tests 60 | @Test 61 | fun testHomeFeed() = testIn("Testing Home Feed") { 62 | if (extension !is HomeFeedClient) error("HomeFeedClient is not implemented") 63 | val feed = extension.getHomeFeed(null).loadFirst() 64 | feed.forEach { 65 | println(it) 66 | } 67 | } 68 | 69 | @Test 70 | fun testHomeFeedWithTab() = testIn("Testing Home Feed with Tab") { 71 | if (extension !is HomeFeedClient) error("HomeFeedClient is not implemented") 72 | val tab = extension.getHomeTabs().firstOrNull() 73 | val feed = extension.getHomeFeed(tab).loadFirst() 74 | feed.forEach { 75 | println(it) 76 | } 77 | } 78 | 79 | @Test 80 | fun testEmptyQuickSearch() = testIn("Testing Empty Quick Search") { 81 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 82 | val search = extension.quickSearch("") 83 | search.forEach { 84 | println(it) 85 | } 86 | } 87 | 88 | @Test 89 | fun testQuickSearch() = testIn("Testing Quick Search") { 90 | if (extension !is SearchFeedClient) error("SearchClient is not implemented") 91 | val search = extension.quickSearch(searchQuery) 92 | search.forEach { 93 | println(it) 94 | } 95 | } 96 | 97 | @Test 98 | fun testEmptySearch() = testIn("Testing Empty Search") { 99 | if (extension !is SearchFeedClient) error("SearchFeedClient is not implemented") 100 | val tab = extension.searchTabs("").firstOrNull() 101 | val search = extension.searchFeed("", tab).loadFirst() 102 | search.forEach { 103 | println(it) 104 | } 105 | } 106 | 107 | @Test 108 | fun testSearch() = testIn("Testing Search") { 109 | if (extension !is SearchFeedClient) error("SearchFeedClient is not implemented") 110 | println("Tabs") 111 | extension.searchTabs(searchQuery).forEach { 112 | println(it.title) 113 | } 114 | println("Search Results") 115 | val search = extension.searchFeed(searchQuery, null).loadFirst() 116 | search.forEach { 117 | println(it) 118 | } 119 | } 120 | 121 | private suspend fun searchTrack(q: String? = null): Track { 122 | if (extension !is SearchFeedClient) error("SearchFeedClient is not implemented") 123 | val query = q ?: searchQuery 124 | println("Searching : $query") 125 | val tab = extension.searchTabs(query).firstOrNull() 126 | val items = extension.searchFeed(query, tab).loadFirst() 127 | val track = items.firstNotNullOfOrNull { 128 | when (it) { 129 | is Shelf.Item -> (it.media as? EchoMediaItem.TrackItem)?.track 130 | is Shelf.Lists.Tracks -> it.list.firstOrNull() 131 | is Shelf.Lists.Items -> (it.list.firstOrNull() as? EchoMediaItem.TrackItem)?.track 132 | else -> null 133 | } 134 | } 135 | return track ?: error("Track not found, try a different search query") 136 | } 137 | 138 | @Test 139 | fun testTrackGet() = testIn("Testing Track Get") { 140 | if (extension !is TrackClient) error("TrackClient is not implemented") 141 | val search = searchTrack() 142 | measureTimeMillis { 143 | val track = extension.loadTrack(search) 144 | println(track) 145 | }.also { println("time : $it") } 146 | } 147 | 148 | @Test 149 | fun testTrackStream() = testIn("Testing Track Stream") { 150 | if (extension !is TrackClient) error("TrackClient is not implemented") 151 | val search = searchTrack() 152 | measureTimeMillis { 153 | val track = extension.loadTrack(search) 154 | val streamable = track.servers.firstOrNull() 155 | ?: error("Track is not streamable") 156 | val stream = extension.loadStreamableMedia(streamable, false) 157 | println(stream) 158 | }.also { println("time : $it") } 159 | } 160 | 161 | @Test 162 | fun testTrackRadio() = testIn("Testing Track Radio") { 163 | if (extension !is TrackClient) error("TrackClient is not implemented") 164 | if (extension !is RadioClient) error("RadioClient is not implemented") 165 | val track = extension.loadTrack(searchTrack()) 166 | val radio = extension.radio(track, null) 167 | val radioTracks = extension.loadTracks(radio).loadFirst() 168 | radioTracks.forEach { 169 | println(it) 170 | } 171 | } 172 | 173 | @Test 174 | fun testTrackShelves() = testIn("Testing Track Shelves") { 175 | if (extension !is TrackClient) error("TrackClient is not implemented") 176 | val track = extension.loadTrack(searchTrack()) 177 | val mediaItems = extension.getShelves(track).loadFirst() 178 | mediaItems.forEach { 179 | println(it) 180 | } 181 | } 182 | 183 | @Test 184 | fun testAlbumGet() = testIn("Testing Album Get") { 185 | if (extension !is TrackClient) error("TrackClient is not implemented") 186 | val small = extension.loadTrack(searchTrack()).album ?: error("Track has no album") 187 | if (extension !is AlbumClient) error("AlbumClient is not implemented") 188 | val album = extension.loadAlbum(small) 189 | println(album) 190 | val mediaItems = extension.getShelves(album).loadFirst() 191 | mediaItems.forEach { 192 | println(it) 193 | } 194 | } 195 | } -------------------------------------------------------------------------------- /ext/src/test/java/dev/brahmkshatriya/echo/extension/MockedSettings.kt: -------------------------------------------------------------------------------- 1 | package dev.brahmkshatriya.echo.extension 2 | 3 | import dev.brahmkshatriya.echo.common.settings.Settings 4 | 5 | class MockedSettings : Settings { 6 | override fun getBoolean(key: String): Boolean? = null 7 | 8 | override fun getInt(key: String): Int? = null 9 | 10 | override fun getString(key: String): String? = null 11 | 12 | override fun getStringSet(key: String): Set? = null 13 | 14 | override fun putBoolean(key: String, value: Boolean?) {} 15 | 16 | override fun putInt(key: String, value: Int?) {} 17 | 18 | override fun putString(key: String, value: String?) {} 19 | 20 | override fun putStringSet(key: String, value: Set?) {} 21 | 22 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | kotlin.code.style=official 4 | android.nonTransitiveRClass=true 5 | 6 | libVersion=e0f9973799 7 | 8 | # Can be music, tracker or lyrics 9 | extType=music 10 | extId=test_ext 11 | extClass=TestExtension 12 | 13 | extIconUrl= 14 | extName=Test 15 | extDescription=This is the test extension you created. 16 | 17 | extAuthor=Test 18 | extAuthorUrl= 19 | 20 | extRepoUrl= 21 | extUpdateUrl= -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brahmkshatriya/echo-extension-template/f3781428f1c16c264b5fbf4ddf7463bd3f9872e7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Feb 19 15:14:17 IST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | before_install: 4 | - sdk install java 17.0.1-open 5 | - sdk use java 17.0.1-open 6 | install: 7 | - ./gradlew clean ext:assemble publishToMavenLocal -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | 9 | @Suppress("UnstableApiUsage") 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | repositories { 13 | google() 14 | mavenCentral() 15 | maven { url = uri("https://jitpack.io") } 16 | } 17 | } 18 | 19 | rootProject.name = "extension" 20 | include(":app") 21 | include(":ext") --------------------------------------------------------------------------------