mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +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
@@ -4,6 +4,7 @@
|
||||
|
||||
<!-- 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.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!--
|
||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||
|
||||
@@ -152,6 +152,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
||||
@@ -146,6 +146,7 @@ object SessionReport {
|
||||
else -> category.label
|
||||
}
|
||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
|
||||
}
|
||||
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
|
||||
// somewhere to play it.
|
||||
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.
|
||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ fun HomeScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusCard(status)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
UpdateCard()
|
||||
}
|
||||
|
||||
// Sits at the bottom, where a thumb reaches it.
|
||||
|
||||
@@ -106,7 +106,7 @@ private fun Arrow() = Icon(
|
||||
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
|
||||
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
|
||||
SettingsSection {
|
||||
@@ -118,6 +118,10 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
|
||||
trailing = { Arrow() },
|
||||
)
|
||||
SettingsDivider()
|
||||
UpdateRow()
|
||||
SettingsDivider()
|
||||
UpdateNotifyRow()
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
"Replay the setup guide",
|
||||
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"?>
|
||||
<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="updates" path="updates/" />
|
||||
</paths>
|
||||
Reference in new issue
Block a user