Initial AmpFire Fire TV Subsonic client scaffold

This commit is contained in:
Scooter 2026-06-22 12:06:59 -05:00
commit 4f9c449405
33 changed files with 1361 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
*.apk
*.aab
*.jks
secrets.properties
/app/build

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# AmpFire
AmpFire is a Fire TV / Android TV music client for self-hosted Subsonic-compatible servers such as Navidrome.
The project is intentionally built for two tracks:
1. **Sideload-first testing** on personal Fire TV devices.
2. **Publish-ready architecture** for eventual Amazon Appstore submission.
## Current status
This is a first-pass app scaffold with:
- Fire TV / Android TV launcher support.
- Kotlin + Jetpack Compose for TV UI.
- Media3 playback service architecture.
- Retrofit-based Subsonic API client.
- Broad Subsonic endpoint coverage in `SubsonicApi`.
- Server/login settings flow.
- Browse/search/library/now-playing UI shells.
- Publish-minded privacy/security notes.
- Gradle wrapper and Android build configuration verified with a real debug APK build.
## Intended stack
- Kotlin
- Android Gradle Plugin
- Jetpack Compose for TV
- AndroidX Media3 / ExoPlayer
- Retrofit + kotlinx.serialization
- DataStore for settings
- Coil for artwork
## Build
With Java + Android SDK installed:
```bash
./gradlew assembleDebug
```
Verified on the initial scaffold with:
```text
BUILD SUCCESSFUL in 1m 53s
35 actionable tasks: 35 executed
```
For sideloading:
```bash
adb connect FIRE_TV_IP:5555
adb install app/build/outputs/apk/debug/app-debug.apk
```
## Security / publishing notes
- Do not log Subsonic stream URLs; they contain auth parameters.
- Prefer token/salt authentication over plain password parameters.
- Release builds should not bypass TLS validation.
- Review builds should provide either a demo server or reviewer credentials.
- App listing should describe AmpFire as a self-hosted music client, not a free music source.
## Roadmap
See [`docs/ROADMAP.md`](docs/ROADMAP.md).

76
app/build.gradle.kts Normal file
View File

@ -0,0 +1,76 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "lol.frijole.ampfire"
compileSdk = 35
defaultConfig {
applicationId = "lol.frijole.ampfire"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.tv:tv-foundation:1.0.0")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
}

3
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,3 @@
-keep class kotlinx.serialization.** { *; }
-keep @kotlinx.serialization.Serializable class * { *; }
-dontwarn org.slf4j.**

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-feature android:name="android.software.leanback" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".AmpFireApplication"
android:allowBackup="false"
android:banner="@drawable/app_banner"
android:icon="@mipmap/ic_launcher"
android:label="AmpFire"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.AmpFire">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".playback.AmpFirePlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,5 @@
package lol.frijole.ampfire
import android.app.Application
class AmpFireApplication : Application()

View File

