├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
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")
--------------------------------------------------------------------------------