Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 1.0-test246 (2026-10-22)
- **Anonymous usage check-in** (opt-in), so the project can count daily and monthly active users (`home/Usage.kt`, TelemetryDeck's ingest API v2, no SDK):
- Official builds ask once, a moment after the home screen opens: "Help improve MoonCast?" with **No thanks** selected. Settings, About has the switch afterwards.
- With yes: at most once a day, one signal with the app version, TV or handheld, Android version, device model, and whether PC, PS5 or Xbox was played since the last one. The install is a random ID made on the device, sent hashed. No names, addresses, games or accounts.
- Only builds made with an app ID (`-PmcTelemetryAppId` or `MOONCAST_TELEMETRY_APP_ID`, plus the TelemetryDeck organization's namespace, `-PmcTelemetryNamespace` or `MOONCAST_TELEMETRY_NAMESPACE`, sent to `/v2/namespace/<namespace>/` like TelemetryDeck's SDKs) have it; everything else, forks and this test build included, never asks and never sends. Debug builds send in TelemetryDeck's test mode.
- TV: `confirm()` can pick its no button and run something on no.
- README: install section for the beta's file names, **Obtainium** links for automatic updates (prereleases on, release titles `^MoonCast [0-9.]+( |$)`, one APK filter per app), and a **Privacy** section.

## 1.0-test245 (2026-10-22)
- **"On battery, stream at" is removed** (Settings, Handheld, and `home/BatteryCap.kt`, added for #65 in test237). It saved little: the screen uses most of the battery and doesn't change, and Native resolution already avoids streaming more pixels than the screen shows. With it goes test244's Auto-bitrate fix. A saved setting is simply ignored.

Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,25 @@ MoonCast grew from [Moonlight for Android](https://github.com/moonlight-stream/m
- **On-screen controller** for PC games, with PlayStation and Xbox layouts. The keys are see-through over the game and give a light tick under your finger. The PC sees a real PlayStation or Xbox pad, so games show the right button symbols.
- **Foldables.** The picture follows the screen when you fold or unfold, and Native resolution uses the whole screen.
- **Controllers.** Bluetooth and USB pads, a test screen for every button, and rumble on the device when a pad can't rumble.
- **Quick menu** while playing: keyboard, controllers, disconnect, quit, move to a TV.
- **Quick menu** while playing: streaming status, touch screen (off, mouse or touch), keyboard, controllers, Task Manager, disconnect, quit, move to a TV.

## Install

Download the APKs from [Releases](https://github.com/MoonCastDev/moonlight-android/releases):
Download the APKs from [Releases](https://github.com/MoonCastDev/moonlight-android/releases). The betas (*MoonCast 1.0 beta …*) have:

- `MoonCastHandheld-….apk` for phones and handhelds;
- `MoonCast-….apk` for phones, handhelds and tablets;
- `MoonCastTV-….apk` for Android TV and Google TV.

Both run on Android 5.0 and later. Builds marked *Pre-release* are test builds.
Both run on Android 5.0 and later. Releases named `1.0-test…` are development builds (`MoonCast (Debug)`), installed next to the beta.

### Automatic updates with Obtainium

[Obtainium](https://github.com/ImranR98/Obtainium) installs MoonCast from these releases and tells you when a new beta is out. Install Obtainium, then open one of these on the device:

- [Add MoonCast (phones and handhelds) to Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22me.mooncast.handheld%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FMoonCastDev%2Fmoonlight-android%22%2C%22author%22%3A%22MoonCastDev%22%2C%22name%22%3A%22MoonCast%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3A%20true%2C%20%5C%22filterReleaseTitlesByRegEx%5C%22%3A%20%5C%22%5EMoonCast%20%5B0-9.%5D%2B%28%20%7C%24%29%5C%22%2C%20%5C%22apkFilterRegEx%5C%22%3A%20%5C%22%5EMoonCast-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%20%5C%22appName%5C%22%3A%20%5C%22MoonCast%5C%22%7D%22%7D)
- [Add MoonCast TV to Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22me.mooncast.tv%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2FMoonCastDev%2Fmoonlight-android%22%2C%22author%22%3A%22MoonCastDev%22%2C%22name%22%3A%22MoonCast%20TV%22%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3A%20true%2C%20%5C%22filterReleaseTitlesByRegEx%5C%22%3A%20%5C%22%5EMoonCast%20%5B0-9.%5D%2B%28%20%7C%24%29%5C%22%2C%20%5C%22apkFilterRegEx%5C%22%3A%20%5C%22%5EMoonCastTV-.%2A%5C%5C%5C%5C.apk%24%5C%22%2C%20%5C%22appName%5C%22%3A%20%5C%22MoonCast%20TV%5C%22%7D%22%7D)

Or add it by hand: **Add App**, the URL `https://github.com/MoonCastDev/moonlight-android`, then in its settings turn on **Include prereleases**, set **Filter release titles by regular expression** to `^MoonCast [0-9.]+( |$)` and **Filter APKs by regular expression** to `^MoonCast-.*\.apk$` (or `^MoonCastTV-.*\.apk$` on a TV).

### On the PC

Expand Down Expand Up @@ -63,6 +72,12 @@ The APKs land in `app/build/outputs/apk/`. Two notes:

Test builds put the build number in the version: `BUILD=test123 ./gradlew …` gives `12.2-test123`.

## Privacy

MoonCast has no ads and no accounts of its own. Your PlayStation Network and Xbox sign-ins and your PC pairings stay on the device and go only to Sony, Microsoft and your own PC.

Official builds ask once whether you'll send an **anonymous usage check-in** (Settings, About). If you say yes, once a day it sends [TelemetryDeck](https://telemetrydeck.com) the app version, TV or handheld, the device model and Android version, and whether you played PC, PS5 or Xbox. The install is a random ID made on the device, sent hashed. It never sends names, addresses, games, accounts or anything you type. Say no, or turn it off later, and nothing is sent. Builds you make yourself never ask and never send. The code is in [`home/Usage.kt`](app/src/main/java/com/limelight/home/Usage.kt).

## Licence

MoonCast is free software under the [GNU General Public License v3](LICENSE.txt). The parts that come from chiaki-ng (PS5) and follow XStreaming (Xbox) are under the GNU Affero General Public License v3. [NOTICE](NOTICE) lists every open-source project MoonCast includes, with its licence. The app shows the same list under Settings, About, Open-source licences.
Expand Down
8 changes: 8 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ android {
// MoonCast TV dev build: arm64 only (the stick + JAL TV are arm64) to keep the native compile within memory,
// and limit parallel native jobs so Gradle + ndk-build don't OOM the 4 GB builder.
ndk { abiFilters "arm64-v8a", "armeabi-v7a" }

// Anonymous usage check-in (home/Usage.kt): only official builds get an app ID, from the release build's
// environment or -PmcTelemetryAppId; without one the app never asks and never sends
buildConfigField "String", "TELEMETRY_APP_ID",
"\"" + (project.findProperty("mcTelemetryAppId") ?: System.getenv("MOONCAST_TELEMETRY_APP_ID") ?: "") + "\""
// the TelemetryDeck organization's namespace (its dashboard shows it); signals go to /v2/namespace/<it>/
buildConfigField "String", "TELEMETRY_NAMESPACE",
"\"" + (project.findProperty("mcTelemetryNamespace") ?: System.getenv("MOONCAST_TELEMETRY_NAMESPACE") ?: "") + "\""
}

flavorDimensions.add("root")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ class HandheldHomeActivity : Activity() {
super.onResume()
resumed = true
resumedAt = android.os.SystemClock.elapsedRealtime()
// anonymous usage check-in: once a day if the user said yes; the question once, a moment after home is up
com.limelight.home.Usage.checkIn(this)
if (com.limelight.home.Usage.shouldAsk(this)) ui.postDelayed({
if (resumed && overlayKind == null && com.limelight.home.Usage.shouldAsk(this))
dialog(com.limelight.home.Usage.QUESTION, com.limelight.home.Usage.EXPLAIN, emptyList(),
listOf("No thanks" to { com.limelight.home.Usage.answer(this, false) },
"Yes, send" to { com.limelight.home.Usage.answer(this, true) }), primaryAt = 0)
}, 1500)
TvFinder.onChange = { render() }
if (System.currentTimeMillis() - TvFinder.lastSweepAt > 90_000) TvFinder.sweep(this) else TvFinder.check(this)
consoles = Ps5Store.all(this)
Expand Down Expand Up @@ -1389,6 +1397,9 @@ class HandheldHomeActivity : Activity() {
try { startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(com.limelight.home.Licences.SOURCE))) }
catch (e: Exception) { toast("No browser on this device: ${com.limelight.home.Licences.SOURCE}") }
}
if (com.limelight.home.Usage.available)
toggle(list, com.limelight.home.Usage.PREF, "Anonymous usage check-in",
"Once a day: app version, device model, and whether you played PC, PS5 or Xbox. Nothing personal", false)
action(list, "about:add", "Add a PC by its address", "For a PC this device doesn't find on its own", null) { addPcByAddress() }
action(list, "about:all", "All settings", "Every option, including the rare ones", null) {
startActivity(Intent(this, StreamSettings::class.java)) }
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/limelight/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ public void onServiceDisconnected(ComponentName componentName) {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
com.limelight.home.Usage.used(this, "pc"); // for the next anonymous check-in, if the user said yes

UiHelper.setLocale(this);

Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/limelight/Ps5StreamActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home

override fun onCreate(b: Bundle?) {
super.onCreate(b)
com.limelight.home.Usage.used(this, "ps5") // for the next anonymous check-in, if the user said yes
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val sv = SurfaceView(this)
// Status over the (black) video surface until the first frame: waking a console over the internet takes up to a minute
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/limelight/XboxStreamActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession

override fun onCreate(b: Bundle?) {
super.onCreate(b)
com.limelight.home.Usage.used(this, "xbox") // for the next anonymous check-in, if the user said yes
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// The decoder rules the Xbox decoder borrows (MediaCodecHelper) need this first, as in Moonlight's Game
com.limelight.binding.video.MediaCodecHelper.initialize(this,
Expand Down
112 changes: 112 additions & 0 deletions app/src/main/java/com/limelight/home/Usage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.limelight.home

import android.content.Context
import android.os.Build
import android.preference.PreferenceManager
import android.util.Log
import com.limelight.BuildConfig
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID

/**
* Anonymous usage check-in, opt-in, so the project can count daily and monthly active users (TelemetryDeck).
*
* At most once a day, when MoonCast's home opens, and only if the user said yes: the app version, TV or handheld,
* the Android version and device model, and whether PC, PS5 or Xbox was played since the last check-in. Never names,
* addresses, games, accounts or sign-ins. The install is a random ID made on this device, sent hashed.
*
* Only official builds have an app ID (`-PmcTelemetryAppId` or MOONCAST_TELEMETRY_APP_ID at build time, with the
* organization's namespace from `-PmcTelemetryNamespace` or MOONCAST_TELEMETRY_NAMESPACE): forks and
* self-built copies never ask and never send. Debug builds send as test mode, which TelemetryDeck keeps out of the
* real numbers. A failed send is simply tried again on a later day; it never affects the app.
*/
object Usage {
const val PREF = "mc_usage_checkin" // the user's answer (true = send); absent = not asked yet
private const val ID = "mc_usage_id"
private const val LAST = "mc_usage_last" // yyyy-MM-dd of the last check-in sent
private const val USED = "mc_usage_used_" // + pc / ps5 / xbox: played since the last check-in
// TelemetryDeck's ingest API v2; with the organization's namespace, its own path (as TelemetryDeck's SDKs do)
private val URL_INGEST get() = if (BuildConfig.TELEMETRY_NAMESPACE.isEmpty()) "https://nom.telemetrydeck.com/v2/"
else "https://nom.telemetrydeck.com/v2/namespace/${BuildConfig.TELEMETRY_NAMESPACE}/"

/** This build can send at all (official builds only). */
@JvmStatic val available get() = BuildConfig.TELEMETRY_APP_ID.isNotEmpty()

private fun prefs(c: Context) = PreferenceManager.getDefaultSharedPreferences(c)

/** Show the question: an official build that hasn't asked yet. */
@JvmStatic fun shouldAsk(c: Context) = available && !prefs(c).contains(PREF)

@JvmStatic fun answer(c: Context, yes: Boolean) {
prefs(c).edit().putBoolean(PREF, yes).apply()
if (yes) checkIn(c)
}

@JvmStatic fun enabled(c: Context) = available && prefs(c).getBoolean(PREF, false)

/** A stream of this kind started ("pc", "ps5" or "xbox"); goes with the next check-in. */
@JvmStatic fun used(c: Context, kind: String) {
if (enabled(c)) prefs(c).edit().putBoolean(USED + kind, true).apply()
}

/** Sends today's check-in if the user said yes and none was sent today. Off the main thread; never throws. */
@JvmStatic fun checkIn(c: Context) {
if (!enabled(c)) return
val p = prefs(c)
val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
if (p.getString(LAST, null) == today) return
val app = c.applicationContext
Thread({
try {
val id = p.getString(ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(ID, it).apply() }
val payload = JSONObject()
.put("TelemetryDeck.AppInfo.version", BuildConfig.VERSION_NAME)
.put("TelemetryDeck.Device.platform", "Android")
.put("TelemetryDeck.Device.operatingSystem", "Android")
.put("TelemetryDeck.Device.systemVersion", Build.VERSION.RELEASE ?: "")
.put("TelemetryDeck.Device.modelName", "${Build.MANUFACTURER} ${Build.MODEL}")
.put("MoonCast.app", if (BuildConfig.TV_APP) "tv" else "handheld")
for (k in listOf("pc", "ps5", "xbox")) payload.put("MoonCast.used.$k", p.getBoolean(USED + k, false).toString())
val signal = JSONObject()
.put("appID", BuildConfig.TELEMETRY_APP_ID)
.put("clientUser", sha256(id))
.put("sessionID", UUID.randomUUID().toString())
.put("type", "MoonCast.dailyCheckIn")
.put("isTestMode", BuildConfig.DEBUG)
.put("payload", payload)
val conn = URL(URL_INGEST).openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = 10_000; conn.readTimeout = 10_000
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.outputStream.use { it.write(JSONArray().put(signal).toString().toByteArray()) }
val code = conn.responseCode
conn.disconnect()
if (code in 200..299) {
val e = p.edit().putString(LAST, today)
for (k in listOf("pc", "ps5", "xbox")) e.remove(USED + k)
e.apply()
Log.i("MoonCast", "usage check-in sent")
} else Log.i("MoonCast", "usage check-in: HTTP $code")
} catch (e: Exception) {
Log.i("MoonCast", "usage check-in failed: $e")
}
}, "mc-usage").start()
}

private fun sha256(s: String) = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }

/** The question, the same words on the handheld and the TV. */
const val QUESTION = "Help improve MoonCast?"
const val EXPLAIN = "Send an anonymous check-in once a day: the app version, the device model and Android version, " +
"and whether you played PC, PS5 or Xbox. Never names, games, accounts or addresses. " +
"You can change this in Settings, About."
}
Loading
Loading