build(todo): Todo deployable beside Learn's server, and released as an APK, on F-Droid and as a snap (ADR 0033, step 5)

deploy/compose.todo.yaml adds Todo's server at todo.halkyon.eu, a member
of Learn's register, its database halkyon_todo in the instance's
PostgreSQL, its image built as Train's. redeploy.sh and backup.sh go over
every app server .env switches on (train, todo). Each compose file names
its server to the register in a variable of its own
(INSTANCE_MEMBER_TRAIN, INSTANCE_MEMBER_TODO), which halkyon_base adds to
INSTANCE_MEMBERS, each address once, as a second compose file's default
would have replaced Train's.

apps/todo/release.dart configures the release tool for Todo's APK and
snap: a key of its own (pinned once made; until then nothing is
released), the F-Droid listing (Time, NonFreeNet), and the snap from
apps/todo/snap/, which the tool builds in a clone of the repository at the
tag with that folder in place of snap/, as snapcraft builds one recipe a
folder and the root's is Learn's. What's new for 0.1.0 in English and
German, now shown. Android's backup and transfer are off, as Train's
(D53): the reminders scheduled keep their tasks' titles unsealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 15:35:40 +01:00
commit e61d3abb4f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 963 additions and 107 deletions

File diff suppressed because one or more lines are too long

View file

@ -18,7 +18,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "app.halkyon.todo"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@ -32,13 +31,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 and Train's do: 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 {

