diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 48a27afb90..d1fa993b3f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -371,7 +371,7 @@ jobs:
cache_read_only: 'true'
# Uses an isolated Gradle user home so every artifact actually traverses the
- # network — meshtastic.flatpak-ops captures URLs via BuildOperationListener and
+ # network — the flatpak-sources plugin captures URLs via BuildOperationListener and
# only sees ExternalResourceReadBuildOperation events for cache misses. With the
# shared cache, downloads would be skipped and the manifest would be (nearly) empty.
# We drive packageUberJarForCurrentOS — the same task the in-flatpak build invokes —
@@ -381,11 +381,10 @@ jobs:
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- -I gradle/init-scripts/flatpak-ops.init.gradle.kts
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
- run: cp build/flatpak-ops-sources.json flatpak-sources.json
+ run: cp build/flatpak-sources.json flatpak-sources.json
- name: List Flatpak source files
run: ls -l flatpak-sources.json
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 94ea28f9e1..906cc4da12 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -582,11 +582,10 @@ jobs:
run: >
./gradlew --no-build-cache --no-configuration-cache
-Dgradle.user.home=${{ runner.temp }}/flatpak-gradle-home
- -I gradle/init-scripts/flatpak-ops.init.gradle.kts
:desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
- name: Stage manifest
- run: cp build/flatpak-ops-sources.json flatpak-sources.json
+ run: cp build/flatpak-sources.json flatpak-sources.json
- run: ls -lah flatpak-sources.json
diff --git a/.github/workflows/verify-flatpak.yml b/.github/workflows/verify-flatpak.yml
new file mode 100644
index 0000000000..9cf061c1a4
--- /dev/null
+++ b/.github/workflows/verify-flatpak.yml
@@ -0,0 +1,111 @@
+name: Verify Flatpak Offline Build
+
+on:
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'scripts/verify-flatpak/**'
+ - 'build.gradle.kts'
+ - 'settings.gradle.kts'
+ - '.github/workflows/verify-flatpak.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: flatpak-verify-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ generate-sources:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: gradle/actions/setup-gradle@v4
+
+ - name: Generate flatpak-sources.json
+ run: |
+ ./gradlew --no-build-cache --no-configuration-cache \
+ -Dgradle.user.home="$RUNNER_TEMP/flatpak-gradle-home" \
+ :desktopApp:packageUberJarForCurrentOS :captureFlatpakSources
+ cp build/flatpak-sources.json flatpak-sources.json
+ echo "### Flatpak Sources Summary" >> "$GITHUB_STEP_SUMMARY"
+ echo "- URLs captured: $(jq length flatpak-sources.json)" >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: flatpak-sources
+ path: flatpak-sources.json
+
+ build-flatpak:
+ needs: generate-sources
+ runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
+ timeout-minutes: 45
+ strategy:
+ matrix:
+ arch: [x86_64, aarch64]
+ fail-fast: false
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ submodules: recursive
+
+ - uses: actions/download-artifact@v4
+ with:
+ name: flatpak-sources
+
+ - name: Clone vid's flatpak repo
+ run: |
+ git clone --depth 1 --recurse-submodules \
+ https://github.com/vidplace7/org.meshtastic.desktop.git \
+ "$RUNNER_TEMP/org.meshtastic.desktop"
+
+ - name: Wire overlay manifest + sources
+ run: |
+ cp scripts/verify-flatpak/desktop-offline.yaml \
+ "$RUNNER_TEMP/org.meshtastic.desktop/org.meshtastic.desktop.yaml"
+ cp flatpak-sources.json \
+ "$RUNNER_TEMP/org.meshtastic.desktop/flatpak-sources.json"
+ rsync -a --delete \
+ --exclude='/build/' --exclude='/.gradle/' \
+ --exclude='*/build/' --exclude='*/.gradle/' \
+ --exclude='/.idea/' --exclude='/local.properties' \
+ ./ "$RUNNER_TEMP/org.meshtastic.desktop/meshtastic-android/"
+
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq flatpak flatpak-builder
+ flatpak remote-add --user --if-not-exists flathub \
+ https://dl.flathub.org/repo/flathub.flatpakrepo
+
+ - name: Build flatpak offline
+ working-directory: ${{ runner.temp }}/org.meshtastic.desktop
+ run: |
+ flatpak-builder --user --repo=repo --install-deps-from=flathub \
+ --force-clean builddir org.meshtastic.desktop.yaml
+
+ - name: Export .flatpak bundle
+ working-directory: ${{ runner.temp }}/org.meshtastic.desktop
+ env:
+ ARCH: ${{ matrix.arch }}
+ run: |
+ flatpak build-bundle repo org.meshtastic.desktop.${ARCH}.flatpak \
+ org.meshtastic.desktop \
+ --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
+ echo "### ✅ Offline Flatpak build succeeded ($ARCH)" >> "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: meshtastic-desktop-flatpak-${{ matrix.arch }}
+ path: ${{ runner.temp }}/org.meshtastic.desktop/org.meshtastic.desktop.${{ matrix.arch }}.flatpak
diff --git a/.gitignore b/.gitignore
index b491269206..18fe71d64b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -85,3 +85,4 @@ flatpak-sources-*.json
flatpak-sources.json
offline-repository/
.claude/
+build-scan-*.scan
diff --git a/build-logic/flatpak-ops/build.gradle.kts b/build-logic/flatpak-ops/build.gradle.kts
deleted file mode 100644
index 04ed7143a9..0000000000
--- a/build-logic/flatpak-ops/build.gradle.kts
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * 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 .
- */
-
-import org.jetbrains.kotlin.gradle.dsl.JvmTarget
-
-plugins {
- `kotlin-dsl`
- alias(libs.plugins.spotless)
- alias(libs.plugins.detekt)
-}
-
-group = "org.meshtastic.flatpakops"
-
-java {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
-}
-
-kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } }
-
-dependencies {
- implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
- detektPlugins(libs.detekt.formatting)
-}
-
-tasks {
- validatePlugins {
- enableStricterValidation = true
- failOnWarning = true
- }
-}
-
-spotless {
- ratchetFrom("origin/main")
- kotlin {
- target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt")
- targetExclude("**/build/**/*.kt")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get())
- .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
- licenseHeaderFile(rootProject.file("../config/spotless/copyright.kt"))
- }
- kotlinGradle {
- target("**/*.gradle.kts")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get())
- .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
- licenseHeaderFile(rootProject.file("../config/spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
- }
-}
-
-detekt {
- toolVersion = libs.versions.detekt.get()
- config.setFrom(rootProject.file("../config/detekt/detekt.yml"))
- buildUponDefaultConfig = true
- allRules = false
- source.setFrom(files("src/main/java", "src/main/kotlin"))
-}
-
-gradlePlugin {
- plugins {
- register("meshtasticFlatpakOps") {
- id = "meshtastic.flatpak-ops"
- implementationClass = "org.meshtastic.flatpakops.FlatpakOpsPlugin"
- }
- }
-}
diff --git a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt b/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
deleted file mode 100644
index 9ed009c38a..0000000000
--- a/build-logic/flatpak-ops/src/main/kotlin/org/meshtastic/flatpakops/FlatpakOpsPlugin.kt
+++ /dev/null
@@ -1,254 +0,0 @@
-/*
- * 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 .
- */
-package org.meshtastic.flatpakops
-
-import groovy.json.JsonOutput
-import groovy.json.JsonSlurper
-import org.gradle.api.Plugin
-import org.gradle.api.Project
-import org.gradle.api.internal.project.ProjectInternal
-import org.gradle.internal.operations.BuildOperationDescriptor
-import org.gradle.internal.operations.BuildOperationListener
-import org.gradle.internal.operations.BuildOperationListenerManager
-import org.gradle.internal.operations.OperationFinishEvent
-import org.gradle.internal.operations.OperationIdentifier
-import org.gradle.internal.operations.OperationProgressEvent
-import org.gradle.internal.operations.OperationStartEvent
-import org.gradle.internal.resource.ExternalResourceReadBuildOperationType
-import java.io.File
-import java.net.URI
-import java.security.MessageDigest
-import java.util.concurrent.ConcurrentHashMap
-
-/**
- * Captures every external resource URL Gradle reads via the internal BuildOperationListener API and emits a
- * Flathub-compliant flatpak-sources.json at build finish.
- *
- * URL is authoritative (taken straight from the build op); the on-disk file is found via Gradle's files-2.1 layout,
- * with a Module-Metadata-aware fallback for jars whose cache name differs from their URL name; SHA-256 is computed from
- * that exact file.
- *
- * Internal APIs touched (acceptable trade-off; same path flatpak-gradle-generator uses):
- * - org.gradle.internal.operations.BuildOperationListener / BuildOperationListenerManager
- * - org.gradle.internal.resource.ExternalResourceReadBuildOperationType
- * - org.gradle.api.internal.project.ProjectInternal (for .services)
- */
-class FlatpakOpsPlugin : Plugin {
-
- override fun apply(target: Project) {
- check(target == target.rootProject) { "meshtastic.flatpak-ops must be applied to the root project" }
-
- // Prefer the URL set populated by gradle/init-scripts/flatpak-ops.init.gradle.kts.
- // The init script attaches its listener BEFORE any plugin/project resolution, so it
- // captures bootstrap downloads (kotlin-dsl plugin marker, build-logic deps) that a
- // listener registered here would miss. If the init script wasn't passed via -I, we
- // fall back to a locally-attached listener — incomplete for build-logic deps but
- // useful for developer debugging. No buildFinished cleanup here: this plugin loads in
- // every normal build, and gradle.buildFinished is incompatible with the configuration
- // cache. The fallback is rarely used and the per-build leak is benign.
- @Suppress("UNCHECKED_CAST")
- val capturedUrls: MutableSet =
- (target.gradle.extensions.findByName("flatpakOpsCapturedUrls") as? MutableSet)
- ?: ConcurrentHashMap.newKeySet().also { fallback ->
- val manager = (target as ProjectInternal).services.get(BuildOperationListenerManager::class.java)
- manager.addListener(OpListener(fallback))
- target.logger.warn(
- "flatpak-ops: init script not loaded; build-logic bootstrap URLs will be missing. " +
- "Pass -I gradle/init-scripts/flatpak-ops.init.gradle.kts for a complete manifest.",
- )
- }
-
- val outputProvider = target.layout.buildDirectory.file("flatpak-ops-sources.json")
-
- target.tasks.register("captureFlatpakSources") {
- group = "flatpak"
- description = "Emit flatpak-sources.json from URLs captured via BuildOperationListener."
- outputs.upToDateWhen { false }
- // Order after the resolution-emitting tasks so we don't snapshot capturedUrls before
- // their downloads happen. mustRunAfter is conditional — only the scheduled task enforces.
- mustRunAfter(":desktopApp:assemble", ":desktopApp:packageUberJarForCurrentOS")
- val proj = target
- val urlsRef = capturedUrls
- val outFile = outputProvider
- doLast { writeSources(proj, urlsRef.toList(), outFile.get().asFile) }
- }
- }
-
- private class OpListener(private val urls: MutableSet) : BuildOperationListener {
- override fun started(op: BuildOperationDescriptor, e: OperationStartEvent) = Unit
-
- override fun progress(id: OperationIdentifier, e: OperationProgressEvent) = Unit
-
- override fun finished(op: BuildOperationDescriptor, e: OperationFinishEvent) {
- val details = op.details as? ExternalResourceReadBuildOperationType.Details ?: return
- if (e.failure != null) return
- // No host/scheme filtering here: non-Maven URLs (distribution zips, repo listings, etc.)
- // naturally drop out in writeSources() when locateCacheFile() can't find them under
- // files-2.1. Keeping this listener permissive avoids hardcoding repo allowlists.
- urls.add(details.location)
- }
- }
-
- private fun writeSources(project: Project, urls: List, output: File) {
- val filesRoot = File(project.gradle.gradleUserHomeDir, "caches/modules-2/files-2.1")
- val entries: List