@ -0,0 +1,106 @@
package lol.frijole.ampfire
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import lol.frijole.ampfire.data.SettingsStore
import lol.frijole.ampfire.data.SubsonicRepository
import lol.frijole.ampfire.subsonic.SubsonicClientFactory
import lol.frijole.ampfire.subsonic.model.Album
import lol.frijole.ampfire.subsonic.model.Artist
import lol.frijole.ampfire.subsonic.model.Playlist
import lol.frijole.ampfire.subsonic.model.Song
class AmpFireViewModel(
private val settingsStore: SettingsStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(AmpFireUiState())
val uiState: StateFlow<AmpFireUiState> = _uiState.asStateFlow()
private var repository: SubsonicRepository? = null
init {
viewModelScope.launch {
settingsStore.settings.collect { settings ->
_uiState.value = _uiState.value.copy(settings = settings)
if (settings.isUsable()) {
repository = SubsonicRepository(SubsonicClientFactory.create(settings), settings)
refreshHome()
}
}
}
}
fun saveSettings(serverUrl: String, username: String, password: String) {
viewModelScope.launch {
settingsStore.save(ServerSettings(serverUrl.trim(), username.trim(), password))
}
}
fun refreshHome() {
val repo = repository ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
runCatching {
val artists = repo.getArtists().take(24)
val albums = repo.getNewestAlbums(size = 24)
val playlists = repo.getPlaylists()
Triple(artists, albums, playlists)
}.onSuccess { (artists, albums, playlists) ->
_uiState.value = _uiState.value.copy(
isLoading = false,
artists = artists,
albums = albums,
playlists = playlists,
)
}.onFailure { err ->
_uiState.value = _uiState.value.copy(isLoading = false, error = err.message ?: "Unknown server error")
}
}
}
fun search(query: String) {
val repo = repository ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(searchQuery = query)
if (query.length < 2) return@launch
runCatching { repo.search(query) }
.onSuccess { songs -> _uiState.value = _uiState.value.copy(searchResults = songs) }
.onFailure { err -> _uiState.value = _uiState.value.copy(error = err.message) }
}
}
companion object {
fun factory(context: Context): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return AmpFireViewModel(SettingsStore(context.applicationContext)) as T
}
}
}
}
data class AmpFireUiState(
val settings: ServerSettings = ServerSettings(),
val isLoading: Boolean = false,
val error: String? = null,
val artists: List<Artist> = emptyList(),
val albums: List<Album> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val searchQuery: String = "",
val searchResults: List<Song> = emptyList(),
)
@kotlinx.serialization.Serializable
data class ServerSettings(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
) {
fun isUsable(): Boolean = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}

View File

@ -0,0 +1,20 @@
package lol.frijole.ampfire
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import lol.frijole.ampfire.ui.AmpFireApp
import lol.frijole.ampfire.ui.AmpFireTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AmpFireTheme {
val vm: AmpFireViewModel = viewModel(factory = AmpFireViewModel.factory(applicationContext))
AmpFireApp(vm)
}
}
}
}

View File

@ -0,0 +1,33 @@
package lol.frijole.ampfire.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import lol.frijole.ampfire.ServerSettings
private val Context.ampFireDataStore by preferencesDataStore("ampfire_settings")
class SettingsStore(private val context: Context) {
private val serverUrl = stringPreferencesKey("server_url")
private val username = stringPreferencesKey("username")
private val password = stringPreferencesKey("password")
val settings: Flow<ServerSettings> = context.ampFireDataStore.data.map { prefs ->
ServerSettings(
serverUrl = prefs[serverUrl].orEmpty(),
username = prefs[username].orEmpty(),
password = prefs[password].orEmpty(),
)
}
suspend fun save(settings: ServerSettings) {
context.ampFireDataStore.edit { prefs ->
prefs[serverUrl] = settings.serverUrl.trimEnd('/')
prefs[username] = settings.username
prefs[password] = settings.password
}
}
}

View File

@ -0,0 +1,36 @@
package lol.frijole.ampfire.data
import lol.frijole.ampfire.ServerSettings
import lol.frijole.ampfire.subsonic.SubsonicApi
import lol.frijole.ampfire.subsonic.SubsonicAuth
import lol.frijole.ampfire.subsonic.model.Album
import lol.frijole.ampfire.subsonic.model.Artist
import lol.frijole.ampfire.subsonic.model.Playlist
import lol.frijole.ampfire.subsonic.model.Song
class SubsonicRepository(
private val api: SubsonicApi,
private val settings: ServerSettings,
) {
private val auth get() = SubsonicAuth.forPassword(settings.username, settings.password)
suspend fun ping(): Boolean = api.ping(auth.params()).response.status == "ok"
suspend fun getArtists(): List<Artist> =
api.getArtists(auth.params()).response.artists?.index.orEmpty().flatMap { it.artist.orEmpty() }
suspend fun getNewestAlbums(size: Int): List<Album> =
api.getAlbumList2(auth.params() + mapOf("type" to "newest", "size" to size.toString())).response.albumList2?.album.orEmpty()
suspend fun getPlaylists(): List<Playlist> =
api.getPlaylists(auth.params()).response.playlists?.playlist.orEmpty()
suspend fun search(query: String): List<Song> =
api.search3(auth.params() + mapOf("query" to query, "songCount" to "50")).response.searchResult3?.song.orEmpty()
fun streamUrl(songId: String): String =
settings.serverUrl.trimEnd('/') + "/rest/stream.view?id=" + songId + "&" + auth.queryString()
fun coverArtUrl(coverArtId: String): String =
settings.serverUrl.trimEnd('/') + "/rest/getCoverArt.view?id=" + coverArtId + "&" + auth.queryString()
}