View file

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/>
@ -8,10 +9,19 @@
them off; without, Android may bring a reminder a few minutes late). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- No backup and no transfer to another phone (ADR 0033, step 5, as
Train's ADR 0030, D53): the reminders scheduled, with their tasks'
titles, are kept unsealed. Through Android 11 allowBackup and
fullBackupContent stop both, from 12 on the extraction rules stop the
transfer. -->
<application
android:label="Halkyon Todo"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
tools:targetApi="s">
<activity
android:name=".MainActivity"
android:exported="true"

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Nothing of Todo's goes to a backup or to another phone, from Android 12
on (ADR 0033, step 5, as Train's ADR 0030, D53): allowBackup="false"
stops the backup, and only these rules stop the transfer. What Todo keeps
unsealed (the reminders it has scheduled, with their tasks' titles) stays
on this phone; what is sealed comes back from the instance on the next. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="."/>
<exclude domain="file" path="."/>
<exclude domain="database" path="."/>
<exclude domain="sharedpref" path="."/>
<exclude domain="external" path="."/>
<exclude domain="device_root" path="."/>
<exclude domain="device_file" path="."/>
<exclude domain="device_database" path="."/>
<exclude domain="device_sharedpref" path="."/>
</cloud-backup>
<device-transfer>
<exclude domain="root" path="."/>
<exclude domain="file" path="."/>
<exclude domain="database" path="."/>
<exclude domain="sharedpref" path="."/>
<exclude domain="external" path="."/>
<exclude domain="device_root" path="."/>
<exclude domain="device_file" path="."/>
<exclude domain="device_database" path="."/>
<exclude domain="device_sharedpref" path="."/>
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1 @@
- Die erste Version von Halkyon Todo: Aufgaben im Eingang und in Projekten mit Abschnitten, Heute und Demnächst, wiederkehrende Aufgaben und Erinnerungen, jedes Projekt als Liste, Board oder Kalender, alles versiegelt.

View file

@ -0,0 +1 @@
- The first release of Halkyon Todo: tasks in an Inbox and in projects with sections, Today and Upcoming, repeating tasks and reminders, each project as a list, a board or a calendar, everything sealed.

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/src/app/licences.dart';
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
@ -28,12 +29,23 @@ Future<void> main() async {
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
TodoApp(
choice: instances,
// Only an instance of Halkyon Todo: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'todo'),
open: (instance) => TodoDependencies.create(instance, device),
// What's new in each release (Train's ADR 0030, D61), from
// assets/whats_new/, shown once after an update.
WhatsNewScope(
whatsNew: WhatsNew(
AssetWhatsNewSource(rootBundle),
FileWhatsNewStore(File(p.join(device.support, 'whats_new.json'))),
version: device.appVersion,
),
// An instance chosen before: the app ran on this device already.
usedBefore: instances.current != null,
child: TodoApp(
choice: instances,
// Only an instance of Halkyon Todo: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'todo'),
open: (instance) => TodoDependencies.create(instance, device),
),
),
);
}

View file

@ -80,6 +80,10 @@ flutter:
# The instance the build names, unless --dart-define=SERVER_URL=… does
# (serverpod_flutter's getServerUrl).
- assets/config.json
# What each release changed, one file a release and language
# (<version>.<language>.txt), drafted by tool/release.dart and edited
# (Train's ADR 0030, D61): halkyon_app's What's new shows them.
- assets/whats_new/
# The app's icon, Lucide's list-todo (ADR 0031), and Lucide's licence for the
# licence list (the repository's tool/app_icons.py writes both).
- assets/icon/halkyon_todo_icon.png

View file

@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Nothing of Todo's leaves the phone by Android's backup or a transfer to
/// another phone (ADR 0033, step 5, as Train's ADR 0030, D53): the reminders
/// scheduled are kept unsealed, with their tasks' titles, and what is sealed
/// comes back from the instance anyway. Read as text, the files are small and
/// Android's own.
void main() {
const folder = 'android/app/src/main';
test('backup is off, through Android 11 as from 12 on', () {
final manifest = File('$folder/AndroidManifest.xml').readAsStringSync();
final application = RegExp(
'<application([^>]*)>',
).firstMatch(manifest)![1]!;
expect(application, contains('android:allowBackup="false"'));
expect(application, contains('android:fullBackupContent="false"'));
expect(
application,
contains('android:dataExtractionRules="@xml/data_extraction_rules"'),
);
});
test("from Android 12 on, neither a backup nor a transfer takes any of the app's "
'folders', () {
// allowBackup="false" leaves a transfer to another phone on from Android
// 12 on: the rules have to exclude it too, folder by folder.
final rules = File(
'$folder/res/xml/data_extraction_rules.xml',
).readAsStringSync();
for (final kind in ['cloud-backup', 'device-transfer']) {
final section = RegExp(
'<$kind>(.*?)</$kind>',
dotAll: true,
).firstMatch(rules)?[1];
expect(section, isNotNull, reason: '$kind is missing');
expect(section, isNot(contains('<include')), reason: kind);
final excluded = {
for (final exclude in RegExp(
r'<exclude domain="(\w+)" path="\."/>',
).allMatches(section!))
exclude[1],
};
expect(excluded, {
'root',
'file',
'database',
'sharedpref',
'external',
'device_root',
'device_file',
'device_database',
'device_sharedpref',
}, reason: kind);
}
});
}

View file

@ -0,0 +1,60 @@
# Todo's server image (ADR 0033, step 5). Built from the repository root (the build
# context), because the workspace's root pubspec and lockfile live there:
#
# docker build -f apps/todo/halkyon_todo_server/Dockerfile .
#
# Train's server image (apps/train/halkyon_train_server/Dockerfile) for Todo, as
# Learn's: the same Dart, the same Ubuntu runtime and start command; no git, no
# repositories folder, no data of its own.
# ── Build stage: compile the server to a native executable ────────────────────────
FROM dart:3.13.4 AS build
WORKDIR /app
COPY pubspec.yaml pubspec.lock ./
COPY tool/workspace_only.sh tool/
COPY apps/todo/halkyon_todo_server apps/todo/halkyon_todo_server
COPY packages/halkyon_base_server packages/halkyon_base_server
COPY packages/halkyon_base_client packages/halkyon_base_client
COPY packages/halkyon_core packages/halkyon_core
COPY packages/halkyon_llm packages/halkyon_llm
COPY packages/halkyon_oidc_server packages/halkyon_oidc_server
COPY packages/halkyon_oidc_client packages/halkyon_oidc_client
# The workspace root with only the server and the modules it builds on, as Learn's
# image has it (the reasons there).
RUN sh tool/workspace_only.sh apps/todo/halkyon_todo_server packages/halkyon_base_server packages/halkyon_base_client packages/halkyon_core packages/halkyon_llm packages/halkyon_oidc_server packages/halkyon_oidc_client
RUN dart pub get
WORKDIR /app/apps/todo/halkyon_todo_server
RUN dart build cli --target bin/main.dart --output build
RUN mv build/bundle/bin/main build/bundle/bin/server
RUN mkdir -p config migrations
# ── Runtime stage ─────────────────────────────────────────────────────────────────
# Ubuntu 24.04 as Learn's; CA certificates for outgoing TLS (the operator's
# assistants, which the module forwards, ADR 0021).
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV runmode=production
ENV serverid=default
ENV logging=normal
ENV role=monolith
COPY --from=build /app/apps/todo/halkyon_todo_server/build/bundle/ ./
COPY --from=build /app/apps/todo/halkyon_todo_server/config/ config/
COPY --from=build /app/apps/todo/halkyon_todo_server/migrations/ migrations/
COPY --from=build /app/apps/todo/halkyon_todo_server/lib/src/generated/protocol.yaml lib/src/generated/protocol.yaml
# No root rights needed: it listens on 8080 and 8082 and writes nothing.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin app
USER app
# The API server, and the web server the register tells of accounts gone (ADR
# 0016), reached on the instance's own network alone.
EXPOSE 8080 8082
ENTRYPOINT ["/bin/sh", "-c", "exec ./bin/server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role \"$@\"", "--"]
CMD []

View file

@ -1,27 +1,20 @@
# This is the configuration file for your production environment.
# Typically, you will want to route the traffic through a load balancer
# which adds SSL security through https. If you use Serverpod's standard
# Terraform scripts to deploy your server, all you need to change in
# this file is the examplepod.com domain name.
# Todo's server on learn.halkyon.eu's server, at todo.halkyon.eu (ADR 0033, step 5),
# behind the instance's Caddy (deploy/compose.todo.yaml): its API and its web server
# on the one domain, as Learn's and Train's; its database in the instance's PostgreSQL.
# Configuration for the main API server.
apiServer:
port: 8080
publicHost: api.examplepod.com
publicHost: todo.halkyon.eu
publicPort: 443
publicScheme: https
# Configuration for the Insights server.
insightsServer:
port: 8081
publicHost: insights.examplepod.com
publicPort: 443
publicScheme: https
# No insightsServer, as Learn's: without the block Serverpod doesn't start Insights.
# Configuration for the web server.
webServer:
port: 8082
publicHost: app.examplepod.com
publicHost: todo.halkyon.eu
publicPort: 443
publicScheme: https
@ -30,11 +23,12 @@ webServer:
# If you are connecting on a public IP (e.g. on AWS or Google Cloud Run), you
# connect on the public IP of the database e.g. database.examplepod.com.
database:
host: database.private-production.examplepod.com
host: postgres
port: 5432
name: serverpod
user: postgres
requireSsl: true
name: halkyon_todo
user: halkyon_todo
# On the instance's own network, never leaving the server.
requireSsl: false
#isUnixSocket: true # defaults to false
#maxConnectionCount: 10 # Defaults to 10, a negative or null value removes the limit

44
apps/todo/release.dart Normal file
View file

@ -0,0 +1,44 @@
// How Halkyon Todo is released (`dart run tool/release.dart todo`): the
// release tool's configuration of it, registered in tool/src/release/apps.dart.
import '../../tool/src/release/app_release.dart';
/// Halkyon Todo's releases (ADR 0033, step 5): the APK and the snap, both
/// connecting to todo.halkyon.eu, published on code.halkyon.app, the APK in
/// the project's F-Droid repository beside Learn and Train.
const todoRelease = AppRelease(
id: 'todo',
name: 'Halkyon Todo',
applicationId: 'app.halkyon.todo',
directory: 'apps/todo',
flutterPackage: 'apps/todo/halkyon_todo_flutter',
artifactName: 'halkyon-todo',
serverUrl: 'https://todo.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 and Train's: it is the app's
// identity, and lost, installed apps can't be updated; one key for several
// apps would let either's build pass for the other's update.
android: AndroidSigning(
certificateFile: 'tool/release/todo-android-certificate.sha256',
keystoreVariable: 'HALKYON_TODO_ANDROID_KEYSTORE',
defaultKeystore: '.local/share/halkyon-todo/android-release.p12',
aliasVariable: 'HALKYON_TODO_ANDROID_KEY_ALIAS',
defaultAlias: 'halkyon-todo',
),
fdroid: FdroidListing(
script: 'tool/fdroid_repo.sh',
listing: 'tool/release/fdroid',
indexCertificateFile: 'tool/release/fdroid-index-certificate.sha256',
repositoryUrl: 'https://halkyon.app/fdroid/repo',
),
// Built in a clone of its own (`SnapRelease.projectPath`), as the root's
// snap/ is Learn's. To the Store's edge channel, as Learn's, once the
// name is registered (`snapcraft register halkyon-todo`).
snap: SnapRelease(
name: 'halkyon-todo',
part: 'halkyon-todo',
directory: 'apps/todo/snap',
storeChannel: 'edge',
),
);

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Halkyon Todo
Comment=Tasks and projects, sealed and synced between your devices
Exec=halkyon-todo
Icon=${SNAP}/meta/gui/halkyon-todo.png
Terminal=false
Categories=Office;ProjectManagement;
StartupWMClass=app.halkyon.todo

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,145 @@
# Halkyon Todo's Linux app as a Snap (ADR 0033, step 5), built in LXD by the release
# tool (`dart run tool/release.dart todo`): snapcraft builds one recipe a folder, from
# its snap/snapcraft.yaml, and the root's snap/ is Learn's, so the tool builds this one
# in a clone of the repository at the release's tag with this folder in place of the
# clone's snap/ (`SnapRelease.projectPath`), but for snap/local/, which the recipes
# share. By hand, the same: in such a clone, `snapcraft pack`.
#
# It builds what is committed (the source is the clone's HEAD, the tag), never a
# working tree with its build caches, and always for todo.halkyon.eu. Flutter is
# pinned like the local and CI one (FLUTTER_VERSION in .forgejo/workflows/ci.yaml),
# spelled out as in Learn's recipe, which says why.
name: halkyon-todo
title: Halkyon Todo
base: core24
adopt-info: halkyon-todo # the version, from the app's pubspec.yaml
summary: Tasks and projects, sealed and synced between your devices
description: |
Halkyon Todo keeps your tasks: an Inbox for what comes up, projects with
sections, sub-tasks and labels, Today with what is overdue, and Upcoming as
an agenda that opens into the month. Tasks repeat (every day, every other
Tuesday, the first Monday of each month), have reminders, deadlines and
priorities, and are typed the quick way: "Call Anna tomorrow 9am #Home p1".
Each project shows as a list, a board or a calendar, grouped, sorted and
filtered as you set it, the same on every device. Tasks are dragged between
sections, days and hours.
Everything is sealed on your device 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 todo.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
license: AGPL-3.0-or-later
source-code: https://code.halkyon.app/project-halkyon/halkyon
# The project's, not an instance's: the app works with any instance.
website: https://halkyon.app/todo
# No contact or issues, as Learn's (its recipe says why): the address is in the
# description.
# The app's icon with rounded corners, made by the repository's tool/app_icons.py; also
# the desktop file's.
icon: snap/gui/halkyon-todo.png
grade: stable
confinement: strict
platforms:
amd64:
apps:
halkyon-todo:
command: bin/halkyon_todo_flutter
# How the session is kept: see snap/local/secret-backend (Learn's, kept in the
# clone).
command-chain: [bin/secret-backend]
# GTK, WebKitGTK (the sign-in's page) and libsecret come from the GNOME runtime,
# the reminders' notifications through the desktop's own; files and the browser
# go through the desktop's portals.
extensions: [gnome]
plugs:
- network
# SimpleLogin's sign-in comes back on a loopback address the app listens on.
- network-bind
# Never connected by itself; whoever connects it by hand gets the keyring.
- password-manager-service
# As Learn's: the sync's connection ended at once when the network goes;
# not connected until the Store grants it.
- network-manager-observe
parts:
flutter:
plugin: nil
source: https://github.com/flutter/flutter.git
source-tag: '3.47.8'
source-depth: 1
build-packages:
- clang
- cmake
- curl
- git
- libgtk-3-dev
- lld
- llvm-18
- ninja-build
- pkg-config
- unzip
- xz-utils
- zip
override-build: |
mkdir -p "$CRAFT_PART_INSTALL/usr/libexec" "$CRAFT_PART_INSTALL/usr/bin"
cp -r "$CRAFT_PART_SRC" "$CRAFT_PART_INSTALL/usr/libexec/flutter"
ln -s ../libexec/flutter/bin/flutter "$CRAFT_PART_INSTALL/usr/bin/flutter"
ln -s ../libexec/flutter/bin/dart "$CRAFT_PART_INSTALL/usr/bin/dart"
"$CRAFT_PART_INSTALL/usr/bin/flutter" --disable-analytics
"$CRAFT_PART_INSTALL/usr/bin/flutter" precache --linux
# Needed to build, not in the Snap.
override-prime: ''
halkyon-todo:
after: [flutter]
plugin: nil
source: .
source-type: git
# sodium's build hook compiles libsodium with its configure script and make
# (ADR 0017), with the Flutter part's clang.
build-packages:
- make
override-build: |
set -eu
export PATH="$CRAFT_STAGE/usr/bin:$PATH"
cd apps/todo/halkyon_todo_flutter
craftctl set version="$(sed -n 's/^version: *\([^+]*\).*/\1/p' pubspec.yaml)"
flutter pub get --enforce-lockfile
flutter build linux --release \
--dart-define=SERVER_URL=https://todo.halkyon.eu/
mkdir -p "$CRAFT_PART_INSTALL/bin"
cp -r build/linux/x64/release/bundle/. "$CRAFT_PART_INSTALL/bin/"
secret-backend:
plugin: dump
source: snap/local
organize:
secret-backend: bin/secret-backend
# As Learn's: every file the GNOME runtime or the base has at the same path
# goes, so that nothing staged hides the runtime's own (Learn's recipe says
# what that broke there). Todo stages no package today; the part keeps a
# later one from doing so unnoticed.
cleanup:
after: [halkyon-todo, secret-backend]
plugin: nil
build-snaps: [core24, gnome-46-2404]
override-prime: |
set -eu
for snap in core24 gnome-46-2404; do
cd "/snap/$snap/current/usr"
find . \( -type f -o -type l \) -print0 |
while IFS= read -r -d '' path; do
case "$path" in */blas/* | */lapack/*) continue ;; esac
target="$CRAFT_PRIME/usr/$path"
if [ -L "$target" ] || [ -f "$target" ]; then
rm -f "$target"
fi
done
done