Files
Gadgetbridge/app/build.gradle
T
835884ab16 Add device-agnostic FIT activity export (#6268)
Adds FIT file export for activity tracks, alongside the existing GPX export:

- **`FitExporter`** — builds a valid FIT activity (file_id / session / lap / record, plus length / split / set when present) from an `ActivityTrack` + `ActivitySummaryData`.
- **`AutoFitExporter`** + **`AutoExportFitSettingsActivity`** — opt-in auto-export on sync, mirroring the GPX auto-export setting (per-device or all-devices).
- **"Share FIT File"** action in the workout detail menu.

Export is fed device-agnostically: `GarminWorkoutParser` and the shared model (`ActivityTrack` / `ActivityPoint` / `ActivitySummaryEntries` / `ActivityKind`) carry laps, segments, per-length swim, splits/sets, running dynamics and HR zones.
The Garmin FIT codec (`messages`, `baseTypes`, `NativeFITMessage`, `RecordData`) is extended to encode them.

`AutoFitExporter.doExport(...)` is wired beside `AutoGpxExporter` in the Garmin, CMF, Huawei, BangleJS, Huami and Xiaomi activity paths; `FitImporter` also honours the FIT auto-export toggle.

## Testing

`:app:testMainlineDebugUnitTest` — FIT/export subset green:

- `FitExporterReadmeRoundTripTest` — round-trips the curated README sample **and** the full `Activity/<year>/` corpus for years ≥ 2015 from  https://github.com/ThomasKuehne/FIT-test-files (3482 files): parse → export → re-parse, asserting GPS/altitude/HR/speed/cadence/distance/power/temp and session aggregates survive.
- `FitExporterPaceTest`, `BaseTypeEncodeRoundTripTest`, `GarminSportTest`, `GPXExporterTest`, `FitImporterTest`, `GpxRouteFileConverterTest`, `FitWeatherTest`.

Thanks a lot to @ThomasKuehne for the initial review

Co-authored-by: Dany Mestas <94061157+DanyPM@users.noreply.github.com>
Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6268
2026-06-21 23:35:20 +02:00

359 lines
12 KiB
Groovy

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.nio.file.Files
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.protobuf)
}
tasks.withType(Test).configureEach {
systemProperty "MiFirmwareDir", System.getProperty("MiFirmwareDir", null)
systemProperty "logback.configurationFile", System.getProperty("user.dir", null) + "/app/src/main/assets/logback.xml"
systemProperty "GB_LOGFILES_DIR", Files.createTempDirectory("gblog").toString()
}
final def getVersionCode = { ->
try {
final def commitCount = providers.exec {
commandLine('git', 'rev-list', 'HEAD', '--count')
}.standardOutput.asText.get().trim()
return Integer.valueOf(commitCount)
} catch (ignored) {
return null
}
}
tasks.register("buildGitChangelog"){
final def changelogFile = new File("${project.rootDir}/app/build/generated/res/changelog/xml/changelog_git.xml")
inputs.file(project.rootProject.file(".git/HEAD"))
outputs.file(changelogFile)
doLast {
def allCommits
try {
allCommits = providers.exec {
commandLine('git', 'log', '--pretty=format:%h %s')
}.standardOutput.asText.get()
} catch (ignored) {
logger.warn("Skipping git changelog: git is not available")
changelogFile.getParentFile().mkdirs()
changelogFile.write(groovy.xml.XmlUtil.serialize(new Node(null, 'changelog')))
return
}
def commitVersionCode = getVersionCode()
def includedCommits = 0
def includedTranslations = 0
final def changelogNode = new Node(null, 'changelog')
allCommits.trim().eachLine { final line ->
if (includedCommits > 100) {
return true
}
final def (commitHash, commitMessage) = line.split(" ", 2)
if (commitMessage.contains("Translated using Weblate")) {
if (includedTranslations == 0) {
final def releaseNode = new Node(changelogNode, 'release', [version: commitHash, versioncode: commitVersionCode--])
def _ = new Node(releaseNode, 'change', [:], "Sync translations from Weblate")
}
includedTranslations++
return true
}
final def releaseNode = new Node(changelogNode, 'release', [version: commitHash, versioncode: commitVersionCode--])
def _ = new Node(releaseNode, 'change', [:], commitMessage)
includedCommits++
}
changelogFile.getParentFile().mkdirs()
changelogFile.write(groovy.xml.XmlUtil.serialize(changelogNode))
}
}
final def getGitHashShort = { ->
try {
return providers.exec {
commandLine('git', 'rev-parse', '--short', 'HEAD')
}.standardOutput.asText.get().trim()
} catch (ignored) {
return null
}
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(21)
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
android {
compileOptions {
coreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
namespace = 'nodomain.freeyourgadget.gadgetbridge'
compileSdk {
version = release(36) {
it.minorApiLevel = 1
}
}
buildToolsVersion = '36.1.0'
defaultConfig {
applicationId "nodomain.freeyourgadget.gadgetbridge"
// Note: update toolchain.languageVersion when changing targetSdk
//noinspection OldTargetApi
targetSdk = 34
// Note: update sourceCompatibility and targetCompatibility when changing minSdk
minSdk = 23
// Note: always bump BOTH versionCode and versionName!
versionName "0.91.1"
versionCode 248
vectorDrawables.useSupportLibrary = true
buildConfigField "String", "GIT_HASH_SHORT", "\"${getGitHashShort()}\""
buildConfigField "boolean", "INTERNET_ACCESS", "false"
manifestPlaceholders = [
appAuthRedirectScheme: "${applicationId}"
]
}
signingConfigs {
nightly {
if (System.getProperty("nightly_store_file") != null) {
storeFile file(System.getProperty("nightly_store_file"))
storePassword System.getProperty("nightly_store_password")
keyAlias System.getProperty("nightly_key_alias")
keyPassword System.getProperty("nightly_key_password")
}
}
}
flavorDimensions += "device_type"
productFlavors {
mainline {
// Ensure that when starting from scratch, 'mainline' is selected, not 'banglejs'
getIsDefault().set(true)
// the default build product flavor
dimension "device_type"
//applicationIdSuffix ""
//versionNameSuffix ""
}
banglejs {
dimension "device_type"
applicationId "com.espruino.gadgetbridge"
applicationIdSuffix ".banglejs"
versionNameSuffix "-banglejs"
buildConfigField "boolean", "INTERNET_ACCESS", "true"
// Note: app/src/banglejs/AndroidManifest.xml contains some extra permissions
}
}
sourceSets {
main {
java.srcDirs += "build/generated/sources/gbdao"
res.srcDirs += "build/generated/res/changelog"
}
test {
// The optional FIT round-trip corpus (FitExporterReadmeRoundTripTest) is read
// by filesystem path, not the classpath, and is not committed. Exclude it from
// the test resources so Gradle does not copy tens of thousands of files into
// the unit-test runtime on every build.
resources {
exclude "FIT-test-files-main/**"
}
}
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
nightly {
applicationIdSuffix ".nightly"
versionNameSuffix "-${getGitHashShort}"
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
minifyEnabled true
debuggable false
if (System.getProperty("nightly_store_file") != null) {
signingConfig = signingConfigs.nightly
} else {
signingConfig = signingConfigs.debug
}
}
nopebble {
applicationIdSuffix ".nightly_nopebble"
versionNameSuffix "-${getGitHashShort}"
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
minifyEnabled true
debuggable false
if (System.getProperty("nightly_store_file") != null) {
signingConfig = signingConfigs.nightly
} else {
signingConfig = signingConfigs.debug
}
}
applicationVariants.all { variant ->
variant.resValue "string", "applicationId", variant.applicationId
if (variant.buildType.name == 'nightly' || variant.buildType.name == 'nopebble') {
variant.outputs.all {
setVersionCodeOverride(getVersionCode())
//setVersionNameOverride(getGitHashShort())
setVersionNameOverride(variant.versionName)
outputFileName = "${applicationId}_${variant.versionName}.apk"
}
}
}
}
lint {
abortOnError = true
lintConfig = file("${rootDir}/app/src/main/lint.xml")
// If true, generate an HTML report (with issue explanations, sourcecode, etc)
htmlReport = true
// Optional path to report (default will be lint-results.html in the builddir)
htmlOutput = layout.buildDirectory.file("reports/lint/lint.html").get().asFile
// Ignore checks present in the snapshot
baseline = file("lint-baseline.xml")
}
testOptions {
unitTests {
returnDefaultValues = true
includeAndroidResources = true
}
}
buildFeatures {
aidl = true
buildConfig = true
viewBinding = true
}
}
dependencies {
coreLibraryDesugaring libs.desugar.jdk.libs
implementation libs.androidx.constraintlayout
implementation libs.bundles.androidx.camera
testImplementation libs.junit
testImplementation libs.mockito
testImplementation libs.robolectric
testImplementation libs.hamcrest
implementation libs.androidx.core.ktx
implementation platform(libs.kotlin.bom)
implementation libs.kotlin.stdlib
implementation libs.androidx.appcompat
implementation libs.androidx.preference.ktx
implementation libs.androidx.cardview
implementation libs.androidx.recyclerview
implementation libs.androidx.legacy.support.v4
implementation libs.androidx.gridlayout
implementation libs.androidx.palette.ktx
implementation libs.androidx.activity.ktx
implementation libs.androidx.fragment.ktx
implementation libs.androidx.viewpager2
implementation libs.androidx.work.runtime.ktx
implementation libs.androidx.lifecycle.process
implementation libs.androidx.security.crypto
implementation libs.androidx.browser
implementation libs.material
implementation libs.flexbox
implementation libs.gson
implementation libs.dfu
implementation libs.logback.android
implementation libs.slf4j.api
implementation libs.mpandroidchart
implementation libs.durationformatter
implementation libs.ckchangelog
implementation libs.solarpositioning
implementation libs.cbor
// use pristine greendao instead of our custom version, since our custom jitpack-packaged
// version contains way too much and our custom patches are in the generator only.
implementation libs.greendao
implementation libs.apache.commons.lang3
implementation libs.cyanogenmod.sdk
implementation libs.colorpicker
implementation libs.bundles.android.emojify
implementation libs.protobuf.lite
implementation libs.okhttp
implementation libs.msgpack
implementation libs.searchpreference
implementation libs.bundles.mapsforge
implementation libs.androidsvg
implementation libs.jsoup
// Bouncy Castle is included directly in GB, to avoid pulling the entire dependency
// It's included in the org.bouncycastle.shaded package, to fix conflicts with roboelectric
//implementation 'org.bouncycastle:bcpkix-jdk18on:1.76'
//implementation 'org.bouncycastle:bcprov-jdk18on:1.76'
// Android SDK bundles org.json, but we need an actual implementation to replace the stubs in tests
testImplementation libs.json
// Needed for Armenian transliteration
implementation libs.ahocorasick
// Android Health Connect
implementation libs.guava // This is needed because CameraActivity.java line 41 doesn't have a ListenableFuture package
implementation libs.androidx.health.connect.client
}
preBuild.dependsOn(":GBDaoGenerator:genSources")
preBuild.dependsOn(tasks.buildGitChangelog)
gradle.beforeProject {
preBuild.dependsOn(":GBDaoGenerator:genSources")
}
tasks.register('cleanGenerated', Delete) {
delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/entities') {
include '**/*.java'
exclude '**/Abstract*.java'
exclude '**/GenericActivitySample.java'
}
}
tasks.clean.dependsOn(tasks.cleanGenerated)
protobuf {
protoc {
artifact = libs.protoc.get()
}
generateProtoTasks {
all().each { final task ->
task.builtins {
java {
option 'lite'
}
}
}
}
}