mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +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
@@ -178,11 +178,6 @@
|
|||||||
android:name=".notify.TestMessageReceiver"
|
android:name=".notify.TestMessageReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- The one-time "Gearslip worked with your car" ask: its Share and Not now buttons. -->
|
|
||||||
<receiver
|
|
||||||
android:name=".stats.WorkedPromptReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".mirror.MirrorService"
|
android:name=".mirror.MirrorService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.stats
|
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.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.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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
|
* 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
|
* 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,
|
* a card on Home. Answering it clears it, and it never comes back.
|
||||||
* and it never comes back.
|
|
||||||
*
|
*
|
||||||
* Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
|
* 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.
|
* 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 PREFS = "worked_prompt"
|
||||||
private const val KEY_DONE = "done"
|
private const val KEY_DONE = "done"
|
||||||
private const val KEY_CAR = "car"
|
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)
|
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()
|
p.edit().putString(KEY_CAR, car.toString()).apply()
|
||||||
loaded = true
|
loaded = true
|
||||||
flow.value = carName(car)
|
flow.value = carName(car)
|
||||||
notify(context, carName(car))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** True once a driver turns sharing on from the ask, until Home has thrown its confetti. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private val celebrateFlow = MutableStateFlow(false)
|
private val celebrateFlow = MutableStateFlow(false)
|
||||||
val celebrate: StateFlow<Boolean> = celebrateFlow
|
val celebrate: StateFlow<Boolean> = celebrateFlow
|
||||||
|
|
||||||
fun celebrated() { celebrateFlow.value = false }
|
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) {
|
fun answer(context: Context, share: Boolean) {
|
||||||
if (share) {
|
if (share) {
|
||||||
// Turning sharing on lands in [onSharingOn], which clears the ask.
|
// 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()
|
prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply()
|
||||||
loaded = true
|
loaded = true
|
||||||
flow.value = null
|
flow.value = null
|
||||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saved(context: Context): JSONObject? =
|
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 "
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
WirelessCard()
|
WirelessCard()
|
||||||
}
|
}
|
||||||
if (!live) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
SupportCard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sits at the bottom, where a thumb reaches it.
|
// 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.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -77,11 +79,13 @@ fun SettingsScreen(
|
|||||||
atUsageNotes: Boolean = false,
|
atUsageNotes: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val scroll = rememberScrollState()
|
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) {
|
if (atUsageNotes) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
val y = snapshotFlow { usageNotesY }.first { it >= 0 }
|
||||||
snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
|
snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
|
||||||
scroll.scrollTo(scroll.maxValue)
|
scroll.scrollTo(y)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -106,13 +110,18 @@ fun SettingsScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(0.dp))
|
Spacer(Modifier.height(0.dp))
|
||||||
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
|
|
||||||
ConnectionSection()
|
ConnectionSection()
|
||||||
CarAudioSection()
|
CarAudioSection()
|
||||||
VoiceModelSection()
|
VoiceModelSection()
|
||||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
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()
|
CertificateSection()
|
||||||
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
AboutSection(onOpenWhatsNew, onReplayTutorial, onReplayIntro)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,10 +134,10 @@ private fun Arrow() = Icon(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
@Composable
|
||||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
|
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, onReplayIntro: () -> Unit) {
|
||||||
SettingsSection {
|
SettingsSection("About") {
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"What's new",
|
"What's new",
|
||||||
Modifier.clickable(onClick = onOpenWhatsNew),
|
Modifier.clickable(onClick = onOpenWhatsNew),
|
||||||
@@ -137,11 +146,13 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
|||||||
trailing = { Arrow() },
|
trailing = { Arrow() },
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
UpdateRow()
|
SettingsRow(
|
||||||
SettingsDivider()
|
"Replay the ${Intro.release} intro",
|
||||||
UpdateAutoRow()
|
Modifier.clickable(onClick = onReplayIntro),
|
||||||
SettingsDivider()
|
subtitle = "What's in this release, in a few taps",
|
||||||
UpdateBetaRow()
|
icon = Icons.Filled.Star,
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Replay the setup guide",
|
"Replay the setup guide",
|
||||||
@@ -151,14 +162,6 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
|||||||
trailing = { Arrow() },
|
trailing = { Arrow() },
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
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()
|
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
|
@Composable
|
||||||
private fun CarAudioSection() {
|
private fun CarAudioSection() {
|
||||||
val usb by CarSettings.pipeAudio.collectAsState()
|
val usb by CarSettings.pipeAudio.collectAsState()
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
"Car audio",
|
"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(
|
SettingsRow(
|
||||||
"USB",
|
"Bluetooth (Recommended)",
|
||||||
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
||||||
subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
|
subtitle = "No prompt each drive, and your map app's voice reaches the car",
|
||||||
trailing = { RadioButton(selected = usb, onClick = null) },
|
trailing = { RadioButton(selected = !usb, onClick = null) },
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Bluetooth",
|
"USB",
|
||||||
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
|
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
|
||||||
subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
|
subtitle = "Clearer sound with no delay. Android asks to share audio once each drive",
|
||||||
trailing = { RadioButton(selected = !usb, onClick = null) },
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.material.icons.Icons
|
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.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material.icons.filled.Build
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -27,6 +30,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
@@ -101,68 +105,130 @@ fun UpdateCard() {
|
|||||||
Spacer(Modifier.height(16.dp))
|
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
|
@Composable
|
||||||
internal fun UpdateRow() {
|
internal fun UpdateRow() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val state by UpdateChecker.state.collectAsStateWithLifecycle()
|
val state by UpdateChecker.state.collectAsStateWithLifecycle()
|
||||||
SessionStatus.state.collectAsStateWithLifecycle().value
|
SessionStatus.state.collectAsStateWithLifecycle().value
|
||||||
val subtitle = when (val s = state) {
|
var channel by remember { mutableStateOf(UpdateChecker.channel(context)) }
|
||||||
State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap" else "Gearslip checks once a day"
|
var open by remember { mutableStateOf(false) }
|
||||||
State.Checking -> "Checking…"
|
val channelName = CHANNELS.first { it.first == channel }.second
|
||||||
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
|
|
||||||
}
|
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Check for updates",
|
"Updates",
|
||||||
Modifier.clickable {
|
Modifier.clickable { open = true },
|
||||||
when (val s = state) {
|
subtitle = "$channelName · ${statusLine(state)}",
|
||||||
is State.Available -> UpdateChecker.download(context, s.release)
|
icon = Icons.Filled.CheckCircle,
|
||||||
is State.Ready -> UpdateChecker.install(context)
|
trailing = {
|
||||||
is State.Failed -> s.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
|
Icon(
|
||||||
else -> UpdateChecker.check(context)
|
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,
|
confirmButton = {
|
||||||
icon = Icons.Filled.CheckCircle,
|
action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } }
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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)
|
|
||||||
},
|
},
|
||||||
subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates"
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } },
|
||||||
else if (on) "Once a day, with a note on the car screen and the phone when one is out"
|
|
||||||
else "Gearslip checks only when you tap Check for updates",
|
|
||||||
icon = Icons.Filled.Notifications,
|
|
||||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether pre-releases count as updates. */
|
|
||||||
@Composable
|
|
||||||
internal fun UpdateBetaRow() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) }
|
|
||||||
SettingsRow(
|
|
||||||
"Beta updates",
|
|
||||||
Modifier.toggleable(on, role = Role.Switch) {
|
|
||||||
on = it
|
|
||||||
UpdateChecker.setBetaEnabled(context, it)
|
|
||||||
},
|
|
||||||
subtitle = "Get test versions before they're released. They may have more bugs.",
|
|
||||||
icon = Icons.Filled.Build,
|
|
||||||
trailing = { Switch(checked = on, onCheckedChange = null) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,9 @@ import java.security.MessageDigest
|
|||||||
/**
|
/**
|
||||||
* Looks for a newer Gearslip on GitHub and installs it through Android's own installer.
|
* 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
|
* Which releases count depends on the driver's channel. Stable takes only GitHub's "latest"
|
||||||
* driver opted into betas: then pre-releases count too. The app checks at most once a day: when
|
* 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,
|
* 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
|
* 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
|
* 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"
|
private const val KEY_FOUND = "found"
|
||||||
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
|
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
|
||||||
private const val KEY_AUTO = "notify"
|
private const val KEY_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_BETA = "beta"
|
||||||
|
private const val KEY_CHANNEL = "channel"
|
||||||
private const val KEY_NOTIFIED = "notified_version"
|
private const val KEY_NOTIFIED = "notified_version"
|
||||||
private const val KEY_CAR_NOTIFIED = "car_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. */
|
/** 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)
|
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether GitHub pre-releases count as updates. */
|
/** Which releases count as updates. Each channel also takes everything the one before it does. */
|
||||||
fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false)
|
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
|
* Switching channels forgets what the old one found: an alpha found earlier shouldn't still be
|
||||||
* offered once betas are off. Checks again right away if automatic checks are on.
|
* offered on Stable. Checks again right away if automatic checks are on.
|
||||||
*/
|
*/
|
||||||
fun setBetaEnabled(context: Context, on: Boolean) {
|
fun setChannel(context: Context, channel: Channel) {
|
||||||
prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
|
prefs(context).edit().putString(KEY_CHANNEL, channel.name).remove(KEY_BETA).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
|
||||||
val current = flow.value
|
val current = flow.value
|
||||||
if (current is State.Checking || current is State.Downloading) return
|
if (current is State.Checking || current is State.Downloading) return
|
||||||
updatesDir(context).deleteRecursively()
|
updatesDir(context).deleteRecursively()
|
||||||
@@ -222,7 +231,7 @@ object UpdateChecker {
|
|||||||
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
|
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 ->
|
onSuccess = { release ->
|
||||||
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
|
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
|
||||||
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
|
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. */
|
/** The newest release with an APK that [channel] takes. Drafts never count. */
|
||||||
private fun fetchNewest(beta: Boolean): Release? {
|
private fun fetchNewest(channel: Channel): Release? {
|
||||||
val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply {
|
val stable = channel == Channel.STABLE
|
||||||
|
val conn = (URL(if (stable) LATEST else ALL).openConnection() as HttpURLConnection).apply {
|
||||||
connectTimeout = 15_000
|
connectTimeout = 15_000
|
||||||
readTimeout = 15_000
|
readTimeout = 15_000
|
||||||
setRequestProperty("Accept", "application/vnd.github+json")
|
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 == 404) return null // no stable release yet
|
||||||
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
||||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
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)
|
val list = org.json.JSONArray(body)
|
||||||
return (0 until list.length()).map { list.getJSONObject(it) }
|
return (0 until list.length()).map { list.getJSONObject(it) }
|
||||||
.filterNot { it.optBoolean("draft") }
|
.filterNot { it.optBoolean("draft") }
|
||||||
|
.filter {
|
||||||
|
channel == Channel.ALPHA || !it.optBoolean("prerelease") ||
|
||||||
|
it.optString("tag_name").contains("beta", ignoreCase = true)
|
||||||
|
}
|
||||||
.mapNotNull(::release)
|
.mapNotNull(::release)
|
||||||
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
|
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user