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 {