settings: tidy the order, fold updates into one row with Stable, Beta and Alpha, and drop the donation asks and the after-drive notification

This commit is contained in:
Seb3thehacker committed 2026-10-13 15:05:45 -07:00
1 parent f1fe94c487
commit 9100dac290
8 files changed
+247 -336

No files matched your search

@@ -1,16 +1,6 @@
package app.seb3thehacker.gearslip.stats
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.seb3thehacker.gearslip.GearslipActivity
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ServiceDiscovery
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,8 +10,7 @@ import org.json.JSONObject
/**
* The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share
* usage notes. It comes after the first drive that worked, when a driver is happiest to help, as
* a notification the moment the car leaves and as a card on Home. Answering either clears both,
* and it never comes back.
* a card on Home. Answering it clears it, and it never comes back.
*
* Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
* first note. Until the driver answers, the car waits here on the phone and is sent nowhere.
@@ -31,10 +20,6 @@ object WorkedPrompt {
private const val PREFS = "worked_prompt"
private const val KEY_DONE = "done"
private const val KEY_CAR = "car"
private const val CHANNEL = "worked"
private const val NOTIFICATION_ID = 4_311
private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE"
private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW"
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -66,19 +51,15 @@ object WorkedPrompt {
p.edit().putString(KEY_CAR, car.toString()).apply()
loaded = true
flow.value = carName(car)
notify(context, carName(car))
}
/**
* True once a driver turns sharing on from the ask, until Home has thrown its confetti. It
* waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens.
*/
/** True once a driver turns sharing on from the ask, until Home has thrown its confetti. */
private val celebrateFlow = MutableStateFlow(false)
val celebrate: StateFlow<Boolean> = celebrateFlow
fun celebrated() { celebrateFlow.value = false }
/** The driver answered, here or on Home. Either way it's never asked again. */
/** The driver answered on Home. Either way it's never asked again. */
fun answer(context: Context, share: Boolean) {
if (share) {
// Turning sharing on lands in [onSharingOn], which clears the ask.
@@ -112,7 +93,6 @@ object WorkedPrompt {
prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply()
loaded = true
flow.value = null
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun saved(context: Context): JSONObject? =
@@ -130,50 +110,5 @@ object WorkedPrompt {
}
}
private fun notify(context: Context, car: String) {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT),
)
val open = PendingIntent.getActivity(
context, NOTIFICATION_ID,
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val text = "Share that so the next driver knows it works? No name, account, or location."
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setContentTitle(TITLE_START + car)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(open)
.setAutoCancel(true)
.addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW))
.addAction(0, "Share", broadcast(context, ACTION_SHARE))
.build()
runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
.onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") }
.onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") }
}
private fun broadcast(context: Context, action: String): PendingIntent =
PendingIntent.getBroadcast(
context, action.hashCode(),
Intent(context, WorkedPromptReceiver::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE,
)
internal fun onAction(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SHARE -> answer(context, share = true)
ACTION_NOT_NOW -> answer(context, share = false)
}
}
const val TITLE_START = "Gearslip worked with "
}
/** The notification's Share and Not now buttons land here. */
class WorkedPromptReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent)
}
@@ -0,0 +1,63 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
/** Where Gearslip lives outside the app: the chat room and the code. */
internal object GearslipLinks {
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/Seb3thehacker/gearslip"
fun open(context: Context, url: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
/** The About section's links: the chat room, the source code, and credits. */
@Composable
internal fun AboutLinkRows() {
val context = LocalContext.current
val arrow = @Composable {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SettingsRow(
"Join the discussion on Matrix",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
subtitle = "Questions, ideas, or a car to report",
icon = Icons.AutoMirrored.Filled.Send,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Source code",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
subtitle = "Read or change Gearslip's code under the AGPL",
icon = Icons.Filled.Build,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Voice credits",
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
icon = Icons.Filled.Favorite,
)
}
@@ -112,10 +112,6 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
WirelessCard()
}
if (!live) {
Spacer(Modifier.height(16.dp))
SupportCard()
}
}
// Sits at the bottom, where a thumb reaches it.
@@ -50,6 +50,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
@@ -77,11 +79,13 @@ fun SettingsScreen(
atUsageNotes: Boolean = false,
) {
val scroll = rememberScrollState()
// Opened from Home's usage notes row: usage notes is the last section, so start at the end.
// Opened from Home's usage notes row: start at that section.
var usageNotesY by remember { mutableIntStateOf(-1) }
if (atUsageNotes) {
LaunchedEffect(Unit) {
val y = snapshotFlow { usageNotesY }.first { it >= 0 }
snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
scroll.scrollTo(scroll.maxValue)
scroll.scrollTo(y)
}
}
Scaffold(
@@ -106,13 +110,18 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Spacer(Modifier.height(0.dp))
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
ConnectionSection()
CarAudioSection()
VoiceModelSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
SettingsSection(
"Usage notes",
Modifier.onGloballyPositioned { usageNotesY = it.positionInParent().y.toInt() },
header = USAGE_STATS_INTRO,
) { UsageStatsRows() }
SettingsSection("Updates") { UpdateRow() }
CertificateSection()
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
Spacer(Modifier.height(16.dp))
}
}
@@ -125,10 +134,10 @@ private fun Arrow() = Icon(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
/** What's new, updates and the setup guide, one tap from the top. */
/** What's new, the replays, and where Gearslip lives outside the app. */
@Composable
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
SettingsSection {
SettingsSection("About") {
SettingsRow(
"What's new",
Modifier.clickable(onClick = onOpenWhatsNew),
@@ -137,11 +146,13 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
trailing = { Arrow() },
)
SettingsDivider()
UpdateRow()
SettingsDivider()
UpdateAutoRow()
SettingsDivider()
UpdateBetaRow()
SettingsRow(
"Replay the ${Intro.release} intro",
Modifier.clickable(onClick = onReplayIntro),
subtitle = "What's in this release, in a few taps",
icon = Icons.Filled.Star,
trailing = { Arrow() },
)
SettingsDivider()
SettingsRow(
"Replay the setup guide",
@@ -151,14 +162,6 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
trailing = { Arrow() },
)
SettingsDivider()
SettingsRow(
"Replay the ${Intro.release} intro",
Modifier.clickable(onClick = onReplayIntro),
subtitle = "What's in this release, in a few taps",
icon = Icons.Filled.Star,
trailing = { Arrow() },
)
SettingsDivider()
AboutLinkRows()
}
}
@@ -182,26 +185,26 @@ private fun ConnectionSection() {
}
}
/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */
/** How music reaches the car: by Bluetooth as usual, or through Gearslip over USB. Same choice as setup. */
@Composable
private fun CarAudioSection() {
val usb by CarSettings.pipeAudio.collectAsState()
SettingsSection(
"Car audio",
footer = "Run the command in Help once, and USB stops asking.",
footer = if (usb) "Run the command in Help once, and USB stops asking." else "Calls go over Bluetooth either way.",
) {
SettingsRow(
"USB",
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
trailing = { RadioButton(selected = usb, onClick = null) },
"Bluetooth (Recommended)",
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
subtitle = "No prompt each drive, and your map app's voice reaches the car",
trailing = { RadioButton(selected = !usb, onClick = null) },
)
SettingsDivider()
SettingsRow(
"Bluetooth",
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
trailing = { RadioButton(selected = !usb, onClick = null) },
"USB",
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
subtitle = "Clearer sound with no delay. Android asks to share audio once each drive",
trailing = { RadioButton(selected = usb, onClick = null) },
)
}
}
@@ -1,161 +0,0 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
/** Where Gearslip lives outside the app: donations, the chat room, and the code. */
internal object GearslipLinks {
const val SPONSORS = "https://github.com/sponsors/Seb3thehacker"
const val LIBERAPAY = "https://liberapay.com/Seb3thehacker"
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/Seb3thehacker/gearslip"
fun open(context: Context, url: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
}
/** "Hide for now" on the home card puts it away for this long; the About page keeps the links. */
private const val HIDE_MS = 30L * 24 * 60 * 60 * 1000
private const val PREFS = "support"
private const val KEY_HIDDEN_UNTIL = "hidden_until"
private fun hiddenUntil(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(KEY_HIDDEN_UNTIL, 0L)
/**
* The donation ask on Home: always in view, never in the way. It sits below the status and update
* cards, and "Hide for now" puts it away for a month.
*/
@Composable
internal fun SupportCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
var hidden by remember { mutableStateOf(System.currentTimeMillis() < hiddenUntil(context)) }
if (hidden) return
val scheme = MaterialTheme.colorScheme
Surface(
modifier,
color = scheme.tertiaryContainer,
contentColor = scheme.onTertiaryContainer,
shape = MaterialTheme.shapes.extraLarge,
) {
Column(Modifier.fillMaxWidth().padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Favorite, contentDescription = null)
Text(
"Support Gearslip",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 12.dp),
)
}
Text(
"Gearslip is free, with no ads and no tracking. If it gets you on the road, a " +
"donation keeps it going.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_HIDDEN_UNTIL, System.currentTimeMillis() + HIDE_MS).apply()
hidden = true
},
contentPadding = PaddingValues(horizontal = 0.dp),
) { Text("Hide for now") }
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) }) { Text("Liberapay") }
Button(onClick = { GearslipLinks.open(context, GearslipLinks.SPONSORS) }) { Text("GitHub") }
}
}
}
}
/** The About section's links: donating, the chat room, and the source code. */
@Composable
internal fun AboutLinkRows() {
val context = LocalContext.current
val arrow = @Composable {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SettingsRow(
"Donate on GitHub Sponsors",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SPONSORS) },
subtitle = "Give once or monthly, through GitHub",
icon = Icons.Filled.Favorite,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Donate on Liberapay",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) },
subtitle = "Give weekly or monthly, through a nonprofit",
icon = Icons.Filled.Favorite,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Join the discussion on Matrix",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) },
subtitle = "Questions, ideas, or a car to report",
icon = Icons.AutoMirrored.Filled.Send,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Source code",
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) },
subtitle = "Read or change Gearslip's code under the AGPL",
icon = Icons.Filled.Build,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
"Voice credits",
subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " +
"(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)",
icon = Icons.Filled.Favorite,
)
}
@@ -10,14 +10,17 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -27,6 +30,7 @@ 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
@@ -101,68 +105,130 @@ fun UpdateCard() {
Spacer(Modifier.height(16.dp))
}
/** The Settings row: checks on tap, and carries on to download and install once something is found. */
/** What the update check found, in a line. */
private fun statusLine(state: State): String = when (state) {
State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap Check now" else "Gearslip checks once a day"
State.Checking -> "Checking…"
State.UpToDate -> "You have the latest version"
is State.Available -> "Version ${state.release.version} is out"
is State.Downloading -> "Downloading… ${(state.fraction * 100).toInt()}%"
is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Version ${state.release.version} is ready to install"
is State.Failed -> state.message
}
private val CHANNELS = listOf(
Triple(UpdateChecker.Channel.STABLE, "Stable", "Finished releases only"),
Triple(UpdateChecker.Channel.BETA, "Beta", "Test versions that are nearly done"),
Triple(UpdateChecker.Channel.ALPHA, "Alpha", "Every test version, first. Expect bugs"),
)
/** The one Settings row for updates. Checking, the channel and automatic checks all sit behind it. */
@Composable
internal fun UpdateRow() {
val context = LocalContext.current
val state by UpdateChecker.state.collectAsStateWithLifecycle()
SessionStatus.state.collectAsStateWithLifecycle().value
val subtitle = when (val s = state) {
State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap" else "Gearslip checks once a day"
State.Checking -> "Checking…"
State.UpToDate -> "You have the latest version"
is State.Available -> "Version ${s.release.version} is out. Tap to download."
is State.Downloading -> "Downloading… ${(s.fraction * 100).toInt()}%"
is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Tap to install version ${s.release.version}"
is State.Failed -> s.message
}
var channel by remember { mutableStateOf(UpdateChecker.channel(context)) }
var open by remember { mutableStateOf(false) }
val channelName = CHANNELS.first { it.first == channel }.second
SettingsRow(
"Check for updates",
Modifier.clickable {
when (val s = state) {
is State.Available -> UpdateChecker.download(context, s.release)
is State.Ready -> UpdateChecker.install(context)
is State.Failed -> s.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
else -> UpdateChecker.check(context)
"Updates",
Modifier.clickable { open = true },
subtitle = "$channelName · ${statusLine(state)}",
icon = Icons.Filled.CheckCircle,
trailing = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
if (open) {
UpdateDialog(
state = state,
channel = channel,
onChannel = {
channel = it
UpdateChecker.setChannel(context, it)
},
onDismiss = { open = false },
)
}
}
@Composable
private fun UpdateDialog(
state: State,
channel: UpdateChecker.Channel,
onChannel: (UpdateChecker.Channel) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
val action: Pair<String, () -> Unit>? = when (state) {
State.Checking, is State.Downloading -> null
is State.Available -> "Download" to { UpdateChecker.download(context, state.release) }
is State.Ready -> if (UpdateChecker.carConnected) null else "Install" to { UpdateChecker.install(context) }
is State.Failed -> "Try again" to {
state.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
}
else -> "Check now" to { UpdateChecker.check(context) }
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Updates") },
text = {
Column {
Text(
"Version ${BuildConfig.VERSION_NAME}. ${statusLine(state)}.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
CHANNELS.forEach { (value, label, description) ->
Row(
Modifier
.fillMaxWidth()
.selectable(channel == value, role = Role.RadioButton) { onChannel(value) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = channel == value, onClick = null)
Column(Modifier.padding(start = 16.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge)
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(
Modifier
.fillMaxWidth()
.toggleable(auto, role = Role.Switch) {
auto = it
UpdateChecker.setAutoCheckEnabled(context, it)
}
.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Check automatically", style = MaterialTheme.typography.bodyLarge)
Text(
if (BuildConfig.DEBUG) "Dev builds check only when you tap Check now" else "Once a day",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = auto, onCheckedChange = null)
}
}
},
subtitle = subtitle,
icon = Icons.Filled.CheckCircle,
)
}
/** Whether Gearslip checks for updates on its own. */
@Composable
internal fun UpdateAutoRow() {
val context = LocalContext.current
var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
SettingsRow(
"Check automatically",
Modifier.toggleable(on, role = Role.Switch) {
on = it
UpdateChecker.setAutoCheckEnabled(context, it)
confirmButton = {
action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } }
},
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) },
dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
@@ -32,8 +32,9 @@ 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, unless the
* driver opted into betas: then pre-releases count too. The app checks at most once a day: when
* Which releases count depends on the driver's channel. Stable takes only GitHub's "latest"
* release, which leaves out pre-releases and drafts. Beta adds pre-releases tagged "beta", and
* Alpha adds every pre-release. 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
@@ -66,7 +67,9 @@ object UpdateChecker {
private const val KEY_FOUND = "found"
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
private const val KEY_AUTO = "notify"
/** The old on/off switch for pre-releases. On meant every pre-release, which is now Alpha. */
private const val KEY_BETA = "beta"
private const val KEY_CHANNEL = "channel"
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. */
@@ -94,15 +97,21 @@ object UpdateChecker {
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)
/** Which releases count as updates. Each channel also takes everything the one before it does. */
enum class Channel { STABLE, BETA, ALPHA }
fun channel(context: Context): Channel {
val p = prefs(context)
p.getString(KEY_CHANNEL, null)?.let { saved -> Channel.entries.firstOrNull { it.name == saved }?.let { return it } }
return if (p.getBoolean(KEY_BETA, false)) Channel.ALPHA else Channel.STABLE
}
/**
* 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.
* Switching channels forgets what the old one found: an alpha found earlier shouldn't still be
* offered on Stable. 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()
fun setChannel(context: Context, channel: Channel) {
prefs(context).edit().putString(KEY_CHANNEL, channel.name).remove(KEY_BETA).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()
@@ -222,7 +231,7 @@ object UpdateChecker {
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
}
private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold(
private fun checkNow(app: Context): State = runCatching { fetchNewest(channel(app)) }.fold(
onSuccess = { release ->
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
@@ -288,9 +297,10 @@ object UpdateChecker {
)
}
/** 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 {
/** The newest release with an APK that [channel] takes. Drafts never count. */
private fun fetchNewest(channel: Channel): Release? {
val stable = channel == Channel.STABLE
val conn = (URL(if (stable) LATEST else ALL).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Accept", "application/vnd.github+json")
@@ -300,10 +310,14 @@ object UpdateChecker {
if (conn.responseCode == 404) return null // no stable release yet
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
val body = conn.inputStream.bufferedReader().use { it.readText() }
if (!beta) return release(JSONObject(body))
if (stable) return release(JSONObject(body))
val list = org.json.JSONArray(body)
return (0 until list.length()).map { list.getJSONObject(it) }
.filterNot { it.optBoolean("draft") }
.filter {
channel == Channel.ALPHA || !it.optBoolean("prerelease") ||
it.optString("tag_name").contains("beta", ignoreCase = true)
}
.mapNotNull(::release)
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
} finally {