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:
Seb3thehacker committed 2026-10-06 00:06:06 -07:00
1 parent 9620dab758
commit 17534d5784
10 files changed
+535 -3

No files matched your search

+2 -1
View File
@@ -33,7 +33,8 @@ android {
minSdk = 31
targetSdk = 37
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"
// 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.
+1
View File
@@ -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
}
}
+2 -1
View File
@@ -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>