mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user