View File

@ -0,0 +1,26 @@
package lol.frijole.ampfire.playback
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
class AmpFirePlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = ExoPlayer.Builder(this).build()
mediaSession = MediaSession.Builder(this, player).build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession = null
super.onDestroy()
}
}

View File

@ -0,0 +1,67 @@
package lol.frijole.ampfire.subsonic
import lol.frijole.ampfire.subsonic.model.SubsonicEnvelope
import okhttp3.ResponseBody
import retrofit2.http.GET
import retrofit2.http.QueryMap
import retrofit2.http.Streaming
interface SubsonicApi {
@GET("rest/ping.view") suspend fun ping(@QueryMap auth: Map<String, String>): SubsonicEnvelope
@GET("rest/getLicense.view") suspend fun getLicense(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getOpenSubsonicExtensions.view") suspend fun getOpenSubsonicExtensions(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getMusicFolders.view") suspend fun getMusicFolders(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getIndexes.view") suspend fun getIndexes(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getMusicDirectory.view") suspend fun getMusicDirectory(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getArtists.view") suspend fun getArtists(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getArtist.view") suspend fun getArtist(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getAlbum.view") suspend fun getAlbum(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getSong.view") suspend fun getSong(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getGenres.view") suspend fun getGenres(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getAlbumList2.view") suspend fun getAlbumList2(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getStarred2.view") suspend fun getStarred2(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getRandomSongs.view") suspend fun getRandomSongs(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getSongsByGenre.view") suspend fun getSongsByGenre(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getNowPlaying.view") suspend fun getNowPlaying(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/search3.view") suspend fun search3(@QueryMap q: Map<String, String>): SubsonicEnvelope
@Streaming @GET("rest/stream.view") suspend fun stream(@QueryMap q: Map<String, String>): ResponseBody
@Streaming @GET("rest/download.view") suspend fun download(@QueryMap q: Map<String, String>): ResponseBody
@Streaming @GET("rest/getCoverArt.view") suspend fun getCoverArt(@QueryMap q: Map<String, String>): ResponseBody
@GET("rest/getLyrics.view") suspend fun getLyrics(@QueryMap q: Map<String, String>): SubsonicEnvelope
@Streaming @GET("rest/getAvatar.view") suspend fun getAvatar(@QueryMap q: Map<String, String>): ResponseBody
@GET("rest/getPlaylists.view") suspend fun getPlaylists(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getPlaylist.view") suspend fun getPlaylist(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/createPlaylist.view") suspend fun createPlaylist(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/updatePlaylist.view") suspend fun updatePlaylist(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deletePlaylist.view") suspend fun deletePlaylist(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/star.view") suspend fun star(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/unstar.view") suspend fun unstar(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/setRating.view") suspend fun setRating(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/scrobble.view") suspend fun scrobble(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getBookmarks.view") suspend fun getBookmarks(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/createBookmark.view") suspend fun createBookmark(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deleteBookmark.view") suspend fun deleteBookmark(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getInternetRadioStations.view") suspend fun getInternetRadioStations(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/createInternetRadioStation.view") suspend fun createInternetRadioStation(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/updateInternetRadioStation.view") suspend fun updateInternetRadioStation(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deleteInternetRadioStation.view") suspend fun deleteInternetRadioStation(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getPodcasts.view") suspend fun getPodcasts(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/refreshPodcasts.view") suspend fun refreshPodcasts(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/createPodcastChannel.view") suspend fun createPodcastChannel(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deletePodcastChannel.view") suspend fun deletePodcastChannel(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deletePodcastEpisode.view") suspend fun deletePodcastEpisode(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/downloadPodcastEpisode.view") suspend fun downloadPodcastEpisode(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/getShares.view") suspend fun getShares(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/createShare.view") suspend fun createShare(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/updateShare.view") suspend fun updateShare(@QueryMap q: Map<String, String>): SubsonicEnvelope
@GET("rest/deleteShare.view") suspend fun deleteShare(@QueryMap q: Map<String, String>): SubsonicEnvelope
}

View File

@ -0,0 +1,31 @@
package lol.frijole.ampfire.subsonic
import java.math.BigInteger
import java.security.MessageDigest
import java.security.SecureRandom
class SubsonicAuth private constructor(
private val username: String,
private val token: String,
private val salt: String,
) {
fun params(): Map<String, String> = mapOf(
"u" to username,
"t" to token,
"s" to salt,
"v" to "1.16.1",
"c" to "AmpFire",
"f" to "json",
)
fun queryString(): String = params().map { (k, v) -> "$k=${java.net.URLEncoder.encode(v, "UTF-8")}" }.joinToString("&")
companion object {
fun forPassword(username: String, password: String): SubsonicAuth {
val salt = BigInteger(64, SecureRandom()).toString(16)
val digest = MessageDigest.getInstance("MD5").digest((password + salt).toByteArray())
.joinToString("") { "%02x".format(it) }
return SubsonicAuth(username, digest, salt)
}
}
}

View File

@ -0,0 +1,25 @@
package lol.frijole.ampfire.subsonic
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import lol.frijole.ampfire.ServerSettings
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
object SubsonicClientFactory {
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
fun create(settings: ServerSettings): SubsonicApi {
val client = OkHttpClient.Builder()
// Deliberately no URL/body logging: auth params may include credentials/tokens.
.build()
return Retrofit.Builder()
.baseUrl(settings.serverUrl.trimEnd('/') + "/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SubsonicApi::class.java)
}
}

View File

@ -0,0 +1,47 @@
package lol.frijole.ampfire.subsonic.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubsonicEnvelope(
@SerialName("subsonic-response") val response: SubsonicResponse
)
@Serializable
data class SubsonicResponse(
val status: String,
val version: String? = null,
val type: String? = null,
val serverVersion: String? = null,
val error: SubsonicError? = null,
val artists: ArtistsResponse? = null,
val artist: ArtistDetail? = null,
val album: AlbumDetail? = null,
val song: Song? = null,
val albumList2: AlbumList2? = null,
val playlists: Playlists? = null,
val playlist: PlaylistDetail? = null,
val searchResult3: SearchResult3? = null,
val starred2: Starred2? = null,
val randomSongs: SongList? = null,
val songsByGenre: SongList? = null,
val nowPlaying: NowPlaying? = null,
)
@Serializable data class SubsonicError(val code: Int, val message: String)
@Serializable data class ArtistsResponse(val index: List<ArtistIndex> = emptyList())
@Serializable data class ArtistIndex(val name: String, val artist: List<Artist> = emptyList())
@Serializable data class Artist(val id: String, val name: String, val albumCount: Int = 0, val coverArt: String? = null, val artistImageUrl: String? = null)
@Serializable data class ArtistDetail(val id: String, val name: String, val album: List<Album> = emptyList(), val coverArt: String? = null, val artistImageUrl: String? = null)
@Serializable data class AlbumList2(val album: List<Album> = emptyList())
@Serializable data class Album(val id: String, val name: String, val artist: String? = null, val artistId: String? = null, val coverArt: String? = null, val songCount: Int = 0, val duration: Int = 0, val year: Int? = null, val genre: String? = null)
@Serializable data class AlbumDetail(val id: String, val name: String, val artist: String? = null, val artistId: String? = null, val coverArt: String? = null, val song: List<Song> = emptyList(), val duration: Int = 0, val year: Int? = null, val genre: String? = null)
@Serializable data class Song(val id: String, val title: String, val album: String? = null, val albumId: String? = null, val artist: String? = null, val artistId: String? = null, val track: Int? = null, val year: Int? = null, val genre: String? = null, val coverArt: String? = null, val size: Long? = null, val contentType: String? = null, val suffix: String? = null, val duration: Int? = null, val bitRate: Int? = null, val path: String? = null, val starred: String? = null)
@Serializable data class Playlists(val playlist: List<Playlist> = emptyList())
@Serializable data class Playlist(val id: String, val name: String, val songCount: Int = 0, val duration: Int = 0, val owner: String? = null, val public: Boolean? = null, val coverArt: String? = null)
@Serializable data class PlaylistDetail(val id: String, val name: String, val entry: List<Song> = emptyList(), val songCount: Int = 0, val duration: Int = 0)
@Serializable data class SearchResult3(val artist: List<Artist> = emptyList(), val album: List<Album> = emptyList(), val song: List<Song> = emptyList())
@Serializable data class Starred2(val artist: List<Artist> = emptyList(), val album: List<Album> = emptyList(), val song: List<Song> = emptyList())
@Serializable data class SongList(val song: List<Song> = emptyList())
@Serializable data class NowPlaying(val entry: List<Song> = emptyList())

View File

@ -0,0 +1,177 @@
package lol.frijole.ampfire.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import lol.frijole.ampfire.AmpFireViewModel
import lol.frijole.ampfire.subsonic.model.Album
import lol.frijole.ampfire.subsonic.model.Artist
import lol.frijole.ampfire.subsonic.model.Playlist
import lol.frijole.ampfire.subsonic.model.Song
@Composable
fun AmpFireApp(vm: AmpFireViewModel) {
val state by vm.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.radialGradient(listOf(Color(0xFF2B1205), Color(0xFF070811)), radius = 1300f))
.padding(48.dp)
) {
if (!state.settings.isUsable()) {
LoginPanel(onSave = vm::saveSettings)
} else {
HomeScreen(
error = state.error,
artists = state.artists,
albums = state.albums,
playlists = state.playlists,
searchResults = state.searchResults,
onSearch = vm::search,
onRefresh = vm::refreshHome,
)
}
}
}
@Composable
private fun LoginPanel(onSave: (String, String, String) -> Unit) {
var server by remember { mutableStateOf("https://navidrome.example.com") }
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("AmpFire", fontSize = 72.sp, fontWeight = FontWeight.Black, color = Color.White)
Text("Your self-hosted music, tuned for Fire TV.", fontSize = 26.sp, color = Color(0xFFFFB000))
Spacer(Modifier.height(24.dp))
Text("Subsonic/Navidrome compatible • remote-first • publish-ready", color = Color(0xFFC8CBE0), fontSize = 18.sp)
}
Spacer(Modifier.width(48.dp))
Card(colors = CardDefaults.cardColors(containerColor = Color(0xDD11131F)), shape = RoundedCornerShape(28.dp), border = BorderStroke(1.dp, Color(0x44FFB000))) {
Column(Modifier.width(520.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text("Connect server", fontSize = 32.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(server, { server = it }, label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(user, { user = it }, label = { Text("Username") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(pass, { pass = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
Button(onClick = { onSave(server, user, pass) }, modifier = Modifier.fillMaxWidth()) { Text("Ignite AmpFire") }
}
}
}
}
@Composable
private fun HomeScreen(
error: String?,
artists: List<Artist>,
albums: List<Album>,
playlists: List<Playlist>,
searchResults: List<Song>,
onSearch: (String) -> Unit,
onRefresh: () -> Unit,
) {
var query by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("AmpFire", fontSize = 54.sp, fontWeight = FontWeight.Black)
Text("Built for the couch. Fueled by Subsonic.", color = Color(0xFFFFB000), fontSize = 20.sp)
}
OutlinedTextField(query, { query = it; onSearch(it) }, label = { Text("Search library") }, singleLine = true, modifier = Modifier.width(420.dp))
Spacer(Modifier.width(16.dp))
Button(onClick = onRefresh) { Text("Refresh") }
}
if (error != null) Text(error, color = Color(0xFFFF8A80), modifier = Modifier.padding(top = 16.dp))
Spacer(Modifier.height(28.dp))
if (searchResults.isNotEmpty()) SongRail("Search results", searchResults)
AlbumRail("New albums", albums)
ArtistRail("Artists", artists)
PlaylistRail("Playlists", playlists)
Spacer(Modifier.weight(1f))
NowPlayingBar()
}
}
@Composable
private fun AlbumRail(title: String, items: List<Album>) = Rail(title) { items(items) { MediaTile(it.name, it.artist ?: "Album", "") } }
@Composable
private fun ArtistRail(title: String, items: List<Artist>) = Rail(title) { items(items) { MediaTile(it.name, "${it.albumCount} albums", "") } }
@Composable
private fun PlaylistRail(title: String, items: List<Playlist>) = Rail(title) { items(items) { MediaTile(it.name, "${it.songCount} tracks", "") } }
@Composable
private fun SongRail(title: String, items: List<Song>) = Rail(title) { items(items) { MediaTile(it.title, it.artist ?: "Song", "") } }
@Composable
private fun Rail(title: String, content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) {
Text(title, fontSize = 26.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 18.dp, bottom = 12.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), contentPadding = PaddingValues(end = 48.dp), content = content)
}
@Composable
private fun MediaTile(title: String, subtitle: String, glyph: String) {
Card(
modifier = Modifier.size(width = 220.dp, height = 260.dp),
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xEE151827)),
border = BorderStroke(1.dp, Color(0x33FFFFFF)),
) {
Column(Modifier.padding(18.dp)) {
Box(Modifier.fillMaxWidth().height(140.dp).clip(RoundedCornerShape(20.dp)).background(Brush.linearGradient(listOf(Color(0xFFFF6A00), Color(0xFF7C5CFF)))), contentAlignment = Alignment.Center) {
Text(glyph, fontSize = 58.sp, color = Color.White)
}
Spacer(Modifier.height(14.dp))
Text(title, maxLines = 2, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(subtitle, maxLines = 1, color = Color(0xFFB9BDD4), fontSize = 14.sp)
}
}
}
@Composable
private fun NowPlayingBar() {
Card(colors = CardDefaults.cardColors(containerColor = Color(0xEE0D0F19)), shape = RoundedCornerShape(24.dp), border = BorderStroke(1.dp, Color(0x44FF6A00))) {
Row(Modifier.fillMaxWidth().padding(22.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(72.dp).clip(RoundedCornerShape(16.dp)).background(Color(0xFFFF6A00)), contentAlignment = Alignment.Center) { Text("", fontSize = 32.sp) }
Spacer(Modifier.width(18.dp))
Column(Modifier.weight(1f)) {
Text("Ready to burn", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text("Select a track to start streaming", color = Color(0xFFB9BDD4))
}
Text("Shuffle • Repeat • Queue", color = Color(0xFFFFB000))
}
}
}

View File

@ -0,0 +1,24 @@
package lol.frijole.ampfire.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val AmpFireColors = darkColorScheme(
primary = Color(0xFFFF6A00),
secondary = Color(0xFFFFB000),
tertiary = Color(0xFF7C5CFF),
background = Color(0xFF070811),
surface = Color(0xFF11131F),
surfaceVariant = Color(0xFF1C2030),
onPrimary = Color.Black,
onBackground = Color(0xFFF5F7FF),
onSurface = Color(0xFFF5F7FF),
)
@Composable
fun AmpFireTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = AmpFireColors, content = content)
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient android:startColor="#090A12" android:endColor="#FF6A00" android:angle="0" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF6A00" android:pathData="M18,72 L38,18 L50,18 L42,50 L66,18 L90,18 L60,58 L78,58 L36,96 L48,66 Z" />
<path android:fillColor="#FFFFFF" android:fillAlpha="0.86" android:pathData="M24,78 L54,64 L45,88 Z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#090A12</color>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AmpFire" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#FF6A00</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Release posture: trust platform CAs, no trust-all certificate bypass. -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Personal sideload testing can explicitly opt into HTTP by server URL handling later. -->
</network-security-config>

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
}

