commit a11b5a68c030d6d9968d7869e58b46aa1005a7e2 Author: be-nj Date: Tue Aug 25 00:09:00 2026 +0200 Bootstrap Castarr from NodeCast prototype Imported the native TV app (Kotlin, ExoPlayer, embedded remote server, QR pairing) plus CONTEXT.md and ADRs 0001-0005. Rename, Compose for TV UI and the Dispatcharr integration follow as tracked issues. Co-Authored-By: Claude Fable 5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2b745f8 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build TV app + +on: + push: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: castarr-debug-apk + path: app/build/outputs/apk/debug/app-debug.apk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77d60bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.kotlin/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ce7a427 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,70 @@ +# Castarr + +Native Kotlin app for Google TV that lets every user of a Dispatcharr backend +watch live IPTV comfortably on their own TV: full D-pad UI, login via OIDC +Device Flow (Authentik), plus an optional phone Remote paired via QR code. + +## Language + +**TV-App (Castarr)**: +The native Kotlin app running on Google TV; full TV UI, plays streams, serves +the Remote. +_Avoid_: receiver, player app, NodeCast (old name) + +**Device Flow**: +OIDC device authorization grant: TV shows QR/code, user confirms on the phone +at Authentik, app receives Bearer tokens for the Backend API. +_Avoid_: pairing (that word is reserved for the Remote) + +**Backend (Dispatcharr)**: +The self-hosted stream manager owning sources, aggregation, failover, EPG and +stream profiles; forked at be-nj with native OIDC. +_Avoid_: server (ambiguous), nodecast (dropped, see ADR-0003) + +**Quelle (Source)**: +A configured backend the TV-App reads channels from — primary type Dispatcharr +fork (Bearer API), fallback type generic M3U+XMLTV without login (Threadfin, +Tunarr, ErsatzTV). + +**Remote**: +The phone web UI served by the TV-App over HTTP; talks to the TV-App via +WebSocket. Never talks to the Backend directly. +_Avoid_: app (it is not an installed app), controller + +**Pairing**: +Trusting a Remote via the QR token (128-bit) or the rate-limited 4-digit code. + +**Channel**: +A playable live entry the Backend exposes via Xtream API or M3U. +_Avoid_: station, sender (in code/docs) + +**Now/Next**: +The EPG scope of the Remote in phase 1: current + upcoming programme per +Channel, no full guide timeline. + +## Relationships + +- The **TV-App** is a client of the **Backend**; the **Remote** only ever + talks to the TV-App. +- One **User** logs into one **TV-App** via **Device Flow**; favorites belong + to the User on the Backend, not to the device. +- A **Remote** controls exactly one **TV-App**; a TV-App accepts multiple + paired Remotes. +- Aggregation, failover and stream shaping happen in the **Backend**, not in + the TV-App. + +## Example dialogue + +> **Dev:** "Does the **Remote** need Backend credentials?" +> **Domain expert:** "No — the user enters the Xtream credentials of a +> **Quelle** once via the Remote, the **TV-App** stores them and is the only +> one talking to the **Backend**." + +## Flagged ambiguities + +- "nativ" means: no Flutter/WebView wrapper — Kotlin app. It does not mean + "no embedded web content" (the Remote is deliberately a web page). +- "Passthrough" means the fork's `raw` stream profile (ffmpeg copy), selected + per request via profile parameter — not a bypass of the Backend proxy. +- Favorites are per **User** in the Dispatcharr fork (resolved; app-local and + channel-group approaches were rejected). diff --git a/README.md b/README.md new file mode 100644 index 0000000..371c5c2 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Castarr + +Native Google TV client for a [Dispatcharr](https://github.com/be-nj/Dispatcharr) +backend: live IPTV with a full D-pad UI, login via OIDC device flow +(Authentik), per-user favorites, and an optional phone remote paired by QR +code (served by the TV itself, no phone app install). + +Status: bootstrap. The code base was imported from the NodeCast prototype +(phone-remote-first, M3U standalone) and is being reshaped per the decisions +in [CONTEXT.md](CONTEXT.md) and [docs/adr/](docs/adr/) — see the issue tracker +for the phase-1 slices. + +## Building + +Requires JDK 17+ and the Android SDK (platform 35). + +``` +./gradlew assembleDebug +``` + +APK output: `app/build/outputs/apk/debug/app-debug.apk`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..97fa53e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.nodecast.tv" + compileSdk = 35 + + defaultConfig { + applicationId = "com.nodecast.tv" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + allWarningsAsErrors = true + } + + lint { + // Media3's UI classes are @UnstableApi; the opt-in lint check would + // otherwise fail release builds. + disable += "UnsafeOptInUsageError" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("androidx.media3:media3-exoplayer-hls:1.4.1") + implementation("androidx.media3:media3-ui:1.4.1") + implementation("androidx.media3:media3-session:1.4.1") + implementation("org.nanohttpd:nanohttpd:2.3.1") + implementation("org.nanohttpd:nanohttpd-websocket:2.3.1") + implementation("com.google.zxing:core:3.5.3") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fae81cc --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# NanoHTTPD loads its mime-type tables reflectively. +-keep class fi.iki.elonen.** { *; } +-dontwarn fi.iki.elonen.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..177a50a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/remote/index.html b/app/src/main/assets/remote/index.html new file mode 100644 index 0000000..0bcd256 --- /dev/null +++ b/app/src/main/assets/remote/index.html @@ -0,0 +1,502 @@ + + + + + + +NodeCast Remote + + + + + +
+
+ + NodeCast +
+
Getrennt
+
+ +
+ +
+

