commit 4f9c449405eda85eab2e40dec8bdf4a5f5116271 Author: Scooter Date: Mon Jun 22 12:06:59 2026 -0500 Initial AmpFire Fire TV Subsonic client scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..848d862 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +*.apk +*.aab +*.jks +secrets.properties + +/app/build diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ac227a --- /dev/null +++ b/README.md @@ -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). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5119327 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e7c5191 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep class kotlinx.serialization.** { *; } +-keep @kotlinx.serialization.Serializable class * { *; } +-dontwarn org.slf4j.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..27617e3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/lol/frijole/ampfire/AmpFireApplication.kt b/app/src/main/java/lol/frijole/ampfire/AmpFireApplication.kt new file mode 100644 index 0000000..9c06616 --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/AmpFireApplication.kt @@ -0,0 +1,5 @@ +package lol.frijole.ampfire + +import android.app.Application + +class AmpFireApplication : Application() diff --git a/app/src/main/java/lol/frijole/ampfire/AmpFireViewModel.kt b/app/src/main/java/lol/frijole/ampfire/AmpFireViewModel.kt new file mode 100644 index 0000000..35fa3fe --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/AmpFireViewModel.kt @@ -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 = _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 create(modelClass: Class): 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 = emptyList(), + val albums: List = emptyList(), + val playlists: List = emptyList(), + val searchQuery: String = "", + val searchResults: List = 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() +} diff --git a/app/src/main/java/lol/frijole/ampfire/MainActivity.kt b/app/src/main/java/lol/frijole/ampfire/MainActivity.kt new file mode 100644 index 0000000..8044e0d --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/MainActivity.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/data/SettingsStore.kt b/app/src/main/java/lol/frijole/ampfire/data/SettingsStore.kt new file mode 100644 index 0000000..dd824fa --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/data/SettingsStore.kt @@ -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 = 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 + } + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/data/SubsonicRepository.kt b/app/src/main/java/lol/frijole/ampfire/data/SubsonicRepository.kt new file mode 100644 index 0000000..16d50d8 --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/data/SubsonicRepository.kt @@ -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 = + api.getArtists(auth.params()).response.artists?.index.orEmpty().flatMap { it.artist.orEmpty() } + + suspend fun getNewestAlbums(size: Int): List = + api.getAlbumList2(auth.params() + mapOf("type" to "newest", "size" to size.toString())).response.albumList2?.album.orEmpty() + + suspend fun getPlaylists(): List = + api.getPlaylists(auth.params()).response.playlists?.playlist.orEmpty() + + suspend fun search(query: String): List = + 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() +} diff --git a/app/src/main/java/lol/frijole/ampfire/playback/AmpFirePlaybackService.kt b/app/src/main/java/lol/frijole/ampfire/playback/AmpFirePlaybackService.kt new file mode 100644 index 0000000..c76874d --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/playback/AmpFirePlaybackService.kt @@ -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() + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicApi.kt b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicApi.kt new file mode 100644 index 0000000..ddb49da --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicApi.kt @@ -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): SubsonicEnvelope + @GET("rest/getLicense.view") suspend fun getLicense(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getOpenSubsonicExtensions.view") suspend fun getOpenSubsonicExtensions(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/getMusicFolders.view") suspend fun getMusicFolders(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getIndexes.view") suspend fun getIndexes(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getMusicDirectory.view") suspend fun getMusicDirectory(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getArtists.view") suspend fun getArtists(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getArtist.view") suspend fun getArtist(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getAlbum.view") suspend fun getAlbum(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getSong.view") suspend fun getSong(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getGenres.view") suspend fun getGenres(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getAlbumList2.view") suspend fun getAlbumList2(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getStarred2.view") suspend fun getStarred2(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getRandomSongs.view") suspend fun getRandomSongs(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getSongsByGenre.view") suspend fun getSongsByGenre(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getNowPlaying.view") suspend fun getNowPlaying(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/search3.view") suspend fun search3(@QueryMap q: Map): SubsonicEnvelope + + @Streaming @GET("rest/stream.view") suspend fun stream(@QueryMap q: Map): ResponseBody + @Streaming @GET("rest/download.view") suspend fun download(@QueryMap q: Map): ResponseBody + @Streaming @GET("rest/getCoverArt.view") suspend fun getCoverArt(@QueryMap q: Map): ResponseBody + @GET("rest/getLyrics.view") suspend fun getLyrics(@QueryMap q: Map): SubsonicEnvelope + @Streaming @GET("rest/getAvatar.view") suspend fun getAvatar(@QueryMap q: Map): ResponseBody + + @GET("rest/getPlaylists.view") suspend fun getPlaylists(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/getPlaylist.view") suspend fun getPlaylist(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/createPlaylist.view") suspend fun createPlaylist(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/updatePlaylist.view") suspend fun updatePlaylist(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deletePlaylist.view") suspend fun deletePlaylist(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/star.view") suspend fun star(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/unstar.view") suspend fun unstar(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/setRating.view") suspend fun setRating(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/scrobble.view") suspend fun scrobble(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/getBookmarks.view") suspend fun getBookmarks(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/createBookmark.view") suspend fun createBookmark(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deleteBookmark.view") suspend fun deleteBookmark(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/getInternetRadioStations.view") suspend fun getInternetRadioStations(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/createInternetRadioStation.view") suspend fun createInternetRadioStation(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/updateInternetRadioStation.view") suspend fun updateInternetRadioStation(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deleteInternetRadioStation.view") suspend fun deleteInternetRadioStation(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/getPodcasts.view") suspend fun getPodcasts(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/refreshPodcasts.view") suspend fun refreshPodcasts(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/createPodcastChannel.view") suspend fun createPodcastChannel(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deletePodcastChannel.view") suspend fun deletePodcastChannel(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deletePodcastEpisode.view") suspend fun deletePodcastEpisode(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/downloadPodcastEpisode.view") suspend fun downloadPodcastEpisode(@QueryMap q: Map): SubsonicEnvelope + + @GET("rest/getShares.view") suspend fun getShares(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/createShare.view") suspend fun createShare(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/updateShare.view") suspend fun updateShare(@QueryMap q: Map): SubsonicEnvelope + @GET("rest/deleteShare.view") suspend fun deleteShare(@QueryMap q: Map): SubsonicEnvelope +} diff --git a/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicAuth.kt b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicAuth.kt new file mode 100644 index 0000000..e2a387d --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicAuth.kt @@ -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 = 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) + } + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicClientFactory.kt b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicClientFactory.kt new file mode 100644 index 0000000..ee9a72c --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/subsonic/SubsonicClientFactory.kt @@ -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) + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/subsonic/model/SubsonicModels.kt b/app/src/main/java/lol/frijole/ampfire/subsonic/model/SubsonicModels.kt new file mode 100644 index 0000000..a3d4b66 --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/subsonic/model/SubsonicModels.kt @@ -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 = emptyList()) +@Serializable data class ArtistIndex(val name: String, val artist: List = 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 = emptyList(), val coverArt: String? = null, val artistImageUrl: String? = null) +@Serializable data class AlbumList2(val album: List = 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 = 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 = 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 = emptyList(), val songCount: Int = 0, val duration: Int = 0) +@Serializable data class SearchResult3(val artist: List = emptyList(), val album: List = emptyList(), val song: List = emptyList()) +@Serializable data class Starred2(val artist: List = emptyList(), val album: List = emptyList(), val song: List = emptyList()) +@Serializable data class SongList(val song: List = emptyList()) +@Serializable data class NowPlaying(val entry: List = emptyList()) diff --git a/app/src/main/java/lol/frijole/ampfire/ui/AmpFireApp.kt b/app/src/main/java/lol/frijole/ampfire/ui/AmpFireApp.kt new file mode 100644 index 0000000..da10961 --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/ui/AmpFireApp.kt @@ -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, + albums: List, + playlists: List, + searchResults: List, + 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) = Rail(title) { items(items) { MediaTile(it.name, it.artist ?: "Album", "♪") } } +@Composable +private fun ArtistRail(title: String, items: List) = Rail(title) { items(items) { MediaTile(it.name, "${it.albumCount} albums", "★") } } +@Composable +private fun PlaylistRail(title: String, items: List) = Rail(title) { items(items) { MediaTile(it.name, "${it.songCount} tracks", "▶") } } +@Composable +private fun SongRail(title: String, items: List) = 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)) + } + } +} diff --git a/app/src/main/java/lol/frijole/ampfire/ui/AmpFireTheme.kt b/app/src/main/java/lol/frijole/ampfire/ui/AmpFireTheme.kt new file mode 100644 index 0000000..476f679 --- /dev/null +++ b/app/src/main/java/lol/frijole/ampfire/ui/AmpFireTheme.kt @@ -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) +} diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml new file mode 100644 index 0000000..8bcfc9e --- /dev/null +++ b/app/src/main/res/drawable/app_banner.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..adc73d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..b35f374 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #090A12 + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..62956ff --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..9f0fe38 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9d99fad --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/docs/PRIVACY_AND_PUBLISHING.md b/docs/PRIVACY_AND_PUBLISHING.md new file mode 100644 index 0000000..aaf235a --- /dev/null +++ b/docs/PRIVACY_AND_PUBLISHING.md @@ -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. \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..e5846f7 --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/docs/SUBSONIC_API_COVERAGE.md b/docs/SUBSONIC_API_COVERAGE.md new file mode 100644 index 0000000..b7a81b5 --- /dev/null +++ b/docs/SUBSONIC_API_COVERAGE.md @@ -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. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f37de70 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b810da3 --- /dev/null +++ b/settings.gradle.kts @@ -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")