View File

@ -0,0 +1,26 @@
# Privacy and publishing posture
AmpFire is planned as a self-hosted music client for user-controlled Subsonic/Navidrome-compatible servers.
## Data handled by the app
- Server URL supplied by the user.
- Username and password/token used to authenticate to that server.
- Music library metadata returned by the user's server.
- Playback activity if scrobbling is enabled.
## First release goals
- No ads.
- No third-party analytics.
- No credential logging.
- No trust-all TLS bypass in release builds.
- Reviewer/demo server support before Amazon Appstore submission.
## Before Appstore submission
- Replace the temporary DataStore credential persistence with Android keystore-backed encrypted storage.
- Publish a privacy policy URL.
- Create app icon, banner, screenshots, short description, full description, and support contact.
- Provide Amazon reviewers with a demo server or built-in demo mode.
- Verify remote-only navigation on physical Fire TV hardware.

44
docs/ROADMAP.md Normal file
View File

@ -0,0 +1,44 @@
# AmpFire roadmap
## Phase 0 — foundation
- [x] Create Gitea repository.
- [x] Add Android TV / Fire TV project scaffold.
- [x] Add Subsonic API definitions.
- [x] Add Media3 playback service skeleton.
- [x] Add couch-distance UI shell.
- [x] Install Android build toolchain on dev host.
- [x] Produce first debug APK.
## Phase 1 — usable sideload client
- [ ] Login against Navidrome/Subsonic server.
- [ ] Persist server settings securely.
- [ ] Browse artists, albums, songs, playlists.
- [ ] Search with `search3`.
- [ ] Stream via Media3.
- [ ] Queue support.
- [ ] Album art via `getCoverArt`.
- [ ] Fire TV remote controls.
## Phase 2 — full Subsonic client behavior
- [ ] Star/unstar artists/albums/songs.
- [ ] Scrobble now playing/submission events.
- [ ] Ratings.
- [ ] Bookmarks.
- [ ] Internet radio stations.
- [ ] Shares where supported.
- [ ] Podcast endpoints where supported.
- [ ] Playlist create/update/delete.
- [ ] Lyrics endpoint support where supported.
## Phase 3 — publish readiness
- [ ] Demo mode or reviewer demo Navidrome instance.
- [ ] Privacy policy.
- [ ] App icons, banners, screenshots.
- [ ] Release signing.
- [ ] Amazon Appstore metadata.
- [ ] Fire TV physical-device certification pass.
- [ ] Crash-free startup and back-button testing.

