build(train): Train is released as Learn is, with a key of its own, beside it in the F-Droid repository (ADR 0030, D58)

apps/train/release.dart configures the release tool for Train's APK on
train.halkyon.eu; its release builds sign with its own key when the tool
hands it over. Its F-Droid listing declares NonFreeDep for Polar's SDK and
NonFreeNet; the repository's script copies each app's icon. The key's
certificate is pinned once the key is made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 16:40:12 +01:00
commit 48951c1e1a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 180 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "app.halkyon.train"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@ -30,13 +29,46 @@ android {
versionName = flutter.versionName
}
// The app's own key signs release builds when tool/release.dart hands it over
// through the environment, as Learn's does: the keystore's path and the key's
// alias, and the password asked for at each release, never written down. Other
// release builds are signed with the debug key, so `flutter run --release` works
// anywhere; the release tool releases no APK signed otherwise.
val releaseKeystore = System.getenv("HALKYON_ANDROID_KEYSTORE")
signingConfigs {
if (releaseKeystore != null) {
create("release") {
storeFile = file(releaseKeystore)
keyAlias = System.getenv("HALKYON_ANDROID_KEY_ALIAS")
// A PKCS #12 keystore has one password, for the store and its key.
storePassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
keyPassword = System.getenv("HALKYON_ANDROID_KEYSTORE_PASSWORD")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig =
signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
}
}
// The native libraries go into the APK compressed, as Learn's: one APK serves
// every phone, holding them for three architectures, and Android unpacks the
// phone's own as it installs the app.
packaging {
jniLibs {
useLegacyPackaging = true
}
}
// Leaves out the list of dependencies Gradle would otherwise add to the APK,
// encrypted for Google Play alone; F-Droid's and others' scanners flag it.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
kotlin {

35
apps/train/release.dart Normal file
View file

@ -0,0 +1,35 @@
// How Halkyon Train is released (`dart run tool/release.dart train`): the
// release tool's configuration of it, registered in tool/src/release/apps.dart.
import '../../tool/src/release/app_release.dart';
/// Halkyon Train's releases: the APK alone (Train is Android's: Health
/// Connect, the recorder's foreground service, the Polar Loop), connecting
/// to train.halkyon.eu, published on code.halkyon.app and in the project's
/// F-Droid repository beside Learn.
const trainRelease = AppRelease(
id: 'train',
name: 'Halkyon Train',
applicationId: 'app.halkyon.train',
directory: 'apps/train',
flutterPackage: 'apps/train/halkyon_train_flutter',
artifactName: 'halkyon-train',
serverUrl: 'https://train.halkyon.eu/',
forgejo: 'https://code.halkyon.app',
repository: 'project-halkyon/halkyon',
// A PKCS #12 keystore of its own outside the repository (also in the
// maintainer's password manager), as Learn's: it is the app's identity, and
// lost, installed apps can't be updated.
android: AndroidSigning(
certificateFile: 'tool/release/train-android-certificate.sha256',
keystoreVariable: 'HALKYON_TRAIN_ANDROID_KEYSTORE',
defaultKeystore: '.local/share/halkyon-train/android-release.p12',
aliasVariable: 'HALKYON_TRAIN_ANDROID_KEY_ALIAS',
defaultAlias: 'halkyon-train',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo',
),
);

View file

@ -1447,6 +1447,16 @@ Strava. What was found, 27 Oct:
when they are written; a reset of the account's keys (ADR 0017) renames every row,
as it leaves what was sealed unreadable anyway. Train had no release, so no row
needed moving.
- **D58 Train's releases** (recommended, the developer's; 30 Oct): as Learn's, from
`tool/release.dart train` on the developer's laptop (`apps/train/release.dart`), the
APK alone (Train is Android's), connecting to train.halkyon.eu, published on
code.halkyon.app and in the project's F-Droid repository beside Learn, signed with a
key of Train's own (a PKCS #12 keystore at `~/.local/share/halkyon-train/`, its
certificate pinned in `tool/release/train-android-certificate.sha256`): one key for
both apps would let either's build pass for the other's update. Its listing
(`tool/release/fdroid/metadata/app.halkyon.train.yml`) declares `NonFreeDep` (Polar's
SDK, D36) and `NonFreeNet` (Polar's firmware service, the direct way of D40, and the
server's SSPL, as Learn's says).
## Consequences

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:test/test.dart';
import '../../apps/learn/release.dart';
import '../../apps/train/release.dart';
import '../../tool/src/release/apps.dart';
import '../../tool/src/release/version.dart';
import 'fixtures.dart';
@ -74,4 +75,37 @@ void main() {
contains('name: ${learnRelease.snap!.name}\n'),
);
});
test("Train's release is registered, sound, names files that exist, and "
'has a key of its own', () {
expect(releasedApps['train'], same(trainRelease));
expect(trainRelease.problems(), isEmpty);
for (final path in [
trainRelease.pubspec,
trainRelease.android.certificateFile,
...trainRelease.releaseFiles,
]) {
expect(
FileSystemEntity.typeSync(path),
isNot(FileSystemEntityType.notFound),
reason: path,
);
}
expect(trainRelease.snap, isNull);
expect(
trainRelease.android.certificateFile,
isNot(learnRelease.android.certificateFile),
);
expect(
trainRelease.android.defaultKeystore,
isNot(learnRelease.android.defaultKeystore),
);
expect(
File(
'${trainRelease.fdroid!.listing}/metadata/'
'${trainRelease.applicationId}.yml',
).readAsStringSync(),
contains('NonFreeDep'),
);
});
}

