diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml
index 530cd69..2aa3ab1 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -178,11 +178,6 @@
android:name=".notify.TestMessageReceiver"
android:exported="false" />
-
-
-
= 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)
-}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt
new file mode 100644
index 0000000..1b2f642
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt
@@ -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,
+ )
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
index b293639..cc58673 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
@@ -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.
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
index 53f6146..6405eba 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt
@@ -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) },
)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt
deleted file mode 100644
index 4d8f22b..0000000
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SupportViews.kt
+++ /dev/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,
- )
-}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt
index 147e134..f357cc2 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt
@@ -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 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") } },
)
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
index 86a7990..f6bfaaa 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt
@@ -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 {