feat: event firmware easter egg with ambient branding (#5354)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-04 14:37:47 -05:00
committed by GitHub
parent 78ff3f599c
commit 82926fd734
10 changed files with 164 additions and 4 deletions
+4
View File
@@ -340,6 +340,10 @@ establishing_session
ethernet_config
ethernet_enabled
ethernet_ip
event_welcome_burning_man
event_welcome_defcon
event_welcome_hamvention
event_welcome_open_sauce
exchange_position
expand_chart
expires
@@ -69,6 +69,7 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
@@ -179,9 +180,12 @@ class MainActivity : AppCompatActivity() {
usbRepository.refreshState()
}
@Suppress("LongMethod")
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
val eventEdition by model.eventEdition.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalEventBranding provides eventEdition,
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
@@ -0,0 +1,60 @@
/*
* 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.core.model
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.event_welcome_burning_man
import org.meshtastic.core.resources.event_welcome_defcon
import org.meshtastic.core.resources.event_welcome_hamvention
import org.meshtastic.core.resources.event_welcome_open_sauce
import org.meshtastic.core.resources.img_event_hamvention
import org.meshtastic.proto.FirmwareEdition
/**
* Display metadata for event-specific firmware editions. Maps a [FirmwareEdition] proto enum to the resources needed to
* render event branding in the UI (toolbar icon, welcome message, etc.).
*/
data class EventEdition(val name: String, val iconRes: DrawableResource?, val welcomeMessageRes: StringResource)
/**
* Maps a [FirmwareEdition] to its [EventEdition] display metadata, or `null` if the edition is not an event (e.g.,
* VANILLA, SMART_CITIZEN) or is unrecognized.
*/
fun FirmwareEdition.toEventEdition(): EventEdition? = when (this) {
FirmwareEdition.HAMVENTION ->
EventEdition(
name = "Hamvention",
iconRes = Res.drawable.img_event_hamvention,
welcomeMessageRes = Res.string.event_welcome_hamvention,
)
FirmwareEdition.OPEN_SAUCE ->
EventEdition(name = "Open Sauce", iconRes = null, welcomeMessageRes = Res.string.event_welcome_open_sauce)
FirmwareEdition.DEFCON ->
EventEdition(name = "DEFCON", iconRes = null, welcomeMessageRes = Res.string.event_welcome_defcon)
FirmwareEdition.BURNING_MAN ->
EventEdition(name = "Burning Man", iconRes = null, welcomeMessageRes = Res.string.event_welcome_burning_man)
else -> null
}
/** Returns `true` if this edition represents an event firmware (value >= 16, excluding DIY). */
fun FirmwareEdition.isEventEdition(): Boolean = toEventEdition() != null
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

@@ -364,6 +364,10 @@
<string name="ethernet_config">Ethernet Options</string>
<string name="ethernet_enabled">Ethernet enabled</string>
<string name="ethernet_ip">Ethernet IP:</string>
<string name="event_welcome_burning_man">Welcome to Burning Man! 🔥</string>
<string name="event_welcome_defcon">Welcome to DEFCON! 💀</string>
<string name="event_welcome_hamvention">Welcome to Hamvention! 🍖📻</string>
<string name="event_welcome_open_sauce">Welcome to Open Sauce! 🔧</string>
<string name="exchange_position">Exchange position</string>
<string name="expand_chart">Expand chart</string>
<string name="expires">Expires</string>
@@ -19,7 +19,11 @@ package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
@@ -28,17 +32,27 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.koin.compose.koinInject
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_meshtastic
import org.meshtastic.core.resources.navigate_back
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.SnackbarManager
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -52,6 +66,7 @@ fun MainAppBar(
onNavigateUp: () -> Unit,
actions: @Composable () -> Unit,
onClickChip: (Node) -> Unit,
brandingContent: @Composable () -> Unit = { EventAwareBranding() },
) {
TopAppBar(
title = {
@@ -84,7 +99,7 @@ fun MainAppBar(
}
}
} else {
{ Icon(imageVector = vectorResource(Res.drawable.ic_meshtastic), contentDescription = null) }
{ brandingContent() }
},
actions = {
TopBarActions(ourNode = ourNode, showNodeChip = showNodeChip, actions = actions, onClickChip = onClickChip)
@@ -92,6 +107,31 @@ fun MainAppBar(
)
}
/** Reads [LocalEventBranding] to show event artwork (with tap → snackbar), or the default Meshtastic logo. */
@Composable
private fun EventAwareBranding() {
val eventEdition = LocalEventBranding.current
val iconRes = eventEdition?.iconRes
if (iconRes != null) {
val scope = rememberCoroutineScope()
val snackbarManager = koinInject<SnackbarManager>()
Image(
painter = painterResource(iconRes),
contentDescription = eventEdition.name,
contentScale = ContentScale.Fit,
modifier =
Modifier.size(32.dp).clip(CircleShape).clickable(role = Role.Button) {
scope.launch {
val message = getString(eventEdition.welcomeMessageRes)
snackbarManager.showSnackbar(message)
}
},
)
} else {
Icon(imageVector = vectorResource(Res.drawable.ic_meshtastic), contentDescription = null)
}
}
@Composable
private fun TopBarActions(
ourNode: Node?,
@@ -0,0 +1,29 @@
/*
* 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.core.ui.util
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.model.EventEdition
/**
* Provides the active [EventEdition] (if any) to the composition tree. When a connected device reports an event
* firmware edition, this local is populated at the app root so that
* [MainAppBar][org.meshtastic.core.ui.component.MainAppBar] can display event branding automatically no per-screen
* wiring needed.
*/
@Suppress("CompositionLocalAllowlist")
val LocalEventBranding = compositionLocalOf<EventEdition?> { null }
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -38,12 +39,15 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.EventEdition
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.toEventEdition
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -120,6 +124,12 @@ class UIViewModel(
val firmwareEdition = meshLogRepository.getMyNodeInfo().map { nodeInfo -> nodeInfo?.firmware_edition }
val eventEdition: StateFlow<EventEdition?> =
combine(firmwareEdition, connectionState) { edition, state ->
if (state is ConnectionState.Connected) edition?.toEventEdition() else null
}
.stateInWhileSubscribed(initialValue = null)
val clientNotification: StateFlow<ClientNotification?> = serviceRepository.clientNotification
fun clearClientNotification(notification: ClientNotification) {
@@ -18,6 +18,7 @@ package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -77,6 +78,7 @@ import org.meshtastic.core.resources.desktop_tray_show
import org.meshtastic.core.resources.desktop_tray_tooltip
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.viewmodel.UIViewModel
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
@@ -300,9 +302,13 @@ private fun ApplicationScope.MeshtasticWindow(
state = windowState,
onPreviewKeyEvent = { event -> handleKeyboardShortcut(event, multiBackstack, ::exitApplication) },
) {
val eventEdition by uiViewModel.eventEdition.collectAsState()
CoilImageLoaderSetup()
AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) {
DesktopMainScreen(uiViewModel, multiBackstack)
CompositionLocalProvider(LocalEventBranding provides eventEdition) {
AppTheme(darkTheme = isDarkTheme, contrastLevel = contrastLevel) {
DesktopMainScreen(uiViewModel, multiBackstack)
}
}
}
}
@@ -35,6 +35,7 @@ import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.toEventEdition
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.connect_radio_for_remote_admin
@@ -189,16 +190,18 @@ private fun FirmwareSection(
SectionCard(title = Res.string.firmware) {
Column {
firmwareEdition?.let { edition ->
val eventEdition = edition.toEventEdition()
val icon =
when (edition) {
FirmwareEdition.VANILLA -> MeshtasticIcons.Icecream
else -> MeshtasticIcons.ForkLeft
}
val displayName = eventEdition?.name ?: edition.name
ListItem(
text = stringResource(Res.string.firmware_edition),
leadingIcon = icon,
supportingText = edition.name,
supportingText = displayName,
copyable = true,
trailingIcon = null,
)