updates: beta channel, and a switch to turn off automatic checks

Beta updates counts GitHub pre-releases as updates. With Check automatically off, Gearslip contacts GitHub only when you tap Check for updates.
This commit is contained in:
Seb3thehacker committed 2026-10-11 14:48:46 -07:00
1 parent fb6bc018d5
commit fff2db63b0
3 files changed
+90 -32

No files matched your search

@@ -137,7 +137,9 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
SettingsDivider()
UpdateRow()
SettingsDivider()
UpdateNotifyRow()
UpdateAutoRow()
SettingsDivider()
UpdateBetaRow()
SettingsDivider()
SettingsRow(
"Replay the setup guide",
@@ -14,6 +14,7 @@ import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -130,19 +131,38 @@ internal fun UpdateRow() {
)
}
/** Whether a drive's end may notify about a new version. */
/** Whether Gearslip checks for updates on its own. */
@Composable
internal fun UpdateNotifyRow() {
internal fun UpdateAutoRow() {
val context = LocalContext.current
var on by remember { mutableStateOf(UpdateChecker.notifyEnabled(context)) }
var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
SettingsRow(
"Update notifications",
"Check automatically",
Modifier.toggleable(on, role = Role.Switch) {
on = it
UpdateChecker.setNotifyEnabled(context, it)
UpdateChecker.setAutoCheckEnabled(context, it)
},
subtitle = "On the car screen when you plug in, and on the phone after a drive",
subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates"
else if (on) "Once a day, with a note on the car screen and the phone when one is out"
else "Gearslip checks only when you tap Check for updates",
icon = Icons.Filled.Notifications,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
}
/** Whether pre-releases count as updates. */
@Composable
internal fun UpdateBetaRow() {
val context = LocalContext.current
var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) }
SettingsRow(
"Beta updates",
Modifier.toggleable(on, role = Role.Switch) {
on = it
UpdateChecker.setBetaEnabled(context, it)
},
subtitle = "Get test versions before they're released. They may have more bugs.",
icon = Icons.Filled.Build,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
}
@@ -32,8 +32,10 @@ import java.security.MessageDigest
/**
* Looks for a newer Gearslip on GitHub and installs it through Android's own installer.
*
* Only GitHub's "latest" release counts, which leaves out pre-releases and drafts. The app checks
* at most once a day: when it opens, and when a drive ends. After a drive a new version also gets
* Only GitHub's "latest" release counts, which leaves out pre-releases and drafts, unless the
* driver opted into betas: then pre-releases count too. The app checks at most once a day: when
* it opens, and when a drive ends. With automatic checks off it never asks GitHub on its own,
* only when the driver taps Check for updates. After a drive a new version also gets
* a notification, once per version, since the driver is parked and the app is likely closed. The
* car screen shows the same news once per version, at plug-in and only while parked.
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
@@ -62,7 +64,9 @@ object UpdateChecker {
private const val PREFS = "updates"
private const val KEY_CHECKED_AT = "checked_at"
private const val KEY_FOUND = "found"
private const val KEY_NOTIFY = "notify"
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
private const val KEY_AUTO = "notify"
private const val KEY_BETA = "beta"
private const val KEY_NOTIFIED = "notified_version"
private const val KEY_CAR_NOTIFIED = "car_notified_version"
/** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */
@@ -71,22 +75,43 @@ object UpdateChecker {
private const val NOTIFICATION_ID = 4100
private const val DAY_MS = 24 * 60 * 60 * 1000L
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
@Volatile private var installWhenAllowed = false
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** Whether a drive's end may post a notification about a new version. On by default: it sends nothing. */
fun notifyEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_NOTIFY, true)
/**
* Whether Gearslip checks on its own, and tells the driver what it finds on the phone and the
* car screen. On by default: the check sends nothing about the phone.
*/
fun autoCheckEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO, true)
fun setNotifyEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_NOTIFY, on).apply()
fun setAutoCheckEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_AUTO, on).apply()
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
/** Whether GitHub pre-releases count as updates. */
fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false)
/**
* Switching channels forgets what the old one found: a beta found earlier shouldn't still be
* offered once betas are off. Checks again right away if automatic checks are on.
*/
fun setBetaEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
val current = flow.value
if (current is State.Checking || current is State.Downloading) return
updatesDir(context).deleteRecursively()
flow.value = State.Idle
if (autoCheckEnabled(context) && !BuildConfig.DEBUG) check(context)
}
private fun dueForCheck(context: Context) =
!BuildConfig.DEBUG && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
!BuildConfig.DEBUG && autoCheckEnabled(context) && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
/** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */
fun onAppOpened(context: Context) {
@@ -107,7 +132,7 @@ object UpdateChecker {
* notification for a new version they haven't been told about yet.
*/
fun onDriveEnded(context: Context) {
if (!notifyEnabled(context)) return
if (!autoCheckEnabled(context)) return
val app = context.applicationContext
Thread({
val current = flow.value
@@ -126,7 +151,7 @@ object UpdateChecker {
* once per version. Skipped while moving; the next plug-in tries again.
*/
fun onCarConnected(context: Context) {
if (!notifyEnabled(context)) return
if (!autoCheckEnabled(context)) return
val app = context.applicationContext
Thread({
val current = flow.value
@@ -197,7 +222,7 @@ object UpdateChecker {
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
}
private fun checkNow(app: Context): State = runCatching { fetchLatest() }.fold(
private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold(
onSuccess = { release ->
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
@@ -263,8 +288,9 @@ object UpdateChecker {
)
}
private fun fetchLatest(): Release? {
val conn = (URL(LATEST).openConnection() as HttpURLConnection).apply {
/** The newest release with an APK: the "latest" one, or with [beta] the newest of any kind but drafts. */
private fun fetchNewest(beta: Boolean): Release? {
val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Accept", "application/vnd.github+json")
@@ -273,23 +299,33 @@ object UpdateChecker {
try {
if (conn.responseCode == 404) return null // no stable release yet
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
val assets = json.optJSONArray("assets") ?: return null
val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
.firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
?: return null
return Release(
version = json.getString("tag_name").removePrefix("v"),
apkUrl = apk.getString("browser_download_url"),
size = apk.optLong("size", -1),
sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
pageUrl = json.optString("html_url"),
)
val body = conn.inputStream.bufferedReader().use { it.readText() }
if (!beta) return release(JSONObject(body))
val list = org.json.JSONArray(body)
return (0 until list.length()).map { list.getJSONObject(it) }
.filterNot { it.optBoolean("draft") }
.mapNotNull(::release)
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
} finally {
conn.disconnect()
}
}
/** A release as GitHub lists it, or null when it has no APK attached. */
private fun release(json: JSONObject): Release? {
val assets = json.optJSONArray("assets") ?: return null
val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
.firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
?: return null
return Release(
version = json.getString("tag_name").removePrefix("v"),
apkUrl = apk.getString("browser_download_url"),
size = apk.optLong("size", -1),
sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
pageUrl = json.optString("html_url"),
)
}
private fun fetchApk(release: Release, into: File) {
val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000