Mit dem TV koppeln

+

Gib den 4-stelligen Code ein, der unten auf dem TV-Bildschirm steht.

+ + +
+
+ + +
+
+
+ +
+ +
Nichts läuft
+
Wähle einen Sender oder starte eine URL
+
+
+
+ + + +
+
+ + + +
+
+ + +
+
+

Sender

+ +
+
+ + + + + + +
+
+ + +
+
+
+
+ + + +
+ + + + diff --git a/app/src/main/java/com/nodecast/tv/MainActivity.kt b/app/src/main/java/com/nodecast/tv/MainActivity.kt new file mode 100644 index 0000000..48f6f65 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/MainActivity.kt @@ -0,0 +1,281 @@ +package com.nodecast.tv + +import android.app.Activity +import android.graphics.Color +import android.media.AudioManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.media3.ui.PlayerView +import com.nodecast.tv.pairing.Pairing +import com.nodecast.tv.pairing.Qr +import com.nodecast.tv.player.PlayerController +import com.nodecast.tv.playlist.Channel +import com.nodecast.tv.playlist.PlaylistRepository +import com.nodecast.tv.server.ControlServer +import org.json.JSONObject + +class MainActivity : Activity(), ControlServer.Listener { + + private lateinit var playerController: PlayerController + private lateinit var server: ControlServer + private lateinit var playlist: PlaylistRepository + private lateinit var audioManager: AudioManager + + private lateinit var pairingScreen: View + private lateinit var playerScreen: View + private lateinit var playerView: PlayerView + private lateinit var overlay: View + private lateinit var overlayChannel: TextView + private lateinit var overlayState: TextView + private lateinit var overlayLiveDot: View + private lateinit var overlayLiveLabel: TextView + private lateinit var overlayProgress: View + private lateinit var deviceChip: View + private lateinit var deviceName: TextView + private lateinit var pairingStatus: TextView + + private val mainHandler = Handler(Looper.getMainLooper()) + private val hideOverlay = Runnable { overlay.animate().alpha(0f).setDuration(400).start() } + private val ticker = object : Runnable { + override fun run() { + updateProgress() + server.broadcastStatus() + mainHandler.postDelayed(this, TICK_INTERVAL_MS) + } + } + private var connectedName: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + playlist = PlaylistRepository(this) + playerController = PlayerController(this) { onPlaybackChanged() } + + bindViews() + setupPairingScreen() + + server = ControlServer(this, this) + server.startServer() + } + + private fun bindViews() { + pairingScreen = findViewById(R.id.pairing_screen) + playerScreen = findViewById(R.id.player_screen) + playerView = findViewById(R.id.player_view) + overlay = findViewById(R.id.overlay) + overlayChannel = findViewById(R.id.overlay_channel) + overlayState = findViewById(R.id.overlay_state) + overlayLiveDot = findViewById(R.id.overlay_live_dot) + overlayLiveLabel = findViewById(R.id.overlay_live_label) + overlayProgress = findViewById(R.id.overlay_progress) + deviceChip = findViewById(R.id.device_chip) + deviceName = findViewById(R.id.device_name) + pairingStatus = findViewById(R.id.pairing_status) + + playerView.useController = false + playerView.player = playerController.player + overlayProgress.pivotX = 0f + } + + private fun setupPairingScreen() { + val address = Pairing.lanAddress() + val qrImage = findViewById(R.id.qr_image) + val urlText = findViewById(R.id.pairing_url) + val codeText = findViewById(R.id.pairing_code) + codeText.text = getString(R.string.pairing_code, Pairing.code(this)) + if (address != null) { + urlText.text = Pairing.remoteUrl(address) + qrImage.setImageBitmap( + Qr.encode(Pairing.pairingUrl(this, address), QR_SIZE_PX, Color.parseColor("#101216")) + ) + } else { + urlText.text = getString(R.string.no_network) + } + } + + // --- playback state → UI + remote --- + + private fun onPlaybackChanged() { + val showPlayer = playerController.hasMedia + pairingScreen.visibility = if (showPlayer) View.GONE else View.VISIBLE + playerScreen.visibility = if (showPlayer) View.VISIBLE else View.GONE + val active = playerController.state == "playing" || playerController.state == "buffering" + playerScreen.keepScreenOn = active + + mainHandler.removeCallbacks(ticker) + if (showPlayer) { + overlayChannel.text = playerController.channelName + val live = playerController.player.isCurrentMediaItemLive + overlayLiveDot.visibility = if (live) View.VISIBLE else View.GONE + overlayLiveLabel.text = if (live) getString(R.string.live) else "" + overlayState.text = when (playerController.state) { + "paused" -> getString(R.string.paused) + "buffering" -> getString(R.string.buffering) + "error" -> getString(R.string.playback_error, playerController.errorMessage.orEmpty()) + else -> getString(R.string.playing) + } + updateProgress() + showOverlay(autoHide = playerController.state == "playing") + if (active) mainHandler.postDelayed(ticker, TICK_INTERVAL_MS) + } + onClientsChanged(-1, null) + server.broadcastStatus() + } + + private fun updateProgress() { + val player = playerController.player + val fraction = if (player.isCurrentMediaItemLive || player.duration <= 0) { + 1f + } else { + (player.currentPosition.toFloat() / player.duration).coerceIn(0f, 1f) + } + overlayProgress.scaleX = fraction + } + + private fun showOverlay(autoHide: Boolean) { + mainHandler.removeCallbacks(hideOverlay) + overlay.animate().alpha(1f).setDuration(200).start() + if (autoHide) mainHandler.postDelayed(hideOverlay, OVERLAY_HIDE_DELAY_MS) + } + + // --- ControlServer.Listener (called on main thread) --- + + override fun onPlay(url: String, name: String, group: String) { + playerController.play(url, name, group) + } + + override fun onTogglePlay() = playerController.toggle() + + override fun onPause() = playerController.pause() + + override fun onResume() = playerController.resume() + + override fun onStopCast() = playerController.stop() + + override fun onSeek(deltaSeconds: Long) { + playerController.seekBy(deltaSeconds) + showOverlay(autoHide = true) + } + + override fun onVolume(value: Float) { + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt().coerceIn(0, max), 0) + server.broadcastStatus() + } + + override fun onSetPlaylist(url: String) { + playlist.refresh(url) { result -> + mainHandler.post { + result + .onSuccess { server.broadcastChannels() } + .onFailure { server.broadcastToast(getString(R.string.playlist_error)) } + } + } + } + + override fun onClientsChanged(count: Int, newestName: String?) { + if (newestName != null) connectedName = newestName + if (count >= 0) { + pairingStatus.text = if (count > 0) { + getString(R.string.paired_with, connectedName.orEmpty()) + } else { + getString(R.string.ready_to_pair) + } + } + val chipVisible = connectedName != null && playerController.hasMedia + deviceChip.visibility = if (chipVisible) View.VISIBLE else View.GONE + deviceName.text = connectedName.orEmpty() + } + + override fun currentStatus(): JSONObject { + val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val vol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val player = playerController.player + return JSONObject() + .put("state", playerController.state) + .put("channel", playerController.channelName) + .put("group", playerController.channelGroup) + .put("live", player.isCurrentMediaItemLive) + .put("seekable", player.isCurrentMediaItemSeekable) + .put("position", player.currentPosition.coerceAtLeast(0)) + .put("duration", player.duration.coerceAtLeast(0)) + .put("volume", if (max > 0) vol.toDouble() / max else 0.0) + .put("error", playerController.errorMessage ?: "") + } + + override fun currentChannels(): List = playlist.channels + + override fun currentPlaylistUrl(): String = playlist.playlistUrl + + // --- TV remote keys (TV-PC / TV-PP) --- + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (playerController.hasMedia) { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + playerController.toggle() + return true + } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + playerController.resume() + return true + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + playerController.pause() + return true + } + KeyEvent.KEYCODE_MEDIA_STOP -> { + playerController.stop() + return true + } + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_MEDIA_REWIND -> { + onSeek(-SEEK_STEP_SECONDS) + return true + } + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + onSeek(SEEK_STEP_SECONDS) + return true + } + KeyEvent.KEYCODE_BACK -> { + playerController.stop() + return true + } + else -> Unit + } + } + return super.onKeyDown(keyCode, event) + } + + // --- lifecycle --- + + override fun onStop() { + super.onStop() + // TV-NP: video must not keep playing when the user leaves the app. + playerController.pause() + } + + override fun onDestroy() { + super.onDestroy() + mainHandler.removeCallbacksAndMessages(null) + server.stopServer() + playerController.release() + } + + private companion object { + const val QR_SIZE_PX = 512 + const val OVERLAY_HIDE_DELAY_MS = 4_000L + const val TICK_INTERVAL_MS = 2_000L + const val SEEK_STEP_SECONDS = 10L + } +} diff --git a/app/src/main/java/com/nodecast/tv/pairing/Pairing.kt b/app/src/main/java/com/nodecast/tv/pairing/Pairing.kt new file mode 100644 index 0000000..8ff34af --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/pairing/Pairing.kt @@ -0,0 +1,54 @@ +package com.nodecast.tv.pairing + +import android.content.Context +import java.net.Inet4Address +import java.net.NetworkInterface +import java.security.SecureRandom + +object Pairing { + + const val PORT = 8765 + + /** + * Four-digit pairing code, generated once and kept stable so an already + * paired phone survives app restarts. Human fallback only — the QR code + * carries the long token below. + */ + fun code(context: Context): String { + val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) + prefs.getString("code", null)?.let { return it } + val code = "%04d".format(SecureRandom().nextInt(10_000)) + prefs.edit().putString("code", code).apply() + return code + } + + /** 128-bit random token embedded in the QR code; not brute-forceable. */ + fun token(context: Context): String { + val prefs = context.getSharedPreferences("pairing", Context.MODE_PRIVATE) + prefs.getString("token", null)?.let { return it } + val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) } + val token = bytes.joinToString("") { "%02x".format(it) } + prefs.edit().putString("token", token).apply() + return token + } + + /** Best-guess LAN IPv4 address of this device, or null when offline. */ + fun lanAddress(): String? { + val candidates = runCatching { + NetworkInterface.getNetworkInterfaces().asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.asSequence() } + .filterIsInstance() + .filter { it.isSiteLocalAddress } + .map { it.hostAddress } + .filterNotNull() + .toList() + }.getOrDefault(emptyList()) + return candidates.firstOrNull() + } + + fun remoteUrl(address: String): String = "http://$address:$PORT" + + fun pairingUrl(context: Context, address: String): String = + "${remoteUrl(address)}/?t=${token(context)}" +} diff --git a/app/src/main/java/com/nodecast/tv/pairing/Qr.kt b/app/src/main/java/com/nodecast/tv/pairing/Qr.kt new file mode 100644 index 0000000..3571ad6 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/pairing/Qr.kt @@ -0,0 +1,26 @@ +package com.nodecast.tv.pairing + +import android.graphics.Bitmap +import android.graphics.Color +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel + +object Qr { + + fun encode(content: String, size: Int, foreground: Int, background: Int = Color.WHITE): Bitmap { + val hints = mapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M, + ) + val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) + val pixels = IntArray(size * size) + for (y in 0 until size) { + for (x in 0 until size) { + pixels[y * size + x] = if (matrix.get(x, y)) foreground else background + } + } + return Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) + } +} diff --git a/app/src/main/java/com/nodecast/tv/player/PlayerController.kt b/app/src/main/java/com/nodecast/tv/player/PlayerController.kt new file mode 100644 index 0000000..d4b6e30 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/player/PlayerController.kt @@ -0,0 +1,105 @@ +package com.nodecast.tv.player + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession + +/** + * Wraps ExoPlayer plus a MediaSession so the physical TV remote's play/pause + * keys and Google Assistant work alongside the phone remote (TV-PP/TV-VC of + * the TV app quality guidelines). + */ +class PlayerController( + context: Context, + private val onChanged: () -> Unit, +) { + + val player: ExoPlayer = ExoPlayer.Builder(context).build() + private val mediaSession: MediaSession = MediaSession.Builder(context, player).build() + + var channelName: String = "" + private set + var channelGroup: String = "" + private set + var errorMessage: String? = null + private set + + init { + player.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) = onChanged() + + override fun onIsPlayingChanged(isPlaying: Boolean) = onChanged() + + override fun onPlayerError(error: PlaybackException) { + errorMessage = error.errorCodeName + onChanged() + } + }) + } + + val state: String + get() = when { + errorMessage != null -> "error" + player.playbackState == Player.STATE_BUFFERING -> "buffering" + player.playbackState == Player.STATE_READY && player.playWhenReady -> "playing" + player.playbackState == Player.STATE_READY -> "paused" + else -> "idle" + } + + val hasMedia: Boolean + get() = player.mediaItemCount > 0 && player.playbackState != Player.STATE_IDLE + + fun play(url: String, name: String, group: String) { + if (url.isEmpty()) return + errorMessage = null + channelName = name.ifEmpty { url } + channelGroup = group + val item = MediaItem.Builder() + .setUri(url) + .setMediaMetadata(MediaMetadata.Builder().setTitle(channelName).build()) + .build() + player.setMediaItem(item) + player.prepare() + player.play() + } + + fun toggle() { + if (!hasMedia) return + if (player.isPlaying) player.pause() else resume() + } + + fun pause() { + if (player.isPlaying) player.pause() + } + + fun resume() { + if (!hasMedia) return + if (player.playbackState == Player.STATE_ENDED) player.seekToDefaultPosition() + player.play() + } + + fun stop() { + player.stop() + player.clearMediaItems() + channelName = "" + channelGroup = "" + errorMessage = null + onChanged() + } + + fun seekBy(deltaSeconds: Long) { + if (!hasMedia || !player.isCurrentMediaItemSeekable) return + val target = (player.currentPosition + deltaSeconds * 1000) + .coerceIn(0, player.duration.coerceAtLeast(0)) + player.seekTo(target) + } + + fun release() { + mediaSession.release() + player.release() + } +} diff --git a/app/src/main/java/com/nodecast/tv/playlist/Channel.kt b/app/src/main/java/com/nodecast/tv/playlist/Channel.kt new file mode 100644 index 0000000..dc6aec1 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/playlist/Channel.kt @@ -0,0 +1,35 @@ +package com.nodecast.tv.playlist + +import org.json.JSONArray +import org.json.JSONObject + +data class Channel( + val name: String, + val url: String, + val group: String, + val logo: String, +) { + fun toJson(): JSONObject = JSONObject() + .put("name", name) + .put("url", url) + .put("group", group) + .put("logo", logo) + + companion object { + fun fromJson(obj: JSONObject): Channel = Channel( + name = obj.optString("name"), + url = obj.optString("url"), + group = obj.optString("group"), + logo = obj.optString("logo"), + ) + + fun listToJson(channels: List): JSONArray { + val arr = JSONArray() + channels.forEach { arr.put(it.toJson()) } + return arr + } + + fun listFromJson(arr: JSONArray): List = + (0 until arr.length()).map { fromJson(arr.getJSONObject(it)) } + } +} diff --git a/app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt b/app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt new file mode 100644 index 0000000..20321e3 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/playlist/M3uParser.kt @@ -0,0 +1,48 @@ +package com.nodecast.tv.playlist + +object M3uParser { + + private const val MAX_CHANNELS = 5000 + + private val attrRegex = Regex("""([\w-]+)="([^"]*)"""") + + fun parse(content: String): List { + val channels = mutableListOf() + var name = "" + var group = "" + var logo = "" + var pendingInfo = false + + for (rawLine in content.lineSequence()) { + val line = rawLine.trim() + when { + line.startsWith("#EXTINF", ignoreCase = true) -> { + val attrs = attrRegex.findAll(line).associate { it.groupValues[1].lowercase() to it.groupValues[2] } + group = attrs["group-title"].orEmpty() + logo = attrs["tvg-logo"].orEmpty() + name = line.substringAfterLast(',', "").trim() + if (name.isEmpty()) name = attrs["tvg-name"].orEmpty() + pendingInfo = true + } + line.startsWith("#EXTGRP", ignoreCase = true) -> { + group = line.substringAfter(':', "").trim() + } + line.isEmpty() || line.startsWith("#") -> Unit + pendingInfo -> { + channels.add(Channel(name.ifEmpty { line }, line, group, logo)) + if (channels.size >= MAX_CHANNELS) return channels + name = "" + group = "" + logo = "" + pendingInfo = false + } + else -> { + // Bare URL without #EXTINF — still a playable entry. + channels.add(Channel(line, line, "", "")) + if (channels.size >= MAX_CHANNELS) return channels + } + } + } + return channels + } +} diff --git a/app/src/main/java/com/nodecast/tv/playlist/PlaylistRepository.kt b/app/src/main/java/com/nodecast/tv/playlist/PlaylistRepository.kt new file mode 100644 index 0000000..5235e7d --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/playlist/PlaylistRepository.kt @@ -0,0 +1,63 @@ +package com.nodecast.tv.playlist + +import android.content.Context +import org.json.JSONArray +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors + +/** + * Fetches and caches the M3U playlist. The channel list is persisted so the + * remote sees its channels again right after an app restart. + */ +class PlaylistRepository(context: Context) { + + private val prefs = context.getSharedPreferences("playlist", Context.MODE_PRIVATE) + private val executor = Executors.newSingleThreadExecutor() + + var channels: List = loadCached() + private set + + val playlistUrl: String + get() = prefs.getString(KEY_URL, "").orEmpty() + + fun refresh(url: String, onResult: (Result>) -> Unit) { + executor.execute { + val result = runCatching { + val content = download(url) + val parsed = M3uParser.parse(content) + require(parsed.isNotEmpty()) { "playlist is empty" } + parsed + } + result.onSuccess { parsed -> + channels = parsed + prefs.edit() + .putString(KEY_URL, url) + .putString(KEY_CACHE, Channel.listToJson(parsed).toString()) + .apply() + } + onResult(result) + } + } + + private fun download(url: String): String { + val connection = URL(url).openConnection() as HttpURLConnection + return try { + connection.connectTimeout = 15_000 + connection.readTimeout = 30_000 + connection.instanceFollowRedirects = true + connection.inputStream.bufferedReader().use { it.readText() } + } finally { + connection.disconnect() + } + } + + private fun loadCached(): List = runCatching { + Channel.listFromJson(JSONArray(prefs.getString(KEY_CACHE, "[]").orEmpty())) + }.getOrDefault(emptyList()) + + private companion object { + const val KEY_URL = "url" + const val KEY_CACHE = "channels" + } +} diff --git a/app/src/main/java/com/nodecast/tv/server/ControlServer.kt b/app/src/main/java/com/nodecast/tv/server/ControlServer.kt new file mode 100644 index 0000000..b56ad94 --- /dev/null +++ b/app/src/main/java/com/nodecast/tv/server/ControlServer.kt @@ -0,0 +1,244 @@ +package com.nodecast.tv.server + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.nodecast.tv.pairing.Pairing +import com.nodecast.tv.playlist.Channel +import fi.iki.elonen.NanoHTTPD +import fi.iki.elonen.NanoWSD +import org.json.JSONObject +import java.io.IOException +import java.util.Timer +import java.util.TimerTask +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML + * page) over HTTP and takes playback commands over a WebSocket. A client + * authorizes itself with the four-digit pairing code from the QR code. + */ +class ControlServer( + private val context: Context, + private val listener: Listener, +) : NanoWSD(Pairing.PORT) { + + interface Listener { + fun onPlay(url: String, name: String, group: String) + fun onTogglePlay() + fun onPause() + fun onResume() + fun onStopCast() + fun onSeek(deltaSeconds: Long) + fun onVolume(value: Float) + fun onSetPlaylist(url: String) + fun onClientsChanged(count: Int, newestName: String?) + fun currentStatus(): JSONObject + fun currentChannels(): List + fun currentPlaylistUrl(): String + } + + private val mainHandler = Handler(Looper.getMainLooper()) + private val clients = CopyOnWriteArrayList() + private val pairingCode = Pairing.code(context) + private val pairingToken = Pairing.token(context) + private var pingTimer: Timer? = null + + // Rate limit for the human-typable 4-digit code (the QR token is not + // brute-forceable and stays exempt). + private val codeAttempts = ArrayDeque() + + @Synchronized + private fun codeAttemptAllowed(): Boolean { + val now = System.currentTimeMillis() + while (codeAttempts.isNotEmpty() && now - codeAttempts.first() > CODE_ATTEMPT_WINDOW_MS) { + codeAttempts.removeFirst() + } + if (codeAttempts.size >= CODE_ATTEMPT_MAX) return false + codeAttempts.addLast(now) + return true + } + + fun startServer() { + start(0, true) + pingTimer = Timer("ws-ping", true).also { + it.schedule(object : TimerTask() { + override fun run() = pingClients() + }, PING_INTERVAL_MS, PING_INTERVAL_MS) + } + } + + fun stopServer() { + pingTimer?.cancel() + pingTimer = null + stop() + } + + // --- HTTP --- + + override fun serveHttp(session: IHTTPSession): Response { + return when (session.uri) { + "/", "/index.html" -> { + val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() } + newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply { + addHeader("Cache-Control", "no-store") + } + } + else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found") + } + } + + // --- WebSocket --- + + override fun openWebSocket(handshake: IHTTPSession): WebSocket = RemoteSocket(handshake) + + fun broadcast(message: JSONObject) { + val payload = message.toString() + clients.forEach { client -> + if (client.authorized) client.trySend(payload) + } + } + + fun broadcastStatus() { + broadcast(listener.currentStatus().put("type", "status")) + } + + fun broadcastChannels() { + broadcast(channelsMessage()) + } + + fun broadcastToast(message: String) { + broadcast(JSONObject().put("type", "toast").put("message", message)) + } + + private fun channelsMessage(): JSONObject = JSONObject() + .put("type", "channels") + .put("playlistUrl", listener.currentPlaylistUrl()) + .put("channels", Channel.listToJson(listener.currentChannels())) + + private fun pingClients() { + clients.forEach { client -> + try { + client.ping(PING_PAYLOAD) + } catch (e: IOException) { + Log.d(TAG, "ping failed, dropping client", e) + clients.remove(client) + } + } + notifyClientsChanged(null) + } + + private fun notifyClientsChanged(newestName: String?) { + val count = clients.count { it.authorized } + mainHandler.post { listener.onClientsChanged(count, newestName) } + } + + inner class RemoteSocket(handshake: IHTTPSession) : WebSocket(handshake) { + + @Volatile + var authorized = false + private set + + private var deviceName: String = "" + + fun trySend(payload: String) { + try { + send(payload) + } catch (e: IOException) { + Log.d(TAG, "send failed, dropping client", e) + clients.remove(this) + } + } + + override fun onOpen() { + clients.add(this) + } + + override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) { + clients.remove(this) + notifyClientsChanged(null) + } + + override fun onMessage(message: WebSocketFrame) { + val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return + val type = msg.optString("type") + if (!authorized) { + if (type == "hello") handleHello(msg) + return + } + when (type) { + "play" -> post { + listener.onPlay( + msg.optString("url"), + msg.optString("name"), + msg.optString("group"), + ) + } + "toggle" -> post { listener.onTogglePlay() } + "pause" -> post { listener.onPause() } + "resume" -> post { listener.onResume() } + "stop" -> post { listener.onStopCast() } + "seek" -> post { listener.onSeek(msg.optLong("delta")) } + "volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) } + "set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) } + "get_state" -> { + trySend(listener.currentStatus().put("type", "status").toString()) + trySend(channelsMessage().toString()) + } + else -> Unit + } + } + + override fun onPong(pong: WebSocketFrame?) = Unit + + override fun onException(exception: IOException?) { + clients.remove(this) + } + + private fun handleHello(msg: JSONObject) { + val token = msg.optString("token") + val tokenOk = token.isNotEmpty() && token == pairingToken + val codeOk = !tokenOk && msg.optString("code").let { code -> + code.isNotEmpty() && when { + !codeAttemptAllowed() -> { + trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString()) + runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) } + return + } + else -> code == pairingCode + } + } + if (!tokenOk && !codeOk) { + trySend(JSONObject().put("type", "error").put("error", "bad_code").toString()) + runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) } + return + } + authorized = true + deviceName = msg.optString("name").ifEmpty { "Handy" } + trySend( + JSONObject() + .put("type", "welcome") + .put("device", android.os.Build.MODEL) + .put("token", pairingToken) + .put("status", listener.currentStatus()) + .put("playlistUrl", listener.currentPlaylistUrl()) + .put("channels", Channel.listToJson(listener.currentChannels())) + .toString() + ) + notifyClientsChanged(deviceName) + } + + private fun post(action: () -> Unit) { + mainHandler.post(action) + } + } + + private companion object { + const val TAG = "ControlServer" + const val PING_INTERVAL_MS = 8_000L + const val CODE_ATTEMPT_WINDOW_MS = 60_000L + const val CODE_ATTEMPT_MAX = 5 + val PING_PAYLOAD = byteArrayOf(0x6e, 0x63) + } +} diff --git a/app/src/main/res/drawable/bg_chip.xml b/app/src/main/res/drawable/bg_chip.xml new file mode 100644 index 0000000..d7b0237 --- /dev/null +++ b/app/src/main/res/drawable/bg_chip.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_qr_card.xml b/app/src/main/res/drawable/bg_qr_card.xml new file mode 100644 index 0000000..faef234 --- /dev/null +++ b/app/src/main/res/drawable/bg_qr_card.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/dot_accent.xml b/app/src/main/res/drawable/dot_accent.xml new file mode 100644 index 0000000..f3dc0d4 --- /dev/null +++ b/app/src/main/res/drawable/dot_accent.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/dot_live.xml b/app/src/main/res/drawable/dot_live.xml new file mode 100644 index 0000000..5e57ddf --- /dev/null +++ b/app/src/main/res/drawable/dot_live.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cast.xml b/app/src/main/res/drawable/ic_cast.xml new file mode 100644 index 0000000..0a201ca --- /dev/null +++ b/app/src/main/res/drawable/ic_cast.xml @@ -0,0 +1,17 @@ + + + + + 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..168fd01 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/drawable/progress_line.xml b/app/src/main/res/drawable/progress_line.xml new file mode 100644 index 0000000..76fab2a --- /dev/null +++ b/app/src/main/res/drawable/progress_line.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/scrim_bottom.xml b/app/src/main/res/drawable/scrim_bottom.xml new file mode 100644 index 0000000..64189db --- /dev/null +++ b/app/src/main/res/drawable/scrim_bottom.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/tv_banner.xml b/app/src/main/res/drawable/tv_banner.xml new file mode 100644 index 0000000..30aab93 --- /dev/null +++ b/app/src/main/res/drawable/tv_banner.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/space_grotesk.ttf b/app/src/main/res/font/space_grotesk.ttf new file mode 100644 index 0000000..a1b2e6c Binary files /dev/null and b/app/src/main/res/font/space_grotesk.ttf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..d96f99c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..5bd3d56 --- /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-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..75d597e --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,14 @@ + + + Mit dem Handy koppeln + Richte die Kamera auf den Code — die Fernbedienung öffnet sich direkt im Browser. + Bereit zum Koppeln + Gekoppelt mit %1$s + Code %1$s + Keine Netzwerkverbindung + Wird abgespielt + Pausiert + Lädt… + Wiedergabefehler (%1$s) + Playlist konnte nicht geladen werden + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1f19750 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,14 @@ + + + #0A0B0D + #05060A + #F2F3F5 + #9AA0A8 + #6B717A + #5FD4C4 + #FBFCFD + #E5484D + #23262B + #14FFFFFF + #8C0A0B0D + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3312bb9 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + NodeCast + Pair with your phone + Point your camera at the code — the remote opens right in your browser. + Ready to pair + Paired with %1$s + Code %1$s + No network connection + LIVE + Playing + Paused + Buffering… + Playback error (%1$s) + Could not load playlist + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9e197a5 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c7ad754 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/docs/adr/0001-companion-to-nodecast-server.md b/docs/adr/0001-companion-to-nodecast-server.md new file mode 100644 index 0000000..624ba8a --- /dev/null +++ b/docs/adr/0001-companion-to-nodecast-server.md @@ -0,0 +1,14 @@ +--- +status: superseded by ADR-0003 +--- + +# NodeCast is a companion to the nodecast-tv server, not standalone + +The first cut of the native TV app parsed M3U playlists itself and needed no +backend. We decided to make it a thin companion instead: the TV app is the only +client of a nodecast-tv server, which owns sources, channels, EPG, favorites +and users. Rationale: those features already exist server-side and would +otherwise be rebuilt in the app; the household already runs the server. +Consequence: the app is not usable without a reachable nodecast-tv instance. +The app-side M3U parser is deliberately kept as a generic-source fallback so +M3U/XMLTV servers like ErsatzTV can be added later without rearchitecting. diff --git a/docs/adr/0002-device-token-fork.md b/docs/adr/0002-device-token-fork.md new file mode 100644 index 0000000..af02018 --- /dev/null +++ b/docs/adr/0002-device-token-fork.md @@ -0,0 +1,12 @@ +--- +status: superseded by ADR-0003 +--- + +# Long-lived device tokens via nodecast-tv fork + +nodecast-tv issues JWTs with a 24h expiry, which would force the TV either to +store the user's password in plaintext or to re-prompt daily. We decided to +extend our nodecast-tv fork with non-expiring per-device tokens (API keys +scoped to a user, revocable server-side). The TV logs in once with credentials, +exchanges them for a device token, and never stores the password. +Consequence: the app requires the forked server until the feature is upstreamed. diff --git a/docs/adr/0003-dispatcharr-backend-via-standards.md b/docs/adr/0003-dispatcharr-backend-via-standards.md new file mode 100644 index 0000000..35f1d12 --- /dev/null +++ b/docs/adr/0003-dispatcharr-backend-via-standards.md @@ -0,0 +1,16 @@ +--- +status: accepted +supersedes: ADR-0001, ADR-0002 +--- + +# Dispatcharr as backend, app speaks Xtream API + XMLTV + +ADR-0001 made the app a companion to nodecast-tv; grilling the ecosystem +(Dispatcharr, Threadfin, Tunarr, ErsatzTV) showed they all emit standard +outputs, while nodecast-tv is the only backend with a proprietary API. We +decided to drop nodecast-tv and use Dispatcharr as the backend: it owns +sources, aggregation, failover, EPG and stream profiles. The TV app talks the +Xtream API (channels, categories, EPG as JSON) plus XMLTV, with a generic +M3U/XMLTV fallback so Threadfin/Tunarr/ErsatzTV work too. The nodecast-tv +fork plans (device tokens, ADR-0002) are void; the app-side M3U parser stays +as the fallback path. diff --git a/docs/adr/0004-dispatcharr-fork-with-native-oidc.md b/docs/adr/0004-dispatcharr-fork-with-native-oidc.md new file mode 100644 index 0000000..30ea1bb --- /dev/null +++ b/docs/adr/0004-dispatcharr-fork-with-native-oidc.md @@ -0,0 +1,13 @@ +--- +status: accepted +--- + +# Fork Dispatcharr to add native OIDC (Authentik) for the web UI + +Dispatcharr has no SSO (open upstream request: issue #806). A reverse-proxy +forward-auth setup would work without code changes, but we decided to fork +Dispatcharr (be-nj) and build real OIDC login (Authentik as IdP, auto user +creation, group mapping) into its Django backend and React UI, offered +upstream as a PR. Rationale: proxy-auth needs bypass rules for every client +endpoint (Xtream/M3U/HDHomeRun) and gives no in-app user identity. The TV app +is unaffected either way — it authenticates with Xtream credentials. diff --git a/docs/adr/0005-multiuser-tv-ui-oidc-device-flow.md b/docs/adr/0005-multiuser-tv-ui-oidc-device-flow.md new file mode 100644 index 0000000..15c3633 --- /dev/null +++ b/docs/adr/0005-multiuser-tv-ui-oidc-device-flow.md @@ -0,0 +1,20 @@ +--- +status: accepted +--- + +# Multi-user app with full TV UI; login via OIDC Device Flow + +The app's audience is every user of the household's Dispatcharr backend, each +on their own Google TV — not a single phone-controlled screen. Two consequences: + +1. **Full TV UI** (Compose for TV): channel list, zapping and favorites are + operable with the D-pad alone. The QR-paired phone Remote stays as an + optional convenience, no longer the primary control. +2. **Login via OIDC Device Flow** against Authentik: the TV shows a QR/code, + the user confirms on their phone; the app then calls the Dispatcharr fork's + API with Bearer tokens. No Xtream credentials for the primary backend. + +The generic M3U/XMLTV source type (ADR-0003) stays as a login-less fallback +for Threadfin/Tunarr/ErsatzTV. The fork scope now bundles: OIDC UI login, +Device Flow + Bearer API, per-user favorites, per-request stream profile +parameter. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 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..d4081da --- /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.14.3-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..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/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\n' "$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="\\\"\\\"" + + +# 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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..5eed7ee --- /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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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..284b60b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "NodeCast" +include(":app")