mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
update checker: finds new releases, downloads and installs them
Checks GitHub's latest release once a day when the app opens and after a drive. A new version shows as a card on Home, a notification after the drive, and a card on the car screen at plug-in while parked. Downloads are checked against GitHub's SHA-256 and never install while the car is connected. Update notifications can be turned off in Settings.
This commit is contained in:
1 parent
9620dab758
commit
17534d5784
10 files changed
+535
-3
No files matched your search
@@ -33,7 +33,8 @@ android {
|
|||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 8
|
versionCode = 8
|
||||||
versionName = "0.2.00-alpha"
|
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||||
|
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.2.00-alpha"
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
super.onStart()
|
super.onStart()
|
||||||
Prefetch.warm(this)
|
Prefetch.warm(this)
|
||||||
UsageStats.onAppOpened(this)
|
UsageStats.onAppOpened(this)
|
||||||
|
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ object SessionReport {
|
|||||||
else -> category.label
|
else -> category.label
|
||||||
}
|
}
|
||||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||||
|
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
|
||||||
}
|
}
|
||||||
GearslipLog.flush()
|
GearslipLog.flush()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ fun CarUi() {
|
|||||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||||
// somewhere to play it.
|
// somewhere to play it.
|
||||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||||
|
// A new version gets one card in the top right, a few seconds in and only while parked.
|
||||||
|
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
||||||
// The map follows the light outside, whatever the app's own theme is set to.
|
// The map follows the light outside, whatever the app's own theme is set to.
|
||||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ fun HomeScreen(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
StatusCard(status)
|
StatusCard(status)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
UpdateCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sits at the bottom, where a thumb reaches it.
|
// Sits at the bottom, where a thumb reaches it.
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ private fun Arrow() = Icon(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What's new and the setup guide, one tap from the top. */
|
/** What's new, updates and the setup guide, one tap from the top. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||||
SettingsSection {
|
SettingsSection {
|
||||||
@@ -118,6 +118,10 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
|||||||
trailing = { Arrow() },
|
trailing = { Arrow() },
|
||||||
)
|
)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
UpdateRow()
|
||||||
|
SettingsDivider()
|
||||||
|
UpdateNotifyRow()
|
||||||
|
SettingsDivider()
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
"Replay the setup guide",
|
"Replay the setup guide",
|
||||||
Modifier.clickable(onClick = onReplayTutorial),
|
Modifier.clickable(onClick = onReplayTutorial),
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
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.Row
|
||||||
|
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.toggleable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
|
import app.seb3thehacker.gearslip.SessionStatus
|
||||||
|
import app.seb3thehacker.gearslip.update.UpdateChecker
|
||||||
|
import app.seb3thehacker.gearslip.update.UpdateChecker.State
|
||||||
|
|
||||||
|
private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip."
|
||||||
|
|
||||||
|
/** Shown on Home once a newer version is found; hidden otherwise. */
|
||||||
|
@Composable
|
||||||
|
fun UpdateCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val state by UpdateChecker.state.collectAsStateWithLifecycle()
|
||||||
|
SessionStatus.state.collectAsStateWithLifecycle().value // redraw when the car comes or goes
|
||||||
|
val release = when (val s = state) {
|
||||||
|
is State.Available -> s.release
|
||||||
|
is State.Downloading -> s.release
|
||||||
|
is State.Ready -> s.release
|
||||||
|
is State.Failed -> s.release
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(20.dp)) {
|
||||||
|
Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
when (val s = state) {
|
||||||
|
is State.Downloading -> {
|
||||||
|
Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
is State.Ready -> {
|
||||||
|
val connected = UpdateChecker.carConnected
|
||||||
|
Text(
|
||||||
|
if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") }
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
(s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = { UpdateChecker.download(context, release) }) {
|
||||||
|
Text(if (s is State.Failed) "Try again" else "Update")
|
||||||
|
}
|
||||||
|
if (release.pageUrl.isNotBlank()) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl)))
|
||||||
|
}) { Text("What's new") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Settings row: checks on tap, and carries on to download and install once something is found. */
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subtitle = subtitle,
|
||||||
|
icon = Icons.Filled.CheckCircle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a drive's end may notify about a new version. */
|
||||||
|
@Composable
|
||||||
|
internal fun UpdateNotifyRow() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var on by remember { mutableStateOf(UpdateChecker.notifyEnabled(context)) }
|
||||||
|
SettingsRow(
|
||||||
|
"Update notifications",
|
||||||
|
Modifier.toggleable(on, role = Role.Switch) {
|
||||||
|
on = it
|
||||||
|
UpdateChecker.setNotifyEnabled(context, it)
|
||||||
|
},
|
||||||
|
subtitle = "On the car screen when you plug in, and on the phone after a drive",
|
||||||
|
icon = Icons.Filled.Notifications,
|
||||||
|
trailing = { Switch(checked = on, onCheckedChange = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package app.seb3thehacker.gearslip.update
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
|
import app.seb3thehacker.gearslip.GearslipActivity
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.SessionStatus
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarMotion
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotification
|
||||||
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
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. The app checks
|
||||||
|
* at most once a day: when it opens, and when a drive ends. 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
|
||||||
|
* car screen shows the same news once per version, at plug-in and only while parked.
|
||||||
|
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
|
||||||
|
* a browser makes for the release page. Dev builds never check on their own.
|
||||||
|
*
|
||||||
|
* The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an
|
||||||
|
* update signed with a different key, so a bad download can't replace the app.
|
||||||
|
*/
|
||||||
|
object UpdateChecker {
|
||||||
|
|
||||||
|
data class Release(val version: String, val apkUrl: String, val size: Long, val sha256: String?, val pageUrl: String)
|
||||||
|
|
||||||
|
sealed interface State {
|
||||||
|
data object Idle : State
|
||||||
|
data object Checking : State
|
||||||
|
data object UpToDate : State
|
||||||
|
data class Available(val release: Release) : State
|
||||||
|
data class Downloading(val release: Release, val fraction: Float) : State
|
||||||
|
data class Ready(val release: Release, val apk: File) : State
|
||||||
|
data class Failed(val message: String, val release: Release? = null) : State
|
||||||
|
}
|
||||||
|
|
||||||
|
private val flow = MutableStateFlow<State>(State.Idle)
|
||||||
|
val state: StateFlow<State> = flow.asStateFlow()
|
||||||
|
|
||||||
|
private const val PREFS = "updates"
|
||||||
|
private const val KEY_CHECKED_AT = "checked_at"
|
||||||
|
private const val KEY_FOUND = "found"
|
||||||
|
private const val KEY_NOTIFY = "notify"
|
||||||
|
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. */
|
||||||
|
private const val CAR_DELAY_MS = 8_000L
|
||||||
|
private const val CHANNEL = "updates"
|
||||||
|
private const val NOTIFICATION_ID = 4100
|
||||||
|
private const val DAY_MS = 24 * 60 * 60 * 1000L
|
||||||
|
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
|
||||||
|
|
||||||
|
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
|
||||||
|
@Volatile private var installWhenAllowed = false
|
||||||
|
|
||||||
|
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Whether a drive's end may post a notification about a new version. On by default: it sends nothing. */
|
||||||
|
fun notifyEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_NOTIFY, true)
|
||||||
|
|
||||||
|
fun setNotifyEnabled(context: Context, on: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_NOTIFY, on).apply()
|
||||||
|
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dueForCheck(context: Context) =
|
||||||
|
!BuildConfig.DEBUG && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
|
||||||
|
|
||||||
|
/** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */
|
||||||
|
fun onAppOpened(context: Context) {
|
||||||
|
val current = flow.value
|
||||||
|
if (installWhenAllowed && current is State.Ready && context.packageManager.canRequestPackageInstalls()) {
|
||||||
|
installWhenAllowed = false
|
||||||
|
install(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current != State.Idle) return
|
||||||
|
// A release found earlier still shows after Android closed the app.
|
||||||
|
savedRelease(context)?.let { flow.value = State.Available(it) }
|
||||||
|
if (dueForCheck(context)) check(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A car session ended, so the driver is parked. Checks if a day has passed, then posts a
|
||||||
|
* notification for a new version they haven't been told about yet.
|
||||||
|
*/
|
||||||
|
fun onDriveEnded(context: Context) {
|
||||||
|
if (!notifyEnabled(context)) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
Thread({
|
||||||
|
val current = flow.value
|
||||||
|
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
|
||||||
|
flow.value = State.Checking
|
||||||
|
flow.value = checkNow(app)
|
||||||
|
}
|
||||||
|
val release = foundRelease(app) ?: return@Thread
|
||||||
|
if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread
|
||||||
|
if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
|
||||||
|
}, "gearslip-update").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car screen just came up. A new version gets a card in the top right, like a message,
|
||||||
|
* once per version. Skipped while moving; the next plug-in tries again.
|
||||||
|
*/
|
||||||
|
fun onCarConnected(context: Context) {
|
||||||
|
if (!notifyEnabled(context)) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
Thread({
|
||||||
|
val current = flow.value
|
||||||
|
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
|
||||||
|
flow.value = State.Checking
|
||||||
|
flow.value = checkNow(app)
|
||||||
|
}
|
||||||
|
val release = foundRelease(app) ?: return@Thread
|
||||||
|
if (prefs(app).getString(KEY_CAR_NOTIFIED, null) == release.version) return@Thread
|
||||||
|
Thread.sleep(CAR_DELAY_MS)
|
||||||
|
if (!carConnected || CarMotion.moving) return@Thread
|
||||||
|
CarNotifications.post(
|
||||||
|
CarNotification(
|
||||||
|
key = "gearslip-update",
|
||||||
|
id = CarNotifications.nextId(),
|
||||||
|
packageName = app.packageName,
|
||||||
|
appLabel = "Gearslip",
|
||||||
|
title = "Gearslip ${release.version} is out",
|
||||||
|
text = "Update in the Gearslip app or from GitHub",
|
||||||
|
postedAt = System.currentTimeMillis(),
|
||||||
|
icon = runCatching { app.packageManager.getApplicationIcon(app.packageName).toBitmap(96, 96) }.getOrNull(),
|
||||||
|
reply = null,
|
||||||
|
),
|
||||||
|
quiet = false,
|
||||||
|
)
|
||||||
|
prefs(app).edit().putString(KEY_CAR_NOTIFIED, release.version).apply()
|
||||||
|
GearslipLog.i("update: showed ${release.version} on the car screen")
|
||||||
|
}, "gearslip-update").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun foundRelease(app: Context): Release? = when (val s = flow.value) {
|
||||||
|
is State.Available -> s.release
|
||||||
|
is State.Downloading -> s.release
|
||||||
|
is State.Ready -> s.release
|
||||||
|
else -> savedRelease(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts "Gearslip X is out"; tapping it opens the app, where the Home card has the button. */
|
||||||
|
private fun notify(context: Context, release: Release): Boolean {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return false
|
||||||
|
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Updates", 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 notification = NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setContentTitle("Gearslip ${release.version} is out")
|
||||||
|
.setContentText("Tap to update.")
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
return runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
|
||||||
|
.onSuccess { GearslipLog.i("update: told the driver about ${release.version}") }
|
||||||
|
.onFailure { GearslipLog.w("update: could not post the notification (${it.message})") }
|
||||||
|
.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks GitHub now. Ignored while a check or download is under way. */
|
||||||
|
fun check(context: Context) {
|
||||||
|
val current = flow.value
|
||||||
|
if (current is State.Checking || current is State.Downloading) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
flow.value = State.Checking
|
||||||
|
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNow(app: Context): State = runCatching { fetchLatest() }.fold(
|
||||||
|
onSuccess = { release ->
|
||||||
|
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
|
||||||
|
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
|
||||||
|
GearslipLog.i("update: ${release.version} is out (this is ${BuildConfig.VERSION_NAME})")
|
||||||
|
save(app, release)
|
||||||
|
State.Available(release)
|
||||||
|
} else {
|
||||||
|
prefs(app).edit().remove(KEY_FOUND).apply()
|
||||||
|
updatesDir(app).deleteRecursively()
|
||||||
|
State.UpToDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
GearslipLog.w("update: could not check: ${it.message}")
|
||||||
|
State.Failed("Couldn't reach GitHub. Check the connection and try again.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Downloads [release], then opens the installer once the file checks out. */
|
||||||
|
fun download(context: Context, release: Release) {
|
||||||
|
if (flow.value is State.Downloading) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
flow.value = State.Downloading(release, 0f)
|
||||||
|
Thread({
|
||||||
|
val dir = updatesDir(app).apply { deleteRecursively(); mkdirs() }
|
||||||
|
val apk = File(dir, "Gearslip-${release.version}.apk")
|
||||||
|
flow.value = runCatching {
|
||||||
|
fetchApk(release, apk)
|
||||||
|
GearslipLog.i("update: downloaded ${release.version}")
|
||||||
|
State.Ready(release, apk)
|
||||||
|
}.getOrElse {
|
||||||
|
apk.delete()
|
||||||
|
GearslipLog.w("update: download failed: ${it.message}")
|
||||||
|
State.Failed(it.message ?: "The download failed. Try again.", release)
|
||||||
|
}
|
||||||
|
if (flow.value is State.Ready) install(app)
|
||||||
|
}, "gearslip-update").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the downloaded APK to Android's installer. The first time, Android wants the driver to
|
||||||
|
* allow installs from Gearslip; that opens its settings page, and the install carries on when
|
||||||
|
* they come back.
|
||||||
|
*/
|
||||||
|
fun install(context: Context) {
|
||||||
|
val ready = flow.value as? State.Ready ?: return
|
||||||
|
// Installing closes Gearslip, which would drop the car screen mid-drive.
|
||||||
|
if (carConnected) return
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
installWhenAllowed = true
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk)
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchLatest(): Release? {
|
||||||
|
val conn = (URL(LATEST).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
setRequestProperty("Accept", "application/vnd.github+json")
|
||||||
|
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (conn.responseCode == 404) return null // no stable release yet
|
||||||
|
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
||||||
|
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
|
||||||
|
val assets = json.optJSONArray("assets") ?: return null
|
||||||
|
val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||||
|
.firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
|
||||||
|
?: return null
|
||||||
|
return Release(
|
||||||
|
version = json.getString("tag_name").removePrefix("v"),
|
||||||
|
apkUrl = apk.getString("browser_download_url"),
|
||||||
|
size = apk.optLong("size", -1),
|
||||||
|
sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
|
||||||
|
pageUrl = json.optString("html_url"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchApk(release: Release, into: File) {
|
||||||
|
val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
|
||||||
|
val total = release.size.takeIf { it > 0 } ?: conn.contentLengthLong
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
var done = 0L
|
||||||
|
var shown = -1
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
into.outputStream().use { output ->
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buffer)
|
||||||
|
if (n < 0) break
|
||||||
|
output.write(buffer, 0, n)
|
||||||
|
digest.update(buffer, 0, n)
|
||||||
|
done += n
|
||||||
|
// Whole percents only, so the screen isn't redrawn for every buffer.
|
||||||
|
val percent = if (total > 0) (done * 100 / total).toInt() else 0
|
||||||
|
if (percent != shown) {
|
||||||
|
shown = percent
|
||||||
|
flow.value = State.Downloading(release, percent / 100f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (release.size > 0 && done != release.size) error("The download stopped early. Try again.")
|
||||||
|
val hex = digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
if (release.sha256 != null && !hex.equals(release.sha256, ignoreCase = true)) {
|
||||||
|
error("The download came out damaged. Try again.")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installing waits until the car is unplugged. */
|
||||||
|
val carConnected: Boolean
|
||||||
|
get() = SessionStatus.state.value.phase.let { it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING }
|
||||||
|
|
||||||
|
private fun updatesDir(context: Context) = File(context.cacheDir, "updates")
|
||||||
|
|
||||||
|
private fun save(context: Context, release: Release) {
|
||||||
|
val json = JSONObject()
|
||||||
|
.put("version", release.version)
|
||||||
|
.put("apkUrl", release.apkUrl)
|
||||||
|
.put("size", release.size)
|
||||||
|
.put("sha256", release.sha256 ?: "")
|
||||||
|
.put("pageUrl", release.pageUrl)
|
||||||
|
prefs(context).edit().putString(KEY_FOUND, json.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The release found last time, if it is still newer than this build (it isn't after updating). */
|
||||||
|
private fun savedRelease(context: Context): Release? {
|
||||||
|
val json = prefs(context).getString(KEY_FOUND, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
|
||||||
|
val release = Release(
|
||||||
|
version = json.optString("version"),
|
||||||
|
apkUrl = json.optString("apkUrl"),
|
||||||
|
size = json.optLong("size", -1),
|
||||||
|
sha256 = json.optString("sha256").ifBlank { null },
|
||||||
|
pageUrl = json.optString("pageUrl"),
|
||||||
|
)
|
||||||
|
return release.takeIf { isNewer(it.version, BuildConfig.VERSION_NAME) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. */
|
||||||
|
internal fun isNewer(candidate: String, installed: String): Boolean {
|
||||||
|
fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
|
||||||
|
val a = parts(candidate)
|
||||||
|
val b = parts(installed)
|
||||||
|
for (i in 0 until maxOf(a.size, b.size)) {
|
||||||
|
val x = a.getOrElse(i) { 0 }
|
||||||
|
val y = b.getOrElse(i) { 0 }
|
||||||
|
if (x != y) return x > y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths>
|
<paths>
|
||||||
<!-- Only the log copies made for sharing; nothing else in the app is exposed. -->
|
<!-- Only log copies made for sharing and downloaded updates; nothing else in the app is exposed. -->
|
||||||
<cache-path name="shared_logs" path="shared/" />
|
<cache-path name="shared_logs" path="shared/" />
|
||||||
|
<cache-path name="updates" path="updates/" />
|
||||||
</paths>
|
</paths>
|
||||||
Reference in new issue
Block a user