Stable updates install by themselves on Wi-Fi when no car is connected

This commit is contained in:
Seb3thehacker committed 2026-11-05 12:56:39 -08:00
1 parent 9d6d18bc47
commit 31d8232978
5 files changed
+236 -13

No files matched your search

+10
View File
@@ -5,6 +5,8 @@
<!-- 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" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- <!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -216,6 +218,14 @@
android:name=".notify.TestMessageReceiver" android:name=".notify.TestMessageReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".update.UpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service <service
android:name=".mirror.MirrorService" android:name=".mirror.MirrorService"
android:exported="false" android:exported="false"
@@ -171,6 +171,7 @@ private fun UpdateDialog(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) } var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
var silent by remember { mutableStateOf(UpdateChecker.silentEnabled(context)) }
val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) } val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) }
val action: Pair<String, () -> Unit>? = when (state) { val action: Pair<String, () -> Unit>? = when (state) {
State.Checking, is State.Downloading -> null State.Checking, is State.Downloading -> null
@@ -230,6 +231,29 @@ private fun UpdateDialog(
} }
Switch(checked = auto, onCheckedChange = null) Switch(checked = auto, onCheckedChange = null)
} }
// Silent installs ride on the automatic check, so the switch only shows with it on.
if (auto && !BuildConfig.DEBUG) {
Row(
Modifier
.fillMaxWidth()
.toggleable(silent, role = Role.Switch) {
silent = it
UpdateChecker.setSilentEnabled(context, it)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.update_silent), style = MaterialTheme.typography.bodyLarge)
Text(
stringResource(R.string.update_silent_sub),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = silent, onCheckedChange = null)
}
}
} }
}, },
confirmButton = { confirmButton = {
@@ -1,25 +1,31 @@
package app.seb3thehacker.gearslip.update package app.seb3thehacker.gearslip.update
import android.Manifest import android.Manifest
import android.app.Activity
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.GearslipActivity import app.seb3thehacker.gearslip.GearslipActivity
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.notify.CarMotion import app.seb3thehacker.gearslip.notify.CarMotion
import app.seb3thehacker.gearslip.notify.CarNotification import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications import app.seb3thehacker.gearslip.notify.CarNotifications
import app.seb3thehacker.gearslip.wireless.WirelessAuto
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -42,6 +48,10 @@ import java.security.MessageDigest
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one * Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
* a browser makes for the release page. Debug builds never check on their own. * a browser makes for the release page. Debug builds never check on their own.
* *
* On Stable, a new version also installs by itself on Wi-Fi while no car is connected, without
* Android asking, once Gearslip has installed itself before. Android allows that only to the app
* that installed the current version, so the first update from a browser download still asks.
*
* The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an * 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. * update signed with a different key, so a bad download can't replace the app.
*/ */
@@ -72,6 +82,12 @@ object UpdateChecker {
private const val KEY_CHANNEL = "channel" 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"
private const val KEY_SILENT = "silent"
/** The version installing without a prompt, so the new one can say it updated. */
private const val KEY_SILENT_VERSION = "silent_version"
/** A version Android wanted confirmed: it isn't tried quietly again, the card and notification take over. */
private const val KEY_SILENT_REFUSED = "silent_refused"
private const val UPDATED_NOTIFICATION_ID = 4101
/** 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. */
private const val CAR_DELAY_MS = 8_000L private const val CAR_DELAY_MS = 8_000L
private const val CHANNEL = "updates" private const val CHANNEL = "updates"
@@ -97,6 +113,13 @@ object UpdateChecker {
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
} }
/** Whether a Stable update installs by itself on Wi-Fi with no car connected. On by default. */
fun silentEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_SILENT, true)
fun setSilentEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_SILENT, on).apply()
}
/** Which releases count as updates. Each channel also takes everything the one before it does. */ /** Which releases count as updates. Each channel also takes everything the one before it does. */
enum class Channel { STABLE, BETA, ALPHA } enum class Channel { STABLE, BETA, ALPHA }
@@ -130,10 +153,27 @@ object UpdateChecker {
install(context) install(context)
return return
} }
// Opened for a car (plugged in, or wireless found it): installing now would close Gearslip
// just as it connects.
val forCar = (context as? Activity)?.intent?.action in
setOf(UsbManager.ACTION_USB_ACCESSORY_ATTACHED, WirelessAuto.ACTION_CAR_NEARBY)
if (current is State.Ready) {
if (!forCar) installQuietly(context.applicationContext, current.release)
return
}
if (current != State.Idle) return if (current != State.Idle) return
// A release found earlier still shows after Android closed the app. // A release found earlier still shows after Android closed the app.
savedRelease(context)?.let { flow.value = State.Available(it) } savedRelease(context)?.let { flow.value = State.Available(it) }
if (dueForCheck(context)) check(context) val app = context.applicationContext
if (dueForCheck(context)) {
flow.value = State.Checking
Thread({
flow.value = checkNow(app)
(flow.value as? State.Available)?.let { if (!forCar) installQuietly(app, it.release) }
}, "gearslip-update").start()
} else if (!forCar) {
savedRelease(app)?.let { installQuietly(app, it) }
}
} }
/** /**
@@ -150,6 +190,7 @@ object UpdateChecker {
flow.value = checkNow(app) flow.value = checkNow(app)
} }
val release = foundRelease(app) ?: return@Thread val release = foundRelease(app) ?: return@Thread
if (installQuietly(app, release)) return@Thread
if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) 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() if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
}, "gearslip-update").start() }, "gearslip-update").start()
@@ -250,8 +291,34 @@ object UpdateChecker {
}, },
) )
/** Downloads [release], then opens the installer once the file checks out. */ /**
fun download(context: Context, release: Release) { * Downloads and installs [release] without asking, when everything allows it: Stable, the
* switch on, Wi-Fi, no car, and Android already letting Gearslip install. Returns false when it
* didn't start, so the caller tells the driver the usual way.
*/
private fun installQuietly(app: Context, release: Release): Boolean {
val ok = !BuildConfig.DEBUG && autoCheckEnabled(app) && silentEnabled(app) &&
channel(app) == Channel.STABLE && !carConnected && onWifi(app) &&
app.packageManager.canRequestPackageInstalls() &&
prefs(app).getString(KEY_SILENT_REFUSED, null) != release.version
if (!ok) return false
GearslipLog.i("update: installing ${release.version} without asking")
val current = flow.value
if (current is State.Ready && current.release == release) install(app, quiet = true)
else download(app, release, quiet = true)
return true
}
/** Not metered: Wi-Fi or ethernet, where 34 MB doesn't cost anyone. */
private fun onWifi(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/** Downloads [release], then installs it once the file checks out. */
fun download(context: Context, release: Release, quiet: Boolean = false) {
if (flow.value is State.Downloading) return if (flow.value is State.Downloading) return
val app = context.applicationContext val app = context.applicationContext
flow.value = State.Downloading(release, 0f) flow.value = State.Downloading(release, 0f)
@@ -267,21 +334,23 @@ object UpdateChecker {
GearslipLog.w("update: download failed: ${it.message}") GearslipLog.w("update: download failed: ${it.message}")
State.Failed(it.message ?: "The download failed. Try again.", release) State.Failed(it.message ?: "The download failed. Try again.", release)
} }
if (flow.value is State.Ready) install(app) if (flow.value is State.Ready) install(app, quiet)
}, "gearslip-update").start() }, "gearslip-update").start()
} }
/** /**
* Hands the downloaded APK to Android's installer. The first time, Android wants the driver to * 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 * allow installs from Gearslip; that opens its settings page, and the install carries on when
* they come back. * they come back. A [quiet] install never opens anything: if Android wants a confirmation, it
* falls back to the card and the notification.
*/ */
fun install(context: Context) { fun install(context: Context, quiet: Boolean = false) {
val ready = flow.value as? State.Ready ?: return val ready = flow.value as? State.Ready ?: return
// Installing closes Gearslip, which would drop the car screen mid-drive. // Installing closes Gearslip, which would drop the car screen mid-drive.
if (carConnected) return if (carConnected) return
val pm = context.packageManager val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) { if (!pm.canRequestPackageInstalls()) {
if (quiet) return
installWhenAllowed = true installWhenAllowed = true
context.startActivity( context.startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
@@ -289,14 +358,111 @@ object UpdateChecker {
) )
return return
} }
val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk) val app = context.applicationContext
context.startActivity( Thread({
Intent(Intent.ACTION_VIEW) runCatching { commit(app, ready, quiet) }.onFailure {
.setDataAndType(uri, "application/vnd.android.package-archive") GearslipLog.w("update: install failed to start: ${it.message}")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK), if (!quiet) flow.value = State.Failed("Android couldn't start the install. Try again.", ready.release)
) }
}, "gearslip-update").start()
} }
/**
* Writes the APK into an install session. Android skips its prompt when Gearslip installed the
* version being replaced; otherwise [onInstallStatus] gets a screen to show for the driver.
*/
private fun commit(app: Context, ready: State.Ready, quiet: Boolean) {
val installer = app.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(app.packageName)
setSize(ready.apk.length())
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val id = installer.createSession(params)
installer.openSession(id).use { session ->
session.openWrite("gearslip.apk", 0, ready.apk.length()).use { out ->
ready.apk.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
if (quiet) prefs(app).edit().putString(KEY_SILENT_VERSION, ready.release.version).commit()
val status = Intent(app, UpdateReceiver::class.java)
.setAction(UpdateReceiver.ACTION_STATUS)
.putExtra(EXTRA_QUIET, quiet)
.putExtra(EXTRA_VERSION, ready.release.version)
// Mutable: the installer adds the outcome to it.
val pending = PendingIntent.getBroadcast(app, id, status, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
session.commit(pending.intentSender)
}
GearslipLog.i("update: handed ${ready.release.version} to Android's installer")
}
/** What the installer said about a session; on success Gearslip is replaced and this never runs. */
internal fun onInstallStatus(context: Context, intent: Intent) {
val app = context.applicationContext
val quiet = intent.getBooleanExtra(EXTRA_QUIET, false)
val version = intent.getStringExtra(EXTRA_VERSION)
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
if (quiet) {
// Android wants a tap, maybe because a browser installed this version. Don't
// pop a screen up unasked: drop it, and the card and notification take over.
GearslipLog.i("update: Android wants $version confirmed; leaving it to the driver")
val session = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
if (session >= 0) runCatching { app.packageManager.packageInstaller.abandonSession(session) }
prefs(app).edit().putString(KEY_SILENT_REFUSED, version).remove(KEY_SILENT_VERSION).apply()
val release = foundRelease(app) ?: return
if (prefs(app).getString(KEY_NOTIFIED, null) != release.version && notify(app, release)) {
prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
}
} else {
@Suppress("DEPRECATION")
val confirm = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT) ?: return
runCatching { app.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
PackageInstaller.STATUS_SUCCESS -> GearslipLog.i("update: $version installed")
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
GearslipLog.w("update: install of $version failed ($status, $message)")
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
if (quiet) prefs(app).edit().putString(KEY_SILENT_REFUSED, version).apply()
else if (status != PackageInstaller.STATUS_FAILURE_ABORTED) {
flow.value = State.Failed("Android didn't install it. Try again.", foundRelease(app))
}
}
}
}
/** The new version's first breath after updating itself: say so, since nobody was asked. */
internal fun onReplaced(context: Context) {
val app = context.applicationContext
val version = prefs(app).getString(KEY_SILENT_VERSION, null) ?: return
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
if (version != BuildConfig.VERSION_NAME) return
GearslipLog.i("update: now on $version, installed without asking")
if (app.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
app.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
)
val open = PendingIntent.getActivity(
app, UPDATED_NOTIFICATION_ID,
Intent(app, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(app, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(app.getString(R.string.update_installed_title, version))
.setContentText(app.getString(R.string.update_installed_text))
.setContentIntent(open)
.setAutoCancel(true)
.build()
runCatching { NotificationManagerCompat.from(app).notify(UPDATED_NOTIFICATION_ID, notification) }
}
private const val EXTRA_QUIET = "quiet"
private const val EXTRA_VERSION = "version"
/** The newest release with an APK that [channel] takes. Drafts never count. */ /** The newest release with an APK that [channel] takes. Drafts never count. */
private fun fetchNewest(channel: Channel): Release? { private fun fetchNewest(channel: Channel): Release? {
val stable = channel == Channel.STABLE val stable = channel == Channel.STABLE
@@ -0,0 +1,19 @@
package app.seb3thehacker.gearslip.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** The installer's answer about an update, and the new version's first start after one. */
class UpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_STATUS -> UpdateChecker.onInstallStatus(context, intent)
Intent.ACTION_MY_PACKAGE_REPLACED -> UpdateChecker.onReplaced(context)
}
}
companion object {
const val ACTION_STATUS = "app.seb3thehacker.gearslip.update.STATUS"
}
}
+4
View File
@@ -302,6 +302,10 @@
<string name="update_check_now">Check now</string> <string name="update_check_now">Check now</string>
<string name="update_check_auto">Check automatically</string> <string name="update_check_auto">Check automatically</string>
<string name="update_once_a_day">Once a day</string> <string name="update_once_a_day">Once a day</string>
<string name="update_silent">Install updates by itself</string>
<string name="update_silent_sub">Finished releases only, on Wi-Fi, when no car is connected</string>
<string name="update_installed_title">Gearslip updated to %1$s</string>
<string name="update_installed_text">Installed on Wi-Fi while no car was connected.</string>
<!-- --> <!-- -->
<string name="update_unplug_first">Unplug from the car to install. Installing closes Gearslip.</string> <string name="update_unplug_first">Unplug from the car to install. Installing closes Gearslip.</string>