View File

@ -0,0 +1,77 @@
# Subsonic API coverage target
AmpFire targets broad compatibility with Navidrome and generic Subsonic API servers.
## System
- ping
- getLicense
- getOpenSubsonicExtensions
## Browsing/library
- getMusicFolders
- getIndexes
- getMusicDirectory
- getArtists
- getArtist
- getAlbum
- getSong
- getGenres
- getAlbumList2
- getStarred2
- getRandomSongs
- getSongsByGenre
- getNowPlaying
## Search
- search3
## Media/art
- stream
- download
- getCoverArt
- getLyrics
- getAvatar
## Playlists
- getPlaylists
- getPlaylist
- createPlaylist
- updatePlaylist
- deletePlaylist
## User actions
- star
- unstar
- setRating
- scrobble
## Bookmarks
- getBookmarks
- createBookmark
- deleteBookmark
## Radio/podcasts/sharing
- getInternetRadioStations
- createInternetRadioStation
- updateInternetRadioStation
- deleteInternetRadioStation
- getPodcasts
- refreshPodcasts
- createPodcastChannel
- deletePodcastChannel
- deletePodcastEpisode
- downloadPodcastEpisode
- getShares
- createShare
- updateShare
- deleteShare
Not every server implements every endpoint. UI must treat unsupported endpoints as capability-gated features.

4
gradle.properties Normal file
View File

@ -0,0 +1,4 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
settings.gradle.kts Normal file
View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "AmpFire"
include(":app")