Skip to content
Merged
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
12 changes: 7 additions & 5 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ android {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}

// Where problem reports go; -Pcastbay.reportsUrl=http://<mac>:8787/api/reports tests a
// local `wrangler dev` of the website's Worker.
val reportsUrl = project.findProperty("castbay.reportsUrl") as String? ?: "https://castbay.weenas.com/api/reports"
// A resource rather than a BuildConfig constant, which Kotlin copies into its callers
// Where the update check, problem reports and statistics go: the website, then the relay
// for networks that can't reach Cloudflare (util/Servers.kt). -Pcastbay.serverUrl=
// http://<mac>:8787 tests a local `wrangler dev` of the website's Worker, without relay.
val serverUrl = project.findProperty("castbay.serverUrl") as String?
// Resources rather than BuildConfig constants, which Kotlin copies into their callers
// (an incremental build then kept an old address).
resValue("string", "reports_url", reportsUrl)
resValue("string", "server_url", serverUrl ?: "https://castbay.weenas.com")
resValue("string", "relay_url", if (serverUrl == null) "https://cast.weenas.com" else "")
// The in-app updater and the daily update check. Store builds (F-Droid) turn both off
// with -Pcastbay.selfUpdate=false: the store updates the app, and doesn't allow it to.
val selfUpdate = (project.findProperty("castbay.selfUpdate") as String?)?.toBoolean() ?: true
Expand Down
12 changes: 7 additions & 5 deletions app/src/main/java/com/weenas/castbay/service/UpdateChecker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.weenas.castbay.service