View file

@ -82,12 +82,16 @@ mkdir -p "$working/repo"
cp -r tool/release/fdroid/. "$working/"
# fdroid refuses a config.yml others can read.
chmod 600 "$working/config.yml"
# Learn's own icon in the F-Droid app, the snap's (the repository's is the
# kingfisher, halkyon.png in tool/release/fdroid/): fdroid can't find the launcher
# icon in a release APK, whose build shortens the resources' paths (res/9w.png
# rather than res/mipmap-hdpi/ic_launcher.png; fdroidserver issue 885).
mkdir -p "$working/metadata/app.halkyon.learn/en-US"
cp snap/gui/halkyon-learn.png "$working/metadata/app.halkyon.learn/en-US/icon.png"
# Each app's own icon in the F-Droid app (the repository's is the kingfisher,
# halkyon.png in tool/release/fdroid/): Learn's the snap's, Train's its launcher's
# source. fdroid can't find the launcher icon in a release APK, whose build shortens
# the resources' paths (res/9w.png rather than res/mipmap-hdpi/ic_launcher.png;
# fdroidserver issue 885).
for icon in app.halkyon.learn:snap/gui/halkyon-learn.png \
app.halkyon.train:apps/train/halkyon_train_flutter/assets/icon/halkyon_train_icon.png; do
mkdir -p "$working/metadata/${icon%%:*}/en-US"
cp "${icon#*:}" "$working/metadata/${icon%%:*}/en-US/icon.png"
done
if [ -n "$apk" ]; then
[ -f "$apk" ] || fail "No APK at $apk."
cp "$apk" "$working/repo/"

View file

@ -1,8 +1,12 @@
# The anti-features the repository's apps declare, as the main F-Droid
# repository defines them: names, descriptions and icons from fdroiddata
# (https://gitlab.com/fdroid/fdroiddata, config/antiFeatures.yml and the icons
# beside it). The two icons are F-Droid's, under the GNU AGPL v3, fdroiddata's
# beside it). The icons are F-Droid's, under the GNU AGPL v3, fdroiddata's
# licence. fdroid copies them into the repository's icons.
NonFreeDep:
name: Non-Free Dependencies
description: This app depends on other non-free apps
icon: ic_antifeature_nonfreedep.png
NonFreeNet:
name: Non-Free Network Services
description: This app promotes or depends entirely on a non-free network service

View file

@ -1,3 +1,5 @@
# The categories the repository's apps are listed in.
Education:
name: Education
Sports & Health:
name: Sports & Health

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -0,0 +1,40 @@
# Halkyon Train's listing in the F-Droid repository, and its anti-features with their
# reasons (Halkyon Train's ADR 0030, D36).
Categories:
- Sports & Health
License: AGPL-3.0-or-later
AuthorName: Halkyon
AuthorEmail: support@halkyon.app
WebSite: https://halkyon.app/train
SourceCode: https://code.halkyon.app/project-halkyon/halkyon
AntiFeatures:
NonFreeDep: It reads the Polar Loop through Polar's BLE SDK, which isn't free
software; the app's licence carries a permission to link it.
NonFreeNet: The Polar Loop's firmware comes from Polar's firmware service, and the
server's licence (SSPL) isn't a free licence by F-Droid's definition.
Name: Halkyon Train
Summary: Record runs, rides, walks and rows, sealed and synced between your devices
Description: |-
Halkyon Train records your runs, walks, hikes, rides and rows: the track by GPS,
heart rate from a chest strap or the Polar Loop, a bike's speed, cadence and
power sensors, and a rowing machine's strokes. It pauses by itself when you
stop, announces each kilometre, and follows interval plans and routes you plan
on the map, turn by turn.
Music from your Jellyfin server plays in time with your steps. Workouts go to
Health Connect and out as GPX, TCX or FIT.
The Polar Loop's days and nights (steps, sleep, Nightly Recharge, heart rate
around the clock) are read from the band on the phone, never through Polar's
cloud.
Everything is sealed on your phone with your account's keys before it reaches
the instance, which can't read it. You sign in with SimpleLogin, by Proton, or
with an email address, a password and an authenticator app. It connects to
train.halkyon.eu, the project's instance, or to any other: everything an
instance needs is in the repository, to run your own. The app is AGPL-3.0 and
the server SSPL-1.0, so all of its code is public.
Questions and reports: support@halkyon.app

View file

@ -0,0 +1,2 @@
# The SHA-256 of the certificate that signs Halkyon Train's Android releases (key alias
# halkyon-train), as keytool prints it. tool/release.dart releases no APK signed otherwise.

View file

@ -2,9 +2,13 @@
library;
import '../../../apps/learn/release.dart';
import '../../../apps/train/release.dart';
import 'app_release.dart';
/// Every app with a release configuration, by the id the tool takes. A new
/// app's configuration lives beside it (`apps/<id>/release.dart`) and is
/// added here.
final releasedApps = <String, AppRelease>{learnRelease.id: learnRelease};
final releasedApps = <String, AppRelease>{
learnRelease.id: learnRelease,
trainRelease.id: trainRelease,
};