diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4de1c719..f5cb7fc94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` 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. diff --git a/README.md b/README.md index d9d1d6afe0..f77669bfde 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/app/build.gradle b/app/build.gradle index db467af054..9e38b3f4fb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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// + buildConfigField "String", "TELEMETRY_NAMESPACE", + "\"" + (project.findProperty("mcTelemetryNamespace") ?: System.getenv("MOONCAST_TELEMETRY_NAMESPACE") ?: "") + "\"" } flavorDimensions.add("root") diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index fbdfc95138..46fdf34388 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -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) @@ -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)) } diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 5ce5bc2e5b..f5a3b7aa3a 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -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); diff --git a/app/src/main/java/com/limelight/Ps5StreamActivity.kt b/app/src/main/java/com/limelight/Ps5StreamActivity.kt index a3de616ed7..8f73bbd1bd 100644 --- a/app/src/main/java/com/limelight/Ps5StreamActivity.kt +++ b/app/src/main/java/com/limelight/Ps5StreamActivity.kt @@ -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 diff --git a/app/src/main/java/com/limelight/XboxStreamActivity.kt b/app/src/main/java/com/limelight/XboxStreamActivity.kt index 9454950bf7..0f2fe6d0b4 100644 --- a/app/src/main/java/com/limelight/XboxStreamActivity.kt +++ b/app/src/main/java/com/limelight/XboxStreamActivity.kt @@ -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, diff --git a/app/src/main/java/com/limelight/home/Usage.kt b/app/src/main/java/com/limelight/home/Usage.kt new file mode 100644 index 0000000000..aa54c4423f --- /dev/null +++ b/app/src/main/java/com/limelight/home/Usage.kt @@ -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." +} diff --git a/app/src/tv/java/com/limelight/home/HomeActivity.kt b/app/src/tv/java/com/limelight/home/HomeActivity.kt index 4d7c1a533b..89fcf9d8a2 100644 --- a/app/src/tv/java/com/limelight/home/HomeActivity.kt +++ b/app/src/tv/java/com/limelight/home/HomeActivity.kt @@ -123,6 +123,13 @@ class HomeActivity : Activity(), PhoneLink.Listener { super.onResume() resumed = true lastInput = System.currentTimeMillis() + // 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)) + confirm(com.limelight.home.Usage.QUESTION, com.limelight.home.Usage.EXPLAIN, "Yes, send", no = "No thanks", + noPicked = true, onNo = { com.limelight.home.Usage.answer(this, false) }) { com.limelight.home.Usage.answer(this, true) } + }, 1500) PhoneLinkService.listener = this if (overlayKind == "incoming") hideOverlay() consoles = Ps5Store.all(this) @@ -1150,6 +1157,9 @@ class HomeActivity : Activity(), PhoneLink.Listener { } info(list, "Source code", "github.com/MoonCastDev · ${Licences.SOURCE_LICENCE}") info(list, "Phones send games here", "Port ${PhoneLink.PORT}") + 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, "set:add", "Add a PC by its address", "For a PC this TV doesn't find on its own", null) { addPcByAddress() } action(list, "set:all", "All settings", "Every option, including the rare ones", null) { startActivity(Intent(this, StreamSettings::class.java)) } @@ -1875,20 +1885,21 @@ class HomeActivity : Activity(), PhoneLink.Listener { setPadding(tv.px(44), tv.px(44), tv.px(44), tv.px(40)) } - private fun confirm(title: String, body: String, yes: String, danger: Boolean = false, onYes: () -> Unit) { + private fun confirm(title: String, body: String, yes: String, danger: Boolean = false, no: String = "Cancel", + noPicked: Boolean = danger, onNo: () -> Unit = {}, onYes: () -> Unit) { val card = dialogCard() card.addView(tv.text(title, 40, tv.p.moon, tv.display(700), 2)) card.addView(tv.text(body, 22, tv.p.dust, tv.ui(500), 4), tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(28) }) // danger (quit, unpair, remove): Cancel is picked, so a habitual OK on the remote doesn't do it card.addView(tv.row().apply { - addView(tv.pill(yes, if (danger) null else "A", !danger) { + addView(tv.pill(yes, if (noPicked) null else "A", !noPicked) { // one sound per action: what the answer starts (a launch) speaks for it, else the dialog's close val n = FeelSounds.serial hideOverlay(cue = false); onYes() if (n == FeelSounds.serial) FeelSounds.play(this@HomeActivity, FeelSounds.Cue.CLOSE) }.apply { - tag = if (danger) "yes" else "primary"; if (danger) (getChildAt(childCount - 1) as TextView).setTextColor(Tv.DANGER) }) - addView(tv.pill("Cancel", "B", false) { hideOverlay() }.apply { if (danger) tag = "primary" }) + tag = if (noPicked) "yes" else "primary"; if (danger) (getChildAt(childCount - 1) as TextView).setTextColor(Tv.DANGER) }) + addView(tv.pill(no, "B", false) { hideOverlay(); onNo() }.apply { if (noPicked) tag = "primary" }) }) showOverlay("confirm", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) }) }