import android.content.Context
import com.weenas.castbay.util.Log
import com.weenas.castbay.util.Servers
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
Expand Down Expand Up @@ -62,12 +63,13 @@ data class AppUpdate(

/**
* Looks for a newer release at most once a day: first on the website (castbay.weenas.com
* publishes latest.json with each release; it is reachable where GitHub often isn't), then
* on GitHub. The one request sends nothing about the TV (the server sees an address, as for
* publishes latest.json with each release), through its relay where the website can't be
* reached, then on GitHub. The one request sends nothing about the TV (the server sees an address, as for
* any web page). The result is kept, so the home screen can show it at once and without
* asking again. Blocking; call off the main thread.
*/
class UpdateChecker(context: Context, private val appVersion: String) {
private val appContext = context.applicationContext
private val preferences = context.applicationContext.getSharedPreferences("updates", Context.MODE_PRIVATE)

/** The last release found, if newer than this app (no request made). */
Expand Down Expand Up @@ -106,7 +108,8 @@ class UpdateChecker(context: Context, private val appVersion: String) {
return latest
}

private fun fromWebsite(): AppUpdate? = AppUpdate.fromJson(get(WEBSITE_URL))
// The website, or its relay (util/Servers.kt).
private fun fromWebsite(): AppUpdate? = AppUpdate.fromJson(Servers.call(appContext, "/latest.json") { get(it.toString()) })

private fun fromGitHub(): AppUpdate? = newest(get(RELEASES_URL))

Expand All @@ -118,7 +121,7 @@ class UpdateChecker(context: Context, private val appVersion: String) {
// GitHub's API requires a User-Agent.
connection.setRequestProperty("User-Agent", "CastBay/$appVersion (https://github.com/weenas/castbay)")
connection.setRequestProperty("Accept", "application/json")
if (connection.responseCode != HttpURLConnection.HTTP_OK) throw java.io.IOException("HTTP ${connection.responseCode}")
if (connection.responseCode != HttpURLConnection.HTTP_OK) throw Servers.HttpError(connection.responseCode)
connection.inputStream.bufferedReader().use { it.readText() }
} finally {
connection.disconnect()
Expand All @@ -127,7 +130,6 @@ class UpdateChecker(context: Context, private val appVersion: String) {

companion object {
private const val TAG = "CastBayUpdate"
private const val WEBSITE_URL = "https://castbay.weenas.com/latest.json"
private const val RELEASES_URL = "https://api.github.com/repos/weenas/castbay/releases?per_page=10"
private const val CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000L
private const val TIMEOUT_MS = 10_000
Expand Down
9 changes: 6 additions & 3 deletions app/src/main/java/com/weenas/castbay/util/LogReport.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package com.weenas.castbay.util

import android.content.Context
import android.os.Process
import com.weenas.castbay.R
import com.weenas.castbay.protocol.AirPlayNative
import org.json.JSONObject
import java.net.HttpURLConnection
Expand Down Expand Up @@ -111,7 +110,11 @@ object LogReport {
}

private fun send(context: Context, text: String, appVersion: String, crash: Boolean): String {
val connection = URL(context.getString(R.string.reports_url)).openConnection() as HttpURLConnection
return Servers.call(context, "/api/reports") { url -> send(url, text, appVersion, crash) }
}

private fun send(url: URL, text: String, appVersion: String, crash: Boolean): String {
val connection = url.openConnection() as HttpURLConnection
return try {
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = READ_TIMEOUT_MS
Expand All @@ -136,7 +139,7 @@ object LogReport {
}

/** The website answered, but not with a report ID (sending again wouldn't help). */
open class Refused(val code: Int) : java.io.IOException("HTTP $code")
open class Refused(code: Int) : Servers.HttpError(code)

/** The website accepts a few reports a minute from one place. */
class TooManyReports : Refused(429)
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/weenas/castbay/util/Servers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.weenas.castbay.util

import android.content.Context
import com.weenas.castbay.R
import java.io.IOException
import java.net.URL

/**
* Where CastBay's own requests go (the update check, problem reports, usage statistics): the
* website, castbay.weenas.com, on Cloudflare; and cast.weenas.com, a relay that passes the same
* requests on to it, for networks where Cloudflare can't be reached (common in mainland China).
* A request tries them in turn, starting with the one that answered last.
*/
object Servers {
private const val TAG = "CastBayServers"

@Volatile private var lastAnswered: String? = null

/** The website answered, with [code]: trying the other address wouldn't help. */
open class HttpError(val code: Int) : IOException("HTTP $code")

/**
* Makes [request] to [path] ("/api/reports") on each address until one answers: its result,
* or the website's [HttpError], or the last network failure. Blocking.
*/
fun <T> call(context: Context, path: String, request: (URL) -> T): T {
val bases = bases(context).sortedByDescending { it == lastAnswered }
var failure: IOException? = null
for (base in bases) {
try {
return request(URL(base + path)).also { lastAnswered = base }
} catch (e: HttpError) {
lastAnswered = base
throw e
} catch (e: IOException) {
Log.w(TAG, "${base.removePrefix("https://")}$path failed: ${e.javaClass.simpleName}: ${e.message?.take(60)}")
failure = e
}
}
throw failure ?: IOException("no address")
}

private fun bases(context: Context): List<String> =
listOf(context.getString(R.string.server_url)) +
listOfNotNull(context.getString(R.string.relay_url).takeIf { it.isNotEmpty() })
}
15 changes: 7 additions & 8 deletions app/src/main/java/com/weenas/castbay/util/UsageStats.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import com.weenas.castbay.R
import com.weenas.castbay.service.ReceiverSettingsStore
import org.json.JSONObject
import java.net.HttpURLConnection
Expand Down Expand Up @@ -53,9 +52,9 @@ object UsageStats {
val id = prefs.getString(KEY_ID, null)
prefs.edit().clear().apply()
if (id != null) {
val url = statsUrl(context) + "/delete"
val app = context.applicationContext
kotlin.concurrent.thread(name = "CastBay-stats-delete") {
runCatching { post(url, JSONObject().put("id", id).toString()) }
runCatching { post(app, "/api/stats/delete", JSONObject().put("id", id).toString()) }
.onSuccess { Log.i(TAG, "Statistics deleted on the website") }
.onFailure { Log.w(TAG, "Deleting statistics failed: ${it.message}") }
}
Expand Down Expand Up @@ -118,7 +117,7 @@ object UsageStats {
continue
}
val body = summary(context, id, day, days.getJSONObject(day))
val sent = runCatching { post(statsUrl(context), body.toString()) }
val sent = runCatching { post(context, "/api/stats", body.toString()) }
.onFailure { Log.w(TAG, "Statistics for $day not sent: ${it.message}") }
if (sent.isFailure) break
forget(context, day)
Expand Down Expand Up @@ -189,10 +188,10 @@ object UsageStats {
}
}

private fun statsUrl(context: Context) = context.getString(R.string.reports_url).replace("/api/reports", "/api/stats")
private fun post(context: Context, path: String, body: String) = Servers.call(context, path) { url -> post(url, body) }

private fun post(url: String, body: String) {
val connection = URL(url).openConnection() as HttpURLConnection
private fun post(url: URL, body: String) {
val connection = url.openConnection() as HttpURLConnection
try {
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
Expand All @@ -203,7 +202,7 @@ object UsageStats {
connection.setFixedLengthStreamingMode(bytes.size)
connection.outputStream.use { it.write(bytes) }
val code = connection.responseCode
if (code !in 200..299) throw java.io.IOException("HTTP $code")
if (code !in 200..299) throw Servers.HttpError(code)
} finally {
connection.disconnect()
}
Expand Down
8 changes: 6 additions & 2 deletions web/src/pages/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ page: privacy
title: 'Privacy Policy – CastBay'
description: 'CastBay''s privacy policy: no account, no ads, no third-party analytics; nothing is sent unless you choose to. Lyrics lookups, the update check, problem reports and anonymous usage statistics are optional.'
heading: 'Privacy Policy'
intro: 'Effective: October 3, 2026'
intro: 'Effective: October 4, 2026'
---

CastBay is an open-source casting receiver app for Android TVs, car displays and tablets. It needs no account and contains no ads or third-party analytics or tracking code.
Expand All @@ -26,7 +26,7 @@ Nothing, unless you choose to. CastBay does not collect personal information, an

## Update check

Once a day, CastBay asks this website (castbay.weenas.com/latest.json), or GitHub (api.github.com) if the website can't be reached, for the number of the latest CastBay version, so it can tell you when a newer one is out. The request contains nothing about you or your TV; the server sees your network's address, as for any web page (the website is hosted by Cloudflare; GitHub's own policy applies to GitHub). You can turn this off in Settings, under General.
Once a day, CastBay asks this website (castbay.weenas.com/latest.json, or through [the relay](#the-relay-castweenascom) if the website can't be reached), else GitHub (api.github.com), for the number of the latest CastBay version, so it can tell you when a newer one is out. The request contains nothing about you or your TV; the server sees your network's address, as for any web page (the website is hosted by Cloudflare; GitHub's own policy applies to GitHub). You can turn this off in Settings, under General.

Only when you choose **Download and install** on the About screen does CastBay download the new version, from this website or GitHub, check that it is exactly the released file (its SHA-256), and open Android's installer, where you confirm the update. Nothing is downloaded or installed on its own.

Expand Down Expand Up @@ -73,6 +73,10 @@ The report is sent over HTTPS to this website (castbay.weenas.com, hosted by Clo

Never sent: device names (the TV's or a phone's), song or video titles, links, network or hardware addresses, the Wi-Fi name, your location, exact times of casts, passwords or PINs. The website keeps one row per installation and day in its database (Cloudflare D1) and does not store your network's address; rows are deleted after a year. Only the developer can read them, to see which devices and Android versions to support and where CastBay fails. **Turning the switch off** stops counting, forgets the ID and asks the website to delete everything sent under it.

## The relay (cast.weenas.com)

In some networks (often in mainland China) this website, on Cloudflare, can't be reached. CastBay then sends the same three requests (the update check, problem reports and usage statistics) to cast.weenas.com instead: a relay on the developer's server in the United States that passes them on, over HTTPS and unchanged, to this website, and passes the answer back. It stores nothing and keeps no access log; it answers nothing else. It tells this website your network's address only for the upload limits described above, which don't store it either. What is sent and kept is exactly as described above.

## Settings and data on the TV

Your settings (such as the device name and casting password) stay on the TV and are never uploaded, as does the list of devices that have cast to it (their names and AirPlay device IDs, whether each is allowed, and the pairing keys of devices paired with a PIN). You can remove the devices in Settings; uninstalling CastBay deletes all of it.
Expand Down
8 changes: 6 additions & 2 deletions web/src/pages/zh/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ page: privacy
title: '隐私政策 – 映湾 CastBay'
description: '映湾(CastBay)隐私政策:无需账号,没有广告和第三方统计,未经你同意不发送任何数据。歌词查询、检查更新、问题报告和匿名使用统计均为可选功能。'
heading: '隐私政策'
intro: '生效日期:2026 年 10 月 3 日'
intro: '生效日期:2026 年 10 月 4 日'
---

映湾(CastBay)是一款运行在安卓大屏(电视、车机、平板)上的开源投屏接收 App。我们重视你的隐私:映湾不需要账号,不含广告,也不包含任何第三方统计或追踪代码。
Expand All @@ -26,7 +26,7 @@ intro: '生效日期:2026 年 10 月 3 日'

## 检查更新

映湾每天向本网站(castbay.weenas.com/latest.json)查询一次最新版本号,网站无法访问时改为查询 GitHub(api.github.com),以便在有新版本时提示你。请求中不包含任何与你或你的电视有关的信息;和访问任何网页一样,服务器会看到你的网络地址(本网站由 Cloudflare 托管,GitHub 适用其自己的政策)。你可以在设置的"通用"页中关闭此功能。
映湾每天向本网站(castbay.weenas.com/latest.json,网站无法访问时经由[中转服务器](#中转服务器castweenascom))查询一次最新版本号,仍然不行时改为查询 GitHub(api.github.com),以便在有新版本时提示你。请求中不包含任何与你或你的电视有关的信息;和访问任何网页一样,服务器会看到你的网络地址(本网站由 Cloudflare 托管,GitHub 适用其自己的政策)。你可以在设置的"通用"页中关闭此功能。

只有在"关于"页点击**下载并安装**后,映湾才会从本网站或 GitHub 下载新版本,核对它与发布的文件完全一致(SHA-256),再打开安卓的安装界面,由你确认更新。映湾不会自行下载或安装任何东西。

Expand Down Expand Up @@ -73,6 +73,10 @@ intro: '生效日期:2026 年 10 月 3 日'

不会发送:设备名称(电视或手机的)、歌曲和视频名称、网址、网络地址和硬件地址、Wi-Fi 名称、你的位置、每次投屏的具体时间、密码和 PIN 码。本网站在数据库(Cloudflare D1)中按安装 ID 和日期每天保存一行,不保存你的网络地址,一年后自动删除。只有开发者能查看,用来了解需要支持哪些设备和 Android 版本、映湾在哪里出问题。**关闭这个开关**会停止统计、删除安装 ID,并请求本网站删除这个 ID 下发送过的所有数据。

## 中转服务器(cast.weenas.com)

在部分网络中(常见于中国大陆),托管在 Cloudflare 上的本网站无法访问。这时映湾会把同样的三类请求(检查更新、问题报告、使用统计)改发到 cast.weenas.com:这是开发者在美国的服务器上运行的中转服务,通过 HTTPS 把请求原样转给本网站,再把回应传回来。它不保存任何内容,不记录访问日志,也不提供其他任何服务。它会把你的网络地址告诉本网站,仅用于上文所说的上传次数限制,本网站同样不会保存。发送和保存的内容与上文描述完全一致。

## 设置和本机数据

你的设置(例如设备名称、投屏密码)只保存在电视本机,不会上传;投屏过的设备列表(设备名称和 AirPlay 设备 ID、是否允许,以及通过 PIN 码配对的设备的配对密钥)也一样。你可以在设置中移除这些设备,卸载映湾会一并删除所有数据。
Expand Down
17 changes: 15 additions & 2 deletions web/worker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
// report; there is no way to read reports back from here. The bucket deletes them after 90
// days (a lifecycle rule). The sender's address is used only by the rate limiter, not kept.
//
// Devices that can't reach Cloudflare send the same requests through cast.weenas.com, a relay
// on the developer's server (RELAY_IPS) that passes them on unchanged and names the device's
// address in X-CastBay-Client-IP; that header is believed only from the relay's address.
//
// POST /api/stats: a day's anonymous usage summary from an app whose owner turned statistics
// on (one row per installation and day in D1; only whitelisted fields and counters are kept,
// anything else is refused). POST /api/stats/delete {id}: deletes an installation's rows, sent
Expand Down Expand Up @@ -47,6 +51,15 @@ export default {
},
};

/** The sender's address, for the rate limits only: the device's, also through the relay. */
function clientAddress(request, env) {
const address = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const relays = (env.RELAY_IPS ?? '').split(',').map((ip) => ip.trim()).filter(Boolean);
const forwarded = request.headers.get('X-CastBay-Client-IP');
if (relays.includes(address) && forwarded && /^[0-9a-fA-F.:]{2,45}$/.test(forwarded)) return forwarded;
return address;
}

async function receiveReport(request, env) {
const version = request.headers.get('X-CastBay-Version') ?? '';
// 1.1.0, or a test build's 1.1.0-dev+6228385.
Expand All @@ -55,7 +68,7 @@ async function receiveReport(request, env) {
}
const kind = request.headers.get('X-CastBay-Report') === 'crash' ? 'crash' : 'manual';
if (env.UPLOAD_LIMIT) {
const { success } = await env.UPLOAD_LIMIT.limit({ key: request.headers.get('CF-Connecting-IP') ?? 'unknown' });
const { success } = await env.UPLOAD_LIMIT.limit({ key: clientAddress(request, env) });
if (!success) return json({ error: 'too many' }, 429);
}
const length = Number(request.headers.get('Content-Length') ?? 0);
Expand Down Expand Up @@ -98,7 +111,7 @@ const MAX_STATS_BYTES = 8 * 1024;

async function receiveStats(request, env) {
if (env.STATS_LIMIT) {
const { success } = await env.STATS_LIMIT.limit({ key: request.headers.get('CF-Connecting-IP') ?? 'unknown' });
const { success } = await env.STATS_LIMIT.limit({ key: clientAddress(request, env) });
if (!success) return json({ error: 'too many' }, 429);
}
const body = await readJson(request);
Expand Down
Loading
Loading