mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-11 22:06:23 -04:00
feat(desktop): update notifications and store-readiness fixes (#6215)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
11
.github/workflows/pull-request.yml
vendored
11
.github/workflows/pull-request.yml
vendored
@@ -114,6 +114,17 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Validate store listing metadata lengths
|
||||
run: python3 scripts/check-metadata-length.py
|
||||
# Flathub/AppStream needs a <release> entry for the version being shipped; a stale
|
||||
# <releases> block degrades (or fails) the Flathub listing. Fails the PR that bumps
|
||||
# VERSION_NAME_BASE until the matching entry is added.
|
||||
- name: Require AppStream release entry for current version
|
||||
run: |
|
||||
VERSION=$(grep '^VERSION_NAME_BASE=' config.properties | cut -d'=' -f2)
|
||||
METAINFO=desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml
|
||||
if ! grep -q "version=\"$VERSION\"" "$METAINFO"; then
|
||||
echo "::error file=$METAINFO::Missing <release version=\"$VERSION\"> entry. Add it alongside the VERSION_NAME_BASE bump."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
|
||||
# We disable coverage and desktop builds for PRs to keep feedback fast
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -306,7 +306,7 @@ jobs:
|
||||
run: >
|
||||
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
|
||||
${{ contains(runner.os, 'macOS') && ':desktopApp:packageReleaseUberJarForCurrentOS' || '' }}
|
||||
'-PaboutLibraries.release=true' --no-daemon
|
||||
'-PaboutLibraries.release=true' '-Pdesktop.release=true' --no-daemon
|
||||
|
||||
# jpackage computes the .deb Depends: line from the build host's package names.
|
||||
# Ubuntu 24.04 records time_t64 transition names (libasound2t64, libpng16-16t64)
|
||||
|
||||
4
.skills/compose-ui/strings-index.txt
generated
4
.skills/compose-ui/strings-index.txt
generated
@@ -334,10 +334,14 @@ demo_mode
|
||||
demo_mode_replay
|
||||
desc_node_filter_clear
|
||||
description
|
||||
### DESKTOP ###
|
||||
desktop_notification_title
|
||||
desktop_tray_quit
|
||||
desktop_tray_show
|
||||
desktop_tray_tooltip
|
||||
desktop_update_available_message
|
||||
desktop_update_available_title
|
||||
desktop_update_download
|
||||
details
|
||||
detection_sensor
|
||||
detection_sensor_config
|
||||
|
||||
@@ -358,10 +358,14 @@
|
||||
<string name="demo_mode_replay">Demo Mode (Replay)</string>
|
||||
<string name="desc_node_filter_clear">clear node filter</string>
|
||||
<string name="description">Description</string>
|
||||
<!-- DESKTOP -->
|
||||
<string name="desktop_notification_title">Meshtastic</string>
|
||||
<string name="desktop_tray_quit">Quit</string>
|
||||
<string name="desktop_tray_show">Show Meshtastic</string>
|
||||
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
|
||||
<string name="desktop_update_available_message">Meshtastic Desktop %1$s is ready to download.</string>
|
||||
<string name="desktop_update_available_title">App update available</string>
|
||||
<string name="desktop_update_download">Download update %1$s</string>
|
||||
<string name="details">Details</string>
|
||||
<string name="detection_sensor">Detection Sensor</string>
|
||||
<string name="detection_sensor_config">Detection Sensor Config</string>
|
||||
|
||||
@@ -92,6 +92,11 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="2.8.0" date="2026-07-10">
|
||||
<description>
|
||||
<p>In-app update notifications, native OS notifications, deep-link handling, keyboard shortcuts, window-state persistence, and many stability fixes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="2.7.14" date="2026-06-03">
|
||||
<description>
|
||||
<p>Initial release of Meshtastic Desktop.</p>
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -66,6 +67,7 @@ import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.Path.Companion.toPath
|
||||
import org.jetbrains.compose.resources.decodeToSvgPainter
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.context.startKoin
|
||||
@@ -77,14 +79,19 @@ import org.meshtastic.core.navigation.MultiBackstack
|
||||
import org.meshtastic.core.navigation.SettingsRoute
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.rememberMultiBackstack
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.desktop_tray_quit
|
||||
import org.meshtastic.core.resources.desktop_tray_show
|
||||
import org.meshtastic.core.resources.desktop_tray_tooltip
|
||||
import org.meshtastic.core.resources.desktop_update_available_message
|
||||
import org.meshtastic.core.resources.desktop_update_available_title
|
||||
import org.meshtastic.core.resources.desktop_update_download
|
||||
import org.meshtastic.core.service.MeshServiceOrchestrator
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.util.LocalEventBranding
|
||||
import org.meshtastic.core.ui.util.rememberOpenUrl
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
|
||||
import org.meshtastic.desktop.di.desktopModule
|
||||
@@ -223,6 +230,9 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
|
||||
notificationManager.fallbackNotifications.collect { notification -> trayState.sendNotification(notification) }
|
||||
}
|
||||
|
||||
val openUrl = rememberOpenUrl()
|
||||
val updateInfo = rememberAvailableUpdate(notificationManager)
|
||||
|
||||
WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }
|
||||
|
||||
Tray(
|
||||
@@ -231,6 +241,12 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
|
||||
tooltip = stringResource(Res.string.desktop_tray_tooltip),
|
||||
onAction = { isAppVisible = true },
|
||||
menu = {
|
||||
updateInfo?.let { update ->
|
||||
Item(
|
||||
stringResource(Res.string.desktop_update_download, update.versionName),
|
||||
onClick = { openUrl(update.releaseUrl) },
|
||||
)
|
||||
}
|
||||
Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true })
|
||||
Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication)
|
||||
},
|
||||
@@ -252,6 +268,39 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Update discovery -----
|
||||
|
||||
/**
|
||||
* Checks once per launch whether a newer release is published on GitHub and surfaces it via a native notification.
|
||||
* Release builds only — dev builds carry the bare base version and would match or trail every published release.
|
||||
* Flatpak installs (FLATPAK_ID is set inside the sandbox) update through Flathub instead, so don't point them at
|
||||
* GitHub.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberAvailableUpdate(notificationManager: DesktopNotificationManager): UpdateChecker.UpdateInfo? {
|
||||
val buildConfig = koinInject<BuildConfigProvider>()
|
||||
val httpClient = koinInject<HttpClient>()
|
||||
val updateInfo by
|
||||
produceState<UpdateChecker.UpdateInfo?>(initialValue = null) {
|
||||
if (!buildConfig.isDebug && System.getenv("FLATPAK_ID") == null) {
|
||||
value = UpdateChecker(httpClient).check(buildConfig.versionName)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(updateInfo) {
|
||||
updateInfo?.let { update ->
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getString(Res.string.desktop_update_available_title),
|
||||
message = getString(Res.string.desktop_update_available_message, update.versionName),
|
||||
category = Notification.Category.Service,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return updateInfo
|
||||
}
|
||||
|
||||
// ----- Window bounds persistence -----
|
||||
|
||||
/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Checks GitHub Releases for a newer published desktop build.
|
||||
*
|
||||
* The `releases/latest` endpoint only ever returns a published, non-draft, non-prerelease release, so users are only
|
||||
* pointed at builds that completed the full promotion pipeline (see `.github/workflows/promote.yml`).
|
||||
*/
|
||||
class UpdateChecker(private val httpClient: HttpClient) {
|
||||
|
||||
/** A published release newer than the running build. */
|
||||
data class UpdateInfo(val versionName: String, val releaseUrl: String)
|
||||
|
||||
/** Returns the latest published release when it is newer than [currentVersionName], null otherwise. */
|
||||
suspend fun check(currentVersionName: String): UpdateInfo? = runCatching {
|
||||
val release = Json.parseToJsonElement(httpClient.get(LATEST_RELEASE_URL).bodyAsText()).jsonObject
|
||||
val tag = release["tag_name"]?.jsonPrimitive?.content
|
||||
if (tag != null && isNewer(latest = tag, current = currentVersionName)) {
|
||||
UpdateInfo(
|
||||
versionName = tag.removePrefix("v"),
|
||||
releaseUrl = release["html_url"]?.jsonPrimitive?.content ?: RELEASES_PAGE_URL,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.getOrElse { e ->
|
||||
Logger.d(e) { "Update check skipped: offline, rate-limited, or malformed response" }
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LATEST_RELEASE_URL =
|
||||
"https://api.github.com/repos/meshtastic/Meshtastic-Android/releases/latest"
|
||||
private const val RELEASES_PAGE_URL = "https://github.com/meshtastic/Meshtastic-Android/releases"
|
||||
|
||||
private val VERSION_REGEX = Regex("""(\d+)\.(\d+)\.(\d+)""")
|
||||
|
||||
/**
|
||||
* True when [latest] carries a strictly newer numeric X.Y.Z than [current]. Suffixes (`v`, `-internal.N`) are
|
||||
* ignored; versions that don't parse never trigger an update.
|
||||
*/
|
||||
internal fun isNewer(latest: String, current: String): Boolean {
|
||||
val latestVersion = parse(latest)
|
||||
val currentVersion = parse(current)
|
||||
return latestVersion != null &&
|
||||
currentVersion != null &&
|
||||
compareValuesBy(latestVersion, currentVersion, { it.first }, { it.second }, { it.third }) > 0
|
||||
}
|
||||
|
||||
private fun parse(raw: String): Triple<Int, Int, Int>? =
|
||||
VERSION_REGEX.find(raw)?.destructured?.let { (major, minor, patch) ->
|
||||
// runCatching: \d+ can match components too large for Int; treat those as unparseable.
|
||||
runCatching { Triple(major.toInt(), minor.toInt(), patch.toInt()) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class UpdateCheckerTest {
|
||||
|
||||
@Test
|
||||
fun `newer patch release is an update`() {
|
||||
assertTrue(UpdateChecker.isNewer(latest = "v2.8.1", current = "2.8.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same version is not an update`() {
|
||||
assertFalse(UpdateChecker.isNewer(latest = "v2.8.0", current = "2.8.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older release is not an update`() {
|
||||
assertFalse(UpdateChecker.isNewer(latest = "v2.7.14", current = "2.8.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel suffixes are ignored when comparing`() {
|
||||
assertFalse(UpdateChecker.isNewer(latest = "v2.8.0", current = "2.8.0-internal.24"))
|
||||
assertTrue(UpdateChecker.isNewer(latest = "v2.8.1", current = "2.8.0-internal.24"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `components compare numerically not lexically`() {
|
||||
assertTrue(UpdateChecker.isNewer(latest = "v2.10.0", current = "2.9.9"))
|
||||
assertTrue(UpdateChecker.isNewer(latest = "v3.0.0", current = "2.99.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unparseable versions never trigger an update`() {
|
||||
assertFalse(UpdateChecker.isNewer(latest = "snapshot", current = "2.8.0"))
|
||||
assertFalse(UpdateChecker.isNewer(latest = "v2.8.1", current = "unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized version components never trigger an update`() {
|
||||
assertFalse(UpdateChecker.isNewer(latest = "v99999999999999999999.0.0", current = "2.8.0"))
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,9 @@ modules:
|
||||
's/^\(\s*\)vendor\.set(JvmVendorSpec\.JETBRAINS)/\1\/\/ vendor.set(JvmVendorSpec.JETBRAINS)/'
|
||||
desktopApp/build.gradle.kts
|
||||
# Force Gradle to resolve from the bundled offline-repository ONLY (true offline test).
|
||||
- ./gradlew --offline :desktopApp:packageUberJarForCurrentOS
|
||||
# -Pdesktop.release=true: shipped builds must have IS_DEBUG=false (no debug HTTP
|
||||
# logging, enables the in-app update check). Mirror this in the upstream Flathub manifest.
|
||||
- ./gradlew --offline -Pdesktop.release=true :desktopApp:packageUberJarForCurrentOS
|
||||
- >
|
||||
JAR_FILE=$(find desktopApp/build/compose/jars/ -name "*.jar" -type f | head -1)
|
||||
&& install -Dm755 "$JAR_FILE" /app/lib/meshtastic-desktop.jar
|
||||
|
||||
Reference in New Issue
Block a user