mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
opt-in usage notes and a stats dashboard
Release builds ask in setup and Settings whether to send a short note when the app is opened and after each drive: the version, plus the Android version, phone model and cars people tick. Nothing runs in the background. Dev builds always send. The Cloudflare Worker in stats-worker/ stores the notes and serves a private dashboard with GitHub downloads alongside.
This commit is contained in:
1 parent
b6bd6497b2
commit
5a3bbe82a0
13 files changed
+811
-5
No files matched your search
+1
-5
@@ -15,8 +15,6 @@ keystore.properties
|
||||
.cxx/
|
||||
|
||||
# Testing
|
||||
/docs/cert-sweep.sh
|
||||
extraction/
|
||||
/cloned-repos
|
||||
docs-internal/
|
||||
|
||||
@@ -31,6 +29,4 @@ CLAUDE.md
|
||||
|
||||
# Other
|
||||
archive/
|
||||
|
||||
# Side project notes, kept out of the repo
|
||||
backseat-jury.md
|
||||
.wrangler/
|
||||
@@ -74,6 +74,27 @@ mode.
|
||||
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||
visible while audio plays.
|
||||
|
||||
## Usage notes
|
||||
|
||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||
turn them on, in setup or in Settings.
|
||||
|
||||
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
|
||||
Nothing runs in the background.
|
||||
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version
|
||||
and whether the phone runs GrapheneOS, the phone model, whether a car connected that
|
||||
day, and each car's head unit, model, year, screen size, and connection result.
|
||||
Settings shows the exact note.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
anything you type or play.
|
||||
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||
only a salted hash of it.
|
||||
- **Where they go.** To a Cloudflare Worker, whose code is in
|
||||
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note,
|
||||
as it would for any website; the Worker doesn't store it.
|
||||
|
||||
Turn the notes off, and they stop at once.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
|
||||
@@ -35,6 +35,9 @@ android {
|
||||
versionCode = 7
|
||||
versionName = "0.1.10-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||
whether the package is installed at all. -->
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". -->
|
||||
<package android:name="app.grapheneos.setupwizard" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -150,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,10 @@ object SessionReport {
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
@@ -59,6 +63,8 @@ object SessionReport {
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnitInfo = null
|
||||
carScreen = ""; carDpi = 0
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
@@ -68,6 +74,14 @@ object SessionReport {
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
|
||||
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
|
||||
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
|
||||
|
||||
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
|
||||
@Volatile var carScreen = ""; private set
|
||||
@Volatile var carDpi = 0; private set
|
||||
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
@@ -121,6 +135,18 @@ object SessionReport {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
appContext?.let { context ->
|
||||
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
}
|
||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package app.seb3thehacker.gearslip.stats
|
||||
|
||||
import android.app.job.JobScheduler
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Opt-in usage counts: a short note to the stats Worker (stats-worker/ in the repo). Off until the
|
||||
* driver turns it on in setup or Settings. Only the app version is always sent; everything else
|
||||
* is one of the driver's [Choices]. A random install ID counts a phone once; nothing ties it to a
|
||||
* person, and the Worker keeps no IP addresses.
|
||||
*
|
||||
* Notes go out only while Gearslip is in use: the first time the app is opened each day, and
|
||||
* after every car session. Nothing runs in the background, so a phone that stops using Gearslip
|
||||
* goes quiet. A note that fails waits for the next one. With no [BuildConfig.STATS_URL] set at
|
||||
* build time, nothing is sent.
|
||||
*/
|
||||
object UsageStats {
|
||||
|
||||
/** What the driver agreed to share beyond the app version. */
|
||||
data class Choices(
|
||||
val android: Boolean = true,
|
||||
val phone: Boolean = true,
|
||||
val cars: Boolean = true,
|
||||
)
|
||||
|
||||
private const val PREFS = "usage_stats"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_ASKED = "asked"
|
||||
private const val KEY_ID = "install_id"
|
||||
/** The phone details the server last got, and when, so they go again only on a change. */
|
||||
private const val KEY_SENT_DETAILS = "sent_details"
|
||||
private const val KEY_DETAILS_DAY = "details_day"
|
||||
private const val DETAILS_REFRESH_DAYS = 30L
|
||||
private const val KEY_CARS = "cars"
|
||||
private const val KEY_SENT_DAY = "sent_day"
|
||||
/** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
|
||||
private const val OLD_JOB_ID = 4_207
|
||||
private const val MAX_CARS = 10
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Dev builds are the developer's own, so they always send everything and never ask: no switch
|
||||
* in Settings, no page in setup. Only release builds offer the choice.
|
||||
*/
|
||||
val choosable: Boolean get() = !BuildConfig.DEBUG
|
||||
|
||||
fun enabled(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ENABLED, false)
|
||||
|
||||
/** Whether the driver has answered the question in setup, either way. */
|
||||
fun asked(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ASKED, false)
|
||||
|
||||
fun choices(context: Context): Choices {
|
||||
if (!choosable) return Choices()
|
||||
val p = prefs(context)
|
||||
return Choices(
|
||||
android = p.getBoolean("share_android", true),
|
||||
phone = p.getBoolean("share_phone", true),
|
||||
cars = p.getBoolean("share_cars", true),
|
||||
)
|
||||
}
|
||||
|
||||
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
|
||||
prefs(context).edit()
|
||||
.putBoolean(KEY_ENABLED, enabled)
|
||||
.putBoolean(KEY_ASKED, true)
|
||||
.putBoolean("share_android", choices.android)
|
||||
.putBoolean("share_phone", choices.phone)
|
||||
.putBoolean("share_cars", choices.cars)
|
||||
.apply()
|
||||
if (enabled) sendSoon(context)
|
||||
else prefs(context).edit().remove(KEY_CARS).remove(KEY_SENT_DAY).apply()
|
||||
}
|
||||
|
||||
/** Gearslip was opened: send today's note unless one already went out. */
|
||||
fun onAppOpened(context: Context) {
|
||||
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID)
|
||||
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
|
||||
}
|
||||
|
||||
private val sending = AtomicBoolean(false)
|
||||
@Volatile private var sendAgain = false
|
||||
/** Guards the saved car list, which a session's end and a finished send both rewrite. */
|
||||
private val carsLock = Any()
|
||||
|
||||
/**
|
||||
* Sends off the calling thread. A call while a note is in flight sends once more after it, so
|
||||
* a car recorded mid-send still goes out.
|
||||
*/
|
||||
private fun sendSoon(context: Context) {
|
||||
if (!enabled(context)) return
|
||||
if (!sending.compareAndSet(false, true)) { sendAgain = true; return }
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
try {
|
||||
do { sendAgain = false } while (send(app) && sendAgain)
|
||||
} finally {
|
||||
sending.set(false)
|
||||
}
|
||||
}, "gearslip-stats").start()
|
||||
}
|
||||
|
||||
private fun installId(context: Context): String {
|
||||
val p = prefs(context)
|
||||
return p.getString(KEY_ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(KEY_ID, it).apply() }
|
||||
}
|
||||
|
||||
/**
|
||||
* A car session ended. [info] is null when the car hung up before it said who it is, for
|
||||
* instance after rejecting the certificate; the protocol version and [outcome] still say a lot.
|
||||
*/
|
||||
fun recordSession(
|
||||
context: Context,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String = "",
|
||||
dpi: Int = 0,
|
||||
) {
|
||||
if (!enabled(context)) return
|
||||
val p = prefs(context)
|
||||
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
|
||||
sendSoon(context)
|
||||
}
|
||||
|
||||
private fun addCar(
|
||||
p: android.content.SharedPreferences,
|
||||
info: ServiceDiscovery.HeadUnitInfo?,
|
||||
protocol: String,
|
||||
outcome: String,
|
||||
screen: String,
|
||||
dpi: Int,
|
||||
) {
|
||||
val cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
|
||||
val car = JSONObject()
|
||||
.put("name", info?.headUnitName.orEmpty())
|
||||
.put("car", info?.carModel.orEmpty())
|
||||
.put("year", info?.carYear.orEmpty())
|
||||
.put("make", info?.headUnitMake.orEmpty())
|
||||
.put("model", info?.headUnitModel.orEmpty())
|
||||
.put("protocol", protocol)
|
||||
.put("result", outcome)
|
||||
if (screen.isNotEmpty()) car.put("screen", screen).put("dpi", dpi)
|
||||
// The same car and outcome twice in a day is one fact, not two.
|
||||
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
|
||||
if (!seen && cars.length() < MAX_CARS) cars.put(car)
|
||||
p.edit().putString(KEY_CARS, cars.toString()).apply()
|
||||
}
|
||||
|
||||
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
|
||||
private fun isGrapheneOs(context: Context) =
|
||||
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
|
||||
|
||||
private fun details(context: Context, c: Choices) = JSONObject().apply {
|
||||
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
|
||||
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
/**
|
||||
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
|
||||
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
|
||||
*/
|
||||
fun preview(context: Context, everything: Boolean = false): JSONObject {
|
||||
val c = choices(context)
|
||||
val p = prefs(context)
|
||||
val note = JSONObject()
|
||||
.put("id", installId(context))
|
||||
.put("version", BuildConfig.VERSION_NAME)
|
||||
.put("code", BuildConfig.VERSION_CODE)
|
||||
.put("build", if (BuildConfig.DEBUG) "dev" else "release")
|
||||
// Phone details change rarely, so they ride along only when they differ from what the
|
||||
// server last got, or once a month in case it lost them. Unticked ones are left out, and
|
||||
// the server keeps the last value it had.
|
||||
val details = details(context, c)
|
||||
val stale = p.getString(KEY_DETAILS_DAY, null)?.let { LocalDate.parse(it).plusDays(DETAILS_REFRESH_DAYS) <= LocalDate.now() } ?: true
|
||||
if (details.length() > 0 && (everything || stale || details.toString() != p.getString(KEY_SENT_DETAILS, null))) {
|
||||
details.keys().forEach { note.put(it, details.get(it)) }
|
||||
}
|
||||
if (c.cars) note.put("cars", runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray()))
|
||||
return note
|
||||
}
|
||||
|
||||
/** Sends the note now; false when it didn't get through. */
|
||||
private fun send(context: Context): Boolean {
|
||||
if (!enabled(context)) return true
|
||||
val endpoint = BuildConfig.STATS_URL
|
||||
if (endpoint.isBlank()) return true
|
||||
val p = prefs(context)
|
||||
val note = synchronized(carsLock) { preview(context) }
|
||||
val sentCars = note.optJSONArray("cars")?.toString()
|
||||
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
|
||||
val body = note.toString().toByteArray()
|
||||
return runCatching {
|
||||
val conn = (URL("$endpoint/ping").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
conn.outputStream.use { it.write(body) }
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
if (code in 200..299) {
|
||||
// Sent: the cars start over, unless a session ended meanwhile and added one, which
|
||||
// stays for the next note.
|
||||
synchronized(carsLock) {
|
||||
val edit = p.edit().putString(KEY_SENT_DAY, LocalDate.now().toString())
|
||||
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
|
||||
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
|
||||
edit.apply()
|
||||
}
|
||||
GearslipLog.i("stats: note sent")
|
||||
true
|
||||
} else {
|
||||
GearslipLog.w("stats: the server answered $code")
|
||||
false
|
||||
}
|
||||
}.getOrElse {
|
||||
GearslipLog.w("stats: could not send: ${it.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.BuildConfig
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
|
||||
internal const val USAGE_STATS_TITLE = "Help show which cars work"
|
||||
|
||||
private const val INTRO_START =
|
||||
"Gearslip can't tell which cars it works in unless drivers say so. Turn this on, and Gearslip " +
|
||||
"sends a short note when you open it and after each drive, never in the background. The " +
|
||||
"note carries the Gearslip version and whatever you tick below. "
|
||||
private const val INTRO_PRIVACY =
|
||||
"It holds no name, account, or location, only a random number so your phone counts once. " +
|
||||
"Cloudflare carries the note, and the server keeps no IP address."
|
||||
private const val INTRO_END =
|
||||
" Nothing helps the project more: these notes tell the next driver whether their car " +
|
||||
"will work before they install."
|
||||
|
||||
/** The pitch for the usage notes, with the privacy promise in bold so it isn't missed. */
|
||||
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
|
||||
append(INTRO_START)
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
|
||||
append(INTRO_END)
|
||||
}
|
||||
|
||||
/** The usage notes card for the setup guide: the same rows Settings shows, minus the note. */
|
||||
@Composable
|
||||
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
|
||||
SettingsCard(modifier) { UsageStatsRows(showNote = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The on switch, what to share, and (with [showNote]) the note itself, saved as they change.
|
||||
* Rows only: the caller puts them in a card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ColumnScope.UsageStatsRows(showNote: Boolean) {
|
||||
val context = LocalContext.current
|
||||
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
|
||||
var choices by remember { mutableStateOf(UsageStats.choices(context)) }
|
||||
var noteOpen by remember { mutableStateOf(false) }
|
||||
fun save() = UsageStats.set(context, on, choices)
|
||||
|
||||
SettingsRow(
|
||||
"Send usage notes",
|
||||
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
|
||||
subtitle = if (BuildConfig.STATS_URL.isBlank()) "This build has no stats server, so it sends nothing" else null,
|
||||
icon = Icons.Filled.Send,
|
||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||
)
|
||||
AnimatedVisibility(on) {
|
||||
Column {
|
||||
SettingsDivider()
|
||||
Text(
|
||||
"Also share",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
|
||||
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
|
||||
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
|
||||
choices = choices.copy(cars = it)
|
||||
save()
|
||||
}
|
||||
if (showNote) {
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"What gets sent",
|
||||
Modifier.clickable { noteOpen = !noteOpen },
|
||||
subtitle = "The Android version and phone model go only when they change, and once a month",
|
||||
icon = Icons.Filled.Info,
|
||||
trailing = {
|
||||
Icon(
|
||||
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(noteOpen) {
|
||||
// Rebuilt from the saved choices each time one changes, so it always matches.
|
||||
val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
note,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
|
||||
@Composable
|
||||
private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange)
|
||||
.padding(start = 56.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(end = 12.dp))
|
||||
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,5 @@ kotlin.code.style=official
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.dependency.useConstraints=false
|
||||
# Where opted-in phones send their daily usage note (stats-worker/). Blank sends nothing.
|
||||
gearslipStatsUrl=https://gearslip-stats.gearslip.workers.dev
|
||||
@@ -0,0 +1,3 @@
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
node_modules/
|
||||
@@ -0,0 +1,52 @@
|
||||
-- One row per phone per day: the first note that day counts, later ones update it.
|
||||
CREATE TABLE IF NOT EXISTS pings (
|
||||
day TEXT NOT NULL, -- YYYY-MM-DD, UTC
|
||||
uid TEXT NOT NULL, -- salted hash of the phone's random install ID
|
||||
version TEXT NOT NULL,
|
||||
code INTEGER,
|
||||
build TEXT,
|
||||
android TEXT, -- these four are from older builds; details now live in phones
|
||||
os TEXT,
|
||||
phone TEXT,
|
||||
car_today INTEGER,
|
||||
PRIMARY KEY (day, uid)
|
||||
);
|
||||
|
||||
-- Each car a phone saw that day, and how the session ended.
|
||||
CREATE TABLE IF NOT EXISTS cars (
|
||||
day TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
name TEXT, car TEXT, year TEXT, make TEXT, model TEXT,
|
||||
protocol TEXT,
|
||||
result TEXT,
|
||||
screen TEXT, -- usable pixels, e.g. 1280x720
|
||||
dpi INTEGER,
|
||||
PRIMARY KEY (day, uid, name, car, year, protocol, result)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid);
|
||||
|
||||
-- GitHub's running download count for each release's APK, saved once a day.
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
day TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
PRIMARY KEY (day, tag)
|
||||
);
|
||||
|
||||
-- Which releases are pre-releases, so the dashboard can pick out the latest full release.
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
tag TEXT PRIMARY KEY,
|
||||
prerelease INTEGER NOT NULL,
|
||||
published TEXT
|
||||
);
|
||||
|
||||
-- Each phone's latest details. Phones send them only when they change (and once a month), so
|
||||
-- a field a note leaves out keeps its last value.
|
||||
CREATE TABLE IF NOT EXISTS phones (
|
||||
uid TEXT PRIMARY KEY,
|
||||
android TEXT,
|
||||
os TEXT,
|
||||
phone TEXT,
|
||||
updated TEXT
|
||||
);
|
||||
@@ -0,0 +1,293 @@
|
||||
// Gearslip usage notes: opt-in phones POST one short note a day, and a private dashboard counts
|
||||
// them. No IP address is stored, and the phone's random ID is kept only as a salted hash.
|
||||
|
||||
const MAX_BODY = 8 * 1024
|
||||
const MAX_CARS = 10
|
||||
const short = (v, n = 80) => (typeof v === 'string' ? v.slice(0, n) : null)
|
||||
|
||||
// The tables as schema.sql has them, plus the columns added since, so a database made by an
|
||||
// older version catches up on its own. Each runs once per Worker instance; an ALTER whose
|
||||
// column already exists fails, and that's fine.
|
||||
const SCHEMA = [
|
||||
`CREATE TABLE IF NOT EXISTS pings (day TEXT NOT NULL, uid TEXT NOT NULL, version TEXT NOT NULL,
|
||||
code INTEGER, build TEXT, android TEXT, os TEXT, phone TEXT, car_today INTEGER, PRIMARY KEY (day, uid))`,
|
||||
`CREATE TABLE IF NOT EXISTS cars (day TEXT NOT NULL, uid TEXT NOT NULL, name TEXT, car TEXT, year TEXT,
|
||||
make TEXT, model TEXT, protocol TEXT, result TEXT, screen TEXT, dpi INTEGER,
|
||||
PRIMARY KEY (day, uid, name, car, year, protocol, result))`,
|
||||
`CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid)`,
|
||||
`CREATE TABLE IF NOT EXISTS downloads (day TEXT NOT NULL, tag TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (day, tag))`,
|
||||
`CREATE TABLE IF NOT EXISTS releases (tag TEXT PRIMARY KEY, prerelease INTEGER NOT NULL, published TEXT)`,
|
||||
// Each phone's latest details. Phones send them only when they change, so a field a note
|
||||
// leaves out keeps its last value.
|
||||
`CREATE TABLE IF NOT EXISTS phones (uid TEXT PRIMARY KEY, android TEXT, os TEXT, phone TEXT, updated TEXT)`,
|
||||
]
|
||||
const ADDED_COLUMNS = [
|
||||
`ALTER TABLE pings ADD COLUMN os TEXT`,
|
||||
`ALTER TABLE cars ADD COLUMN screen TEXT`,
|
||||
`ALTER TABLE cars ADD COLUMN dpi INTEGER`,
|
||||
// Older builds sent details in every note: carry the latest of those over once.
|
||||
`INSERT OR IGNORE INTO phones (uid, android, os, phone, updated)
|
||||
SELECT uid, android, os, phone, day FROM pings p
|
||||
WHERE day = (SELECT MAX(day) FROM pings q WHERE q.uid = p.uid)
|
||||
AND (android IS NOT NULL OR phone IS NOT NULL)`,
|
||||
]
|
||||
let schemaReady = null
|
||||
function ensureSchema(env) {
|
||||
schemaReady ??= (async () => {
|
||||
for (const sql of SCHEMA) await env.DB.prepare(sql).run()
|
||||
for (const sql of ADDED_COLUMNS) await env.DB.prepare(sql).run().catch(() => {})
|
||||
})().catch((e) => { schemaReady = null; throw e })
|
||||
return schemaReady
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
await ensureSchema(env)
|
||||
const url = new URL(request.url)
|
||||
if (request.method === 'POST' && url.pathname === '/ping') return ping(request, env)
|
||||
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/api/summary')) {
|
||||
if (!env.DASHBOARD_KEY || url.searchParams.get('key') !== env.DASHBOARD_KEY) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
// The daily trigger normally saves today's counts; this covers a missed or late one.
|
||||
await snapshotDownloads(env, false).catch((e) => console.log('github:', e.message))
|
||||
const summary = await summarize(env)
|
||||
if (url.pathname === '/api/summary') return Response.json(summary)
|
||||
return new Response(dashboard(summary), { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
||||
}
|
||||
return new Response('Not found', { status: 404 })
|
||||
},
|
||||
|
||||
async scheduled(event, env) {
|
||||
await ensureSchema(env)
|
||||
await snapshotDownloads(env, true)
|
||||
},
|
||||
}
|
||||
|
||||
// Saves each release's APK download count for today. GitHub only keeps a running total, so
|
||||
// downloads per day come from the difference between days.
|
||||
async function snapshotDownloads(env, force) {
|
||||
if (!env.GITHUB_REPO) return
|
||||
const day = today()
|
||||
// Today's counts and the release list both have to be there; either missing means fetch again.
|
||||
if (!force && (await env.DB.prepare(`SELECT 1 WHERE EXISTS (SELECT 1 FROM downloads WHERE day = ?)
|
||||
AND EXISTS (SELECT 1 FROM releases)`).bind(day).first())) return
|
||||
const headers = { 'user-agent': 'gearslip-stats', accept: 'application/vnd.github+json' }
|
||||
if (env.GITHUB_TOKEN) headers.authorization = `Bearer ${env.GITHUB_TOKEN}`
|
||||
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/releases?per_page=100`, { headers })
|
||||
if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
|
||||
const statements = (await res.json()).filter((release) => !release.draft).flatMap((release) => {
|
||||
const count = release.assets.filter((a) => a.name.endsWith('.apk')).reduce((n, a) => n + a.download_count, 0)
|
||||
return [
|
||||
env.DB.prepare('INSERT OR REPLACE INTO downloads (day, tag, count) VALUES (?, ?, ?)').bind(day, release.tag_name, count),
|
||||
env.DB.prepare('INSERT OR REPLACE INTO releases (tag, prerelease, published) VALUES (?, ?, ?)')
|
||||
.bind(release.tag_name, release.prerelease ? 1 : 0, release.published_at),
|
||||
]
|
||||
})
|
||||
if (statements.length) await env.DB.batch(statements)
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
// Tags are "v0.1.10-alpha" or "0.0.05-alpha"; the app reports "0.1.10-alpha".
|
||||
const versionOf = (tag) => tag.replace(/^v/, '')
|
||||
|
||||
async function ping(request, env) {
|
||||
const text = await request.text()
|
||||
if (text.length > MAX_BODY) return new Response('Too large', { status: 413 })
|
||||
let note
|
||||
try {
|
||||
note = JSON.parse(text)
|
||||
} catch {
|
||||
return new Response('Bad JSON', { status: 400 })
|
||||
}
|
||||
const id = short(note.id, 64)
|
||||
const version = short(note.version, 32)
|
||||
if (!id || !version) return new Response('Missing id or version', { status: 400 })
|
||||
|
||||
const uid = await hash(`${env.SALT}:${id}`)
|
||||
const day = today()
|
||||
const android = short(note.android, 16), os = short(note.os, 16), phone = short(note.phone)
|
||||
const statements = [
|
||||
env.DB.prepare(
|
||||
`INSERT INTO pings (day, uid, version, code, build) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (day, uid) DO UPDATE SET version = excluded.version, code = excluded.code, build = excluded.build`,
|
||||
).bind(day, uid, version, Number.isInteger(note.code) ? note.code : null, short(note.build, 16)),
|
||||
]
|
||||
if (android || os || phone) {
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`INSERT INTO phones (uid, android, os, phone, updated) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (uid) DO UPDATE SET android = COALESCE(excluded.android, android),
|
||||
os = COALESCE(excluded.os, os), phone = COALESCE(excluded.phone, phone), updated = excluded.updated`,
|
||||
).bind(uid, android, os, phone, day),
|
||||
)
|
||||
}
|
||||
for (const car of Array.isArray(note.cars) ? note.cars.slice(0, MAX_CARS) : []) {
|
||||
statements.push(
|
||||
env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO cars (day, uid, name, car, year, make, model, protocol, result, screen, dpi)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).bind(day, uid, short(car.name) ?? '', short(car.car) ?? '', short(car.year, 8) ?? '',
|
||||
short(car.make) ?? '', short(car.model) ?? '', short(car.protocol, 8) ?? '', short(car.result) ?? '',
|
||||
short(car.screen, 16), Number.isInteger(car.dpi) ? car.dpi : null),
|
||||
)
|
||||
}
|
||||
await env.DB.batch(statements)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
async function hash(text) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
||||
return [...new Uint8Array(digest)].slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function summarize(env) {
|
||||
const all = async (sql) => (await env.DB.prepare(sql).all()).results
|
||||
const one = async (sql) => (await env.DB.prepare(sql).first()) ?? {}
|
||||
const latest = await all(`SELECT d.tag, d.count, r.prerelease, r.published FROM downloads d
|
||||
LEFT JOIN releases r ON r.tag = d.tag
|
||||
WHERE d.day = (SELECT MAX(day) FROM downloads)`)
|
||||
const seen = Object.fromEntries((await all(`SELECT version, COUNT(DISTINCT uid) AS n FROM pings
|
||||
WHERE build = 'release' GROUP BY version`)).map((r) => [r.version, r.n]))
|
||||
// The newest full release, the one most people are sent to; pre-releases don't count.
|
||||
const newest = await one(`SELECT tag FROM releases WHERE prerelease = 0 ORDER BY published DESC LIMIT 1`)
|
||||
const latestRelease = newest.tag ? { tag: newest.tag, downloads: latest.find((r) => r.tag === newest.tag)?.count ?? 0 } : null
|
||||
const releases = latest.map((r) => ({
|
||||
tag: r.tag,
|
||||
kind: r.prerelease ? 'Pre-release' : r.prerelease === 0 ? 'Release' : '',
|
||||
published: (r.published ?? '').slice(0, 10),
|
||||
downloads: r.count,
|
||||
phones: seen[versionOf(r.tag)] ?? 0,
|
||||
}))
|
||||
.map((r) => ({ ...r, share: r.phones && r.downloads ? `${Math.round((100 * r.phones) / r.downloads)}%` : '' }))
|
||||
.sort((a, b) => b.published.localeCompare(a.published))
|
||||
// Only releases that have sent anything can say what share of people send; older ones predate the notes.
|
||||
const counted = releases.filter((r) => r.phones > 0)
|
||||
const countedDownloads = counted.reduce((n, r) => n + r.downloads, 0)
|
||||
const countedPhones = counted.reduce((n, r) => n + r.phones, 0)
|
||||
const totals = await all(`SELECT day, SUM(count) AS total FROM downloads GROUP BY day ORDER BY day`)
|
||||
const newPhones = Object.fromEntries((await all(`SELECT first_day AS day, COUNT(*) AS n FROM
|
||||
(SELECT uid, MIN(day) AS first_day FROM pings WHERE build = 'release' GROUP BY uid)
|
||||
GROUP BY first_day`)).map((r) => [r.day, r.n]))
|
||||
const perDay = totals.slice(1).map((r, i) => ({ day: r.day, downloads: r.total - totals[i].total, new_phones: newPhones[r.day] ?? 0 }))
|
||||
return {
|
||||
latestRelease,
|
||||
releases,
|
||||
totalDownloads: releases.reduce((n, r) => n + r.downloads, 0),
|
||||
share: countedDownloads ? Math.round((100 * countedPhones) / countedDownloads) : null,
|
||||
perDay: perDay.slice(-60).reverse(),
|
||||
daily: await all(`SELECT p.day, COUNT(*) AS phones,
|
||||
SUM(EXISTS (SELECT 1 FROM cars c WHERE c.uid = p.uid AND c.day = p.day
|
||||
AND c.result LIKE 'connected%')) AS in_a_car,
|
||||
SUM(p.day = f.first_day) AS new_phones
|
||||
FROM pings p JOIN (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid) f
|
||||
ON f.uid = p.uid
|
||||
WHERE p.day >= date('now', '-60 days') GROUP BY p.day ORDER BY p.day`),
|
||||
// Android runs no code at uninstall, so a phone that stopped sending is the nearest sign.
|
||||
new30: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid)
|
||||
WHERE first_day >= date('now', '-30 days')`)).n ?? 0,
|
||||
quiet: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MAX(day) AS last_day FROM pings GROUP BY uid)
|
||||
WHERE last_day < date('now', '-14 days')`)).n ?? 0,
|
||||
everyone: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings`)).n ?? 0,
|
||||
active7: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-7 days')`)).n ?? 0,
|
||||
active30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-30 days')`)).n ?? 0,
|
||||
driving30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM cars
|
||||
WHERE result LIKE 'connected%' AND day >= date('now', '-30 days')`)).n ?? 0,
|
||||
versions: await all(`SELECT version, COUNT(DISTINCT uid) AS phones FROM pings
|
||||
WHERE day >= date('now', '-7 days') GROUP BY version ORDER BY phones DESC`),
|
||||
// Details come from each phone's latest, counted for phones that sent anything in 30 days.
|
||||
android: await all(`SELECT android, COUNT(*) AS phones FROM phones
|
||||
WHERE android IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY android ORDER BY phones DESC`),
|
||||
screens: await all(`SELECT screen, COUNT(DISTINCT uid) AS phones FROM cars
|
||||
WHERE screen IS NOT NULL GROUP BY screen ORDER BY phones DESC`),
|
||||
os: await all(`SELECT os, COUNT(*) AS phones FROM phones
|
||||
WHERE os IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY os ORDER BY phones DESC`),
|
||||
phones: await all(`SELECT phone, COUNT(*) AS phones FROM phones
|
||||
WHERE phone IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
|
||||
GROUP BY phone ORDER BY phones DESC LIMIT 30`),
|
||||
cars: await all(`SELECT name, car, year, make, model, protocol,
|
||||
GROUP_CONCAT(DISTINCT screen || CASE WHEN dpi THEN ' @ ' || dpi || ' dpi' ELSE '' END) AS screen,
|
||||
SUM(result = 'connected') AS connected,
|
||||
SUM(result != 'connected') AS failed,
|
||||
GROUP_CONCAT(DISTINCT CASE WHEN result != 'connected' THEN result END) AS failures,
|
||||
COUNT(DISTINCT uid) AS phones, MAX(day) AS last_seen
|
||||
FROM cars GROUP BY name, car, year, make, model, protocol
|
||||
ORDER BY phones DESC, connected DESC LIMIT 200`),
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
|
||||
|
||||
function table(rows, columns) {
|
||||
if (!rows.length) return '<p class="muted">Nothing yet.</p>'
|
||||
const head = columns.map(([, label]) => `<th>${esc(label)}</th>`).join('')
|
||||
const body = rows.map((r) => `<tr>${columns.map(([key]) => `<td>${esc(r[key])}</td>`).join('')}</tr>`).join('')
|
||||
return `<div class="scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||
}
|
||||
|
||||
function chart(daily) {
|
||||
if (!daily.length) return '<p class="muted">No notes yet.</p>'
|
||||
const w = 600, h = 160, max = Math.max(...daily.map((d) => d.phones), 1), bw = w / daily.length
|
||||
const bars = daily.map((d, i) => {
|
||||
const ph = (d.phones / max) * (h - 20), ch = ((d.in_a_car ?? 0) / max) * (h - 20)
|
||||
return `<rect x="${i * bw + 1}" y="${h - ph}" width="${Math.max(bw - 2, 1)}" height="${ph}" class="all"><title>${esc(d.day)}: ${d.phones} phones</title></rect>` +
|
||||
`<rect x="${i * bw + 1}" y="${h - ch}" width="${Math.max(bw - 2, 1)}" height="${ch}" class="car"><title>${esc(d.day)}: ${d.in_a_car ?? 0} in a car</title></rect>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Phones per day">${bars}</svg>
|
||||
<p class="muted"><span class="key all"></span> phones that day <span class="key car"></span> of those, in a car</p>`
|
||||
}
|
||||
|
||||
function dashboard(s) {
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Gearslip usage</title>
|
||||
<style>
|
||||
:root { --bg:#fafafa; --fg:#1a1a1a; --muted:#666; --line:#ddd; --all:#9db4d6; --car:#2e7d32; }
|
||||
@media (prefers-color-scheme: dark) { :root { --bg:#121212; --fg:#eee; --muted:#999; --line:#333; --all:#3d5a80; --car:#66bb6a; } }
|
||||
body { background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; margin:0 auto; padding:16px; max-width:960px; }
|
||||
h1 { font-size:1.5rem; margin:0 0 4px; } h2 { font-size:1.1rem; margin:28px 0 8px; }
|
||||
.muted { color:var(--muted); font-size:.9rem; }
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; }
|
||||
.stat.big { margin-bottom:12px; padding:20px; } .stat.big b { font-size:3.2rem; line-height:1.1; }
|
||||
.stat { border:1px solid var(--line); border-radius:12px; padding:12px; } .stat b { display:block; font-size:1.8rem; }
|
||||
svg { width:100%; height:auto; } rect.all { fill:var(--all); } rect.car { fill:var(--car); }
|
||||
.key { display:inline-block; width:10px; height:10px; border-radius:2px; margin:0 4px 0 12px; }
|
||||
.key.all { background:var(--all); } .key.car { background:var(--car); }
|
||||
.scroll { overflow-x:auto; } table { border-collapse:collapse; width:100%; font-size:.9rem; }
|
||||
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
|
||||
</style></head><body>
|
||||
<h1>Gearslip usage</h1><p class="muted">From phones that opted in to daily notes.</p>
|
||||
${s.latestRelease ? `<div class="stat big"><b>${s.latestRelease.downloads}</b>downloads of ${esc(s.latestRelease.tag)}, the latest release</div>` : ''}
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${s.active7}</b>phones, last 7 days</div>
|
||||
<div class="stat"><b>${s.active30}</b>phones, last 30 days</div>
|
||||
<div class="stat"><b>${s.driving30}</b>used in a car, last 30 days</div>
|
||||
<div class="stat"><b>${s.new30}</b>new phones, last 30 days</div>
|
||||
<div class="stat"><b>${s.quiet}</b>gone quiet (nothing in 14 days)</div>
|
||||
<div class="stat"><b>${s.everyone}</b>phones ever</div>
|
||||
</div>
|
||||
<p class="muted">Android tells an app nothing when it's uninstalled, so "gone quiet" is the nearest
|
||||
sign: phones that sent notes before and stopped. Turning the notes off looks the same.</p>
|
||||
<h2>Downloads and phones</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>${s.totalDownloads}</b>APK downloads on GitHub, all releases</div>
|
||||
<div class="stat"><b>${s.share == null ? '–' : s.share + '%'}</b>of downloads send notes</div>
|
||||
</div>
|
||||
<p class="muted">The share counts only releases that have sent at least one note, and only release builds.
|
||||
A download isn't always a new person: updating means downloading again, and some people download
|
||||
twice. So read it as a rough guide, and multiply the phones above by about ${s.share ? Math.round(100 / s.share) : '–'} to estimate everyone.</p>
|
||||
${table(s.releases, [['tag', 'Release'], ['kind', 'Type'], ['published', 'Published'], ['downloads', 'Downloads'], ['phones', 'Phones sending'], ['share', 'Share']])}
|
||||
<h2>Downloads per day</h2>${s.perDay.length ? table(s.perDay, [['day', 'Day'], ['downloads', 'Downloads'], ['new_phones', 'New phones']])
|
||||
: '<p class="muted">Counts are saved once a day, so this fills in from tomorrow.</p>'}
|
||||
<h2>Phones per day</h2>${chart(s.daily)}
|
||||
<h2>New phones per day</h2>${table(s.daily.filter((d) => d.new_phones > 0).slice().reverse(), [['day', 'Day'], ['new_phones', 'New phones']])}
|
||||
<h2>Cars</h2>${table(s.cars, [['name', 'Head unit'], ['car', 'Car'], ['year', 'Year'], ['make', 'Maker'], ['model', 'Unit model'],
|
||||
['protocol', 'Protocol'], ['screen', 'Screen'], ['connected', 'Connected'], ['failed', 'Failed'], ['failures', 'Why it failed'], ['phones', 'Phones'], ['last_seen', 'Last seen']])}
|
||||
<h2>Car screens</h2>${table(s.screens, [['screen', 'Usable size'], ['phones', 'Phones']])}
|
||||
<h2>Versions, last 7 days</h2>${table(s.versions, [['version', 'Version'], ['phones', 'Phones']])}
|
||||
<h2>Phones</h2>${table(s.phones, [['phone', 'Phone'], ['phones', 'Count']])}
|
||||
<h2>GrapheneOS, last 30 days</h2>${table(s.os, [['os', 'System'], ['phones', 'Phones']])}
|
||||
<h2>Android</h2>${table(s.android, [['android', 'Android'], ['phones', 'Count']])}
|
||||
</body></html>`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Gearslip's opt-in usage notes. Deploy with `npx wrangler deploy` from this folder.
|
||||
# Setup, once:
|
||||
# npx wrangler d1 create gearslip-stats (paste the id it prints below)
|
||||
# npx wrangler d1 execute gearslip-stats --remote --file schema.sql
|
||||
# npx wrangler secret put SALT (any long random string; keep it)
|
||||
# npx wrangler secret put DASHBOARD_KEY (the key for the dashboard link)
|
||||
name = "gearslip-stats"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2026-10-01"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "gearslip-stats"
|
||||
database_id = "201521a7-0b4a-4fff-ab11-d575b821528a"
|
||||
|
||||
# The public repo whose release downloads the dashboard compares against. If GitHub starts
|
||||
# refusing (60 requests an hour without a token), add one: npx wrangler secret put GITHUB_TOKEN
|
||||
[vars]
|
||||
GITHUB_REPO = "Seb3thehacker/gearslip"
|
||||
|
||||
# Save the download counts once a day, so the dashboard can show downloads per day.
|
||||
[triggers]
|
||||
crons = ["17 6 * * *"]
|
||||
Reference in new issue
Block a user