feat(learn,todo,meditate): dictate through the phone's voice input app (ADR 0038, D18)

On Android the microphone now opens the app handling
RECOGNIZE_SPEECH (FUTO Voice Input, Google's), the default the person
chose in Android, and puts the text it returns where the cursor stood,
as Dictation does; cancelled, nothing. Only where none is installed, or
it can't be opened, does speech_to_text listen in line as before.

halkyon_app: VoiceInputApp (one listen, an answer once closed) on the
channel app.halkyon/voice_input, which DeviceSpeechInput asks first at
every listen, answering SpeechStart.finished so that Dictation doesn't
listen again; speech_to_text isn't touched on that route, so our app
asks for no microphone. SpeechInput.listen and Dictation.start take the
prompt the voice input app shows (the field's hint).

Each app: VoiceInput.kt (queryIntentActivities, startActivityForResult,
the first of EXTRA_RESULTS) attached in MainActivity, which hands it
activity results, and RECOGNIZE_SPEECH among the manifest's queries.
The privacy pages of Learn and Meditate say so.

Our own Whisper and Meditate's dream recordings are dropped (D18).

Whats-New: The microphone now dictates through your phone's voice input app, such as FUTO Voice Input.
Whats-New-De: Das Mikrofon diktiert jetzt über die Spracheingabe-App deines Telefons, etwa FUTO Voice Input.
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-07 23:45:20 +01:00
commit c36bd83c6a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
31 changed files with 827 additions and 27 deletions

View file

@ -10,7 +10,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the navigation bar flush with the foot with its fade, and the rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput` on `speech_to_text`, `FakeSpeechInput` in `testing.dart`; none on Linux) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput`: on Android the voice input app handling `RECOGNIZE_SPEECH` where one is installed, `VoiceInputApp` on each app's `VoiceInput.kt`, `app.halkyon/voice_input`, FUTO Voice Input for one, which records itself and returns text, else `speech_to_text`; `FakeSpeechInput` in `testing.dart`; none on Linux; Meditate's ADR 0038, D18) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_editor` (ADR 0033, D37, 6 Nov 2026): rich text, the general parts of Learn's editor on our fork of `appflowy_editor`: `RichContent` (the editor's document JSON, which Learn and Todo store), `RichContentEditor`, `RichContentView`, the format bar, links, the clipboard keeping formats, superscript and subscript, and `EditorBlocks`, through which an app adds blocks, marks within lines and their buttons (`basicBlocks` without any), with texts of its own (English, German). What is Learn's alone stays in Learn on top of it.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.

File diff suppressed because one or more lines are too long

View file

@ -96,5 +96,10 @@
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<!-- The voice input app dictation opens first (Meditate's ADR 0038, D18),
FUTO Voice Input or Google's, which records itself. -->
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH"/>
</intent>
</queries>
</manifest>

View file

@ -13,6 +13,9 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
/** The voice input app dictation opens (ADR 0038, D18). */
private val voiceInput = VoiceInput(this)
private var askedToNotify = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@ -21,6 +24,7 @@ class MainActivity : FlutterActivity() {
PomodoroService.appBack()
ScreenGuard.attach(this, messenger)
SensitiveClipboard.attach(this, messenger)
voiceInput.attach(messenger)
// A question searched with the device's own web search (its default
// browser or search app answers ACTION_WEB_SEARCH); false where none does,
// and the app opens a search engine's address instead.
@ -107,6 +111,12 @@ class MainActivity : FlutterActivity() {
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!voiceInput.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
}
private companion object {
const val NOTIFY_REQUEST = 1
}

View file

@ -0,0 +1,84 @@
package app.halkyon.learn
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.RecognizerIntent
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* The device's voice input app for halkyon_app's ChannelVoiceInputApp
* (Halkyon Meditate's ADR 0038, D18): whichever app handles
* ACTION_RECOGNIZE_SPEECH (FUTO Voice Input, Google's) opens over the app,
* records and returns text alone, so this app asks for no microphone.
* `installed` says whether one is (the manifest's <queries> lets Android 11
* on show it); `listen` opens it and answers once it is closed: its first
* result, empty where cancelled, an error where it couldn't be opened. The
* activity hands its results to [onActivityResult].
*/
class VoiceInput(private val activity: Activity) {
private var pending: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, "app.halkyon/voice_input").setMethodCallHandler { call, result ->
when (call.method) {
"installed" -> result.success(installed())
"listen" -> {
if (pending != null) {
result.error("busy", "The voice input app is open already", null)
return@setMethodCallHandler
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
call.argument<String>("language")?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }
call.argument<String>("prompt")?.let { intent.putExtra(RecognizerIntent.EXTRA_PROMPT, it) }
try {
pending = result
activity.startActivityForResult(intent, REQUEST)
} catch (error: ActivityNotFoundException) {
pending = null
result.error("noApp", error.message, null)
} catch (error: SecurityException) {
pending = null
result.error("refused", error.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun installed(): Boolean {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
val packages = activity.packageManager
val found = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packages.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packages.queryIntentActivities(intent, 0)
}
return found.isNotEmpty()
}
/** Whether [requestCode] was the voice input app's, its text handed on. */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST) return false
val result = pending ?: return true
pending = null
val text = if (resultCode == Activity.RESULT_OK) {
data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
} else {
null
}
result.success(text ?: "")
return true
}
private companion object {
/** Any number the app's other activity results don't use. */
const val REQUEST = 0x766f
}
}

View file

@ -394,8 +394,11 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
final words = HalkyonAppLocalizations.of(context);
final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
// Shown by the voice input app, where it shows a prompt (Meditate's ADR 0038, D18).
prompt: l10n.assistantSpeak,
)) {
SpeechStart.listening => null,
// Listening in line, or the voice input app has listened.
SpeechStart.listening || SpeechStart.finished => null,
// A phone without Google's services has no recogniser installed.
SpeechStart.noRecognizer => words.speechNoRecognizer,
SpeechStart.unavailable => l10n.assistantSpeechUnavailable,

View file

@ -406,6 +406,31 @@ void main() {
expect(find.byTooltip('Search'), findsOneWidget);
});
testWidgets('with a voice input app installed, the microphone opens it, '
'and what it returns goes into the field, once; cancelled, nothing '
"(Meditate's ADR 0038, D18)", (tester) async {
speech.app = true;
await showApp(tester);
await unfold(tester);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
expect(speech.appOpen, isTrue);
expect(speech.listening, isFalse, reason: 'nothing else listens');
expect(speech.prompts, ['Speak your question']);
speech.cancel();
await tester.pumpAndSettle();
expect(find.byTooltip('Speak your question'), findsOneWidget);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
speech.answer('what does 火 mean');
await tester.pumpAndSettle();
expect(find.text('what does 火 mean'), findsOneWidget);
expect(speech.languages, hasLength(2), reason: 'once a tap');
expect(find.byTooltip('Search'), findsOneWidget);
});
testWidgets('says so where no speech recognition is installed, and how '
'to get one, never stuck listening (GrapheneOS, 4 Nov 2026)', (
tester,

View file

@ -90,9 +90,11 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'further, what it looks up there, and the files you attach: never '
'your progress, and a private studyset only once you agreed to send '
'it to that assistant. A question you speak is written by your '
'device’s own speech recognition, which on Android may send the '
'sound to Google or the phone’s maker; it never reaches the '
'instance.',
'device’s own speech recognition: on Android the voice input app '
'installed (FUTO Voice Input, for one), which hands Learn the text '
'alone and decides itself whether the sound leaves the device, or '
'else Android’s recogniser, which may send the sound to Google or '
'the phone’s maker; it never reaches the instance.',
german:
'Deine Unterhaltungen mit Assistenten werden aufbewahrt, um sie '
'zwischen '
@ -104,9 +106,12 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'suchen lässt, was er dort nachschlägt, und die Dateien, die du '
'anhängst: nie deinen Fortschritt, und ein privates Lernset erst, '
'nachdem du zugestimmt hast, es diesem Assistenten zu senden. Eine '
'Frage, die du sprichst, schreibt die Spracherkennung deines Geräts, '
'die auf Android den Ton an Google oder den Hersteller des Telefons '
'senden kann; die Instanz erreicht er nie.',
'Frage, die du sprichst, schreibt die Spracherkennung deines Geräts: '
'auf Android die installierte Spracheingabe-App (etwa FUTO Voice '
'Input), die Learn nur den Text gibt und selbst entscheidet, ob der '
'Ton das Gerät verlässt, sonst Androids Spracherkennung, die den Ton '
'an Google oder den Hersteller des Telefons senden kann; die Instanz '
'erreicht er nie.',
),
(
english:

View file

@ -4,7 +4,7 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **Packages**: `halkyon_meditate_server`, `halkyon_meditate_client` (generated, never edited), `halkyon_meditate_flutter`; app ID `app.halkyon.meditate` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). Copied from Todo's tree on 3 Nov 2026 and stripped to the shell, so its frame, keys and sealed rows are Todo's and Train's.
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`meditateBase()`, `lib/src/meditate_base.dart`): its basics say it serves `meditate` (never change it once apps use it), sign-in mails name Halkyon Meditate, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteMeditateData]`, `lib/src/meditate_data.dart`). **Accounts** (ADR 0016): Meditate holds its own register when it runs alone, and is a member of the instance's register where the operator sets `ACCOUNT_REGISTER_URL` (`AccountsMode.fromEnvironment()`) with the instance's shared `INSTANCE_SECRET`; `INSTANCE_MEMBER_MEDITATE` names it to the register. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled once each. Synced tables (ADR 0038, D7; `database: sync`, every row sealed): `content_key`, `meditate_settings` (one row a person, under an ID keyed by the account), `sitting`, `dream` (D14) and `lesson_done` (D16), each indexing `spaceId`. Migrations: `initial` (Serverpod's, the auth modules', the module's and the sync engine's tables), `sittings` (the three tables), `dreams` (`dream`) and `library` (`lesson_done`). Tests: `test/integration/meditate_server_test.dart` (the module's endpoints through the server's own dispatch, `halkyon_base.<endpoint>`, and the sync engine's connector refusing calls without a session), `test/integration/sitting/person_deletion_test.dart`, the library's in `test/unit/library/` (the manifest, the files over HTTP) and `test/integration/library/` (the endpoint).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Meditate's frame (`meditateFrame`) and `ServerpodInstanceChecker(app: 'meditate')`; `MeditateDependencies` per instance, `MeditatePerson` per person (their sync, `SettingsRepository`, `SittingRepository`, `DreamRepository`, `LibraryRepository`, `LessonRepository`, and the device's `BellPlayer`, `GuidePlayer` and `ScreenAwake`); SimpleLogin's way back on `app.halkyon.meditate://` and the loopback port 43826. The person's app (`MeditatePersonApp`) washes in fern (`ScreenColor`, D3) and holds `PracticeCubit` (settings and sittings, read again on every change), `JournalCubit` (the dreams) and `LibraryCubit` (the instance's library, lessons done, recordings kept) for every screen. go_router (`buildMeditateRouter`, `MeditatePaths`): halkyon_ui's `NavigationFrame` (`MeditateShell`) with Today, Explore, Journal and Profile (D2, D14; a bar below medium windows, a rail with the icon from medium on), a practice's page in Explore's branch (`PracticeScreen`) with a library item's and course's (`/explore/library/:id`, `?course=` for a lesson, `/explore/courses/:id`), and over the tabs the sitting (`SittingScreen`, handed its `SittingPlan` as `extra`), a recording playing (`ListeningScreen`, its `ListeningPlan` as `extra`), the library's credits (`/library-credits`), a dream (`DreamScreen`, `/dream` new, `/dream/:id` kept) and Settings. **Today** (`TodayTab`, D9): greeting by the hour, from 4 to 12 o'clock a card to write down last night's dream while it has none (`dreamPromptDue`, D14), Sit now (5, 10, 20 and the timer's own length), the practice of the day (`practiceOfDay`), the week and the days in a row (`WeekCard`), favourites, recently sat. **Explore** (`ExploreTab`, D10): search folded without case or accents (`matchesSearch`), four goal tiles, kind chips, practice cards. **The Journal** (`JournalTab`, D14; `lib/src/feature/journal/`): dreams by month, the newest first (`dreamsByMonth`), searched over title, text and labels and filtered by marks (`findDreams`), *+* to write one; a dream's night is the date of the morning after it (`defaultNight`: today's date at any hour); its screen writes with the text first and focused, the rest folded, and reads a kept one with Edit and Delete; a microphone under the text dictates where the device recognises speech (D15: halkyon_app's `Dictation` on `MeditatePerson.speech`, `DeviceSpeechInput` preferring Android's on-device recogniser, listening again after each pause until stopped or three silent listens; none on Linux, `NoSpeechInput` in tests that don't say), and Today's card offers *Speak it* (`/dream?speak=1`). The platforms declare the microphone as Learn's do (Android's `RECORD_AUDIO` and recognition query, iOS's and macOS's usage texts, macOS's `audio-input`), and the privacy page's points are `meditate_base.dart`'s. **Profile** (D2, D8): halkyon_app's `HalkyonProfile` with a *Practice* tab (minutes, sittings, days, the week, the journal's dreams this month and lucid ones once it holds one, every sitting with Delete). **The practices** are `practices` in `lib/src/feature/practice/model/practice.dart` (IDs never change once released), their words in the ARBs by ID (`practiceTexts`), their cues spread evenly over the length (`Practice.cueAt`). **A sitting** (`SittingCubit`, D6): its time from `clock.now()`, the ticks only showing it; the bell at the start (where on), between every interval and at the end; the screen kept on while it runs (where on); kept whole at its end, and ended early kept from a minute on where the person says so. **The figures** (`practiceFigures`, D8) on the device: a sitting's day by the wall clock it was sat by (`Sitting.offsetMinutes`), the streak up to today or yesterday, the week from the language's first day. **Settings** (D11): Sync, the operator's card, the device's appearance and language (halkyon_app's `DeviceLookCard` on `LookCubit`, kept in `<app support>/look.json`, `meditateLanguages`; ADR 0014's amendment of 4 Nov 2026), *Sitting* (bell, starting bell, bells between, screen on, the timer's length), *Practice* (the streak shown), *Journal* (Today's morning card, `dreamPrompt`), the account, What's new, About, the version. Texts in British English (the template) and German (du), `en_US` and `en_GB` empty. Widget tests start the whole app with halkyon_app's fakes (`test/app/meditate_app_test.dart`), the bells and screen recorded (`test/support/recording_device.dart`); a sitting on screen ticks every second, so tests pump seconds (`sit`) rather than `pumpAndSettle`; a test needing the hour (Today's morning card) starts the app inside `withClock` (`startAt`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Meditate's frame (`meditateFrame`) and `ServerpodInstanceChecker(app: 'meditate')`; `MeditateDependencies` per instance, `MeditatePerson` per person (their sync, `SettingsRepository`, `SittingRepository`, `DreamRepository`, `LibraryRepository`, `LessonRepository`, and the device's `BellPlayer`, `GuidePlayer` and `ScreenAwake`); SimpleLogin's way back on `app.halkyon.meditate://` and the loopback port 43826. The person's app (`MeditatePersonApp`) washes in fern (`ScreenColor`, D3) and holds `PracticeCubit` (settings and sittings, read again on every change), `JournalCubit` (the dreams) and `LibraryCubit` (the instance's library, lessons done, recordings kept) for every screen. go_router (`buildMeditateRouter`, `MeditatePaths`): halkyon_ui's `NavigationFrame` (`MeditateShell`) with Today, Explore, Journal and Profile (D2, D14; a bar below medium windows, a rail with the icon from medium on), a practice's page in Explore's branch (`PracticeScreen`) with a library item's and course's (`/explore/library/:id`, `?course=` for a lesson, `/explore/courses/:id`), and over the tabs the sitting (`SittingScreen`, handed its `SittingPlan` as `extra`), a recording playing (`ListeningScreen`, its `ListeningPlan` as `extra`), the library's credits (`/library-credits`), a dream (`DreamScreen`, `/dream` new, `/dream/:id` kept) and Settings. **Today** (`TodayTab`, D9): greeting by the hour, from 4 to 12 o'clock a card to write down last night's dream while it has none (`dreamPromptDue`, D14), Sit now (5, 10, 20 and the timer's own length), the practice of the day (`practiceOfDay`), the week and the days in a row (`WeekCard`), favourites, recently sat. **Explore** (`ExploreTab`, D10): search folded without case or accents (`matchesSearch`), four goal tiles, kind chips, practice cards. **The Journal** (`JournalTab`, D14; `lib/src/feature/journal/`): dreams by month, the newest first (`dreamsByMonth`), searched over title, text and labels and filtered by marks (`findDreams`), *+* to write one; a dream's night is the date of the morning after it (`defaultNight`: today's date at any hour); its screen writes with the text first and focused, the rest folded, and reads a kept one with Edit and Delete; a microphone under the text dictates where the device recognises speech (D15: halkyon_app's `Dictation` on `MeditatePerson.speech`, `DeviceSpeechInput`: on Android the voice input app first where one is installed, D18, opened once a tap and its text put where the cursor stood, else Android's recogniser, on the device where it can, listening again after each pause until stopped or three silent listens; none on Linux, `NoSpeechInput` in tests that don't say), and Today's card offers *Speak it* (`/dream?speak=1`). The platforms declare the microphone as Learn's do (Android's `RECORD_AUDIO`, the recognition service's and `RECOGNIZE_SPEECH`'s queries, `VoiceInput.kt` answering `app.halkyon/voice_input` from `MainActivity`, iOS's and macOS's usage texts, macOS's `audio-input`), and the privacy page's points are `meditate_base.dart`'s. **Profile** (D2, D8): halkyon_app's `HalkyonProfile` with a *Practice* tab (minutes, sittings, days, the week, the journal's dreams this month and lucid ones once it holds one, every sitting with Delete). **The practices** are `practices` in `lib/src/feature/practice/model/practice.dart` (IDs never change once released), their words in the ARBs by ID (`practiceTexts`), their cues spread evenly over the length (`Practice.cueAt`). **A sitting** (`SittingCubit`, D6): its time from `clock.now()`, the ticks only showing it; the bell at the start (where on), between every interval and at the end; the screen kept on while it runs (where on); kept whole at its end, and ended early kept from a minute on where the person says so. **The figures** (`practiceFigures`, D8) on the device: a sitting's day by the wall clock it was sat by (`Sitting.offsetMinutes`), the streak up to today or yesterday, the week from the language's first day. **Settings** (D11): Sync, the operator's card, the device's appearance and language (halkyon_app's `DeviceLookCard` on `LookCubit`, kept in `<app support>/look.json`, `meditateLanguages`; ADR 0014's amendment of 4 Nov 2026), *Sitting* (bell, starting bell, bells between, screen on, the timer's length), *Practice* (the streak shown), *Journal* (Today's morning card, `dreamPrompt`), the account, What's new, About, the version. Texts in British English (the template) and German (du), `en_US` and `en_GB` empty. Widget tests start the whole app with halkyon_app's fakes (`test/app/meditate_app_test.dart`), the bells and screen recorded (`test/support/recording_device.dart`); a sitting on screen ticks every second, so tests pump seconds (`sit`) rather than `pumpAndSettle`; a test needing the hour (Today's morning card) starts the app inside `withClock` (`startAt`).
- **The operator's library** (D16; `lib/src/library/` on the server, `lib/src/feature/library/` in the app): a folder named by `MEDITATE_LIBRARY_DIR` with recordings, pictures and `library.yaml` (commented sample: the server's `library.example.yaml`), read by `readLibrary` (every check and refusal, with the reason), held in memory by `MeditateLibrary` and reread every ten minutes (`LibraryReloadFutureCall`, `startLibrary`); checked before deploying with `dart run bin/check_library.dart <folder>` in the server package. `LibraryEndpoint.catalogue` for signed-in people alone, with an access (`LibraryFileAccess`: the hour begun plus 12 hours, signed with a key made at start); the web server's `/library/<served name>?access=…` serves the files by ranges (`serveLibraryFile`), never by a path. In the app: Explore's section (none without a library), the item's page (recordings, Play, favourite as `library/<id>` in the settings, keep on the device, a sound under a silent sitting, its credit), the course's (lessons ticked), the player (a finished item is a sitting `library/<id>` and its lesson done), Settings' *Library credits*; the last catalogue in the person's folder (`library.json`), recordings kept in `library/` beside it. Tests: `test/feature/library/library_test.dart` and the app test's group, on `test/support/fake_library.dart` (made-up entries only: nothing of 7Mind's, whose structure alone D16 follows).
- **The bells** (D5) are `assets/bells/*.wav`, synthesised by `apps/meditate/tool/bells.py` (Python's standard library, run from `apps/meditate`; the same bytes every run) and played with `audioplayers` (`AudioplayersBellPlayer`). Only while the app runs: bells with the screen off are for later.
- **The icon** is Lucide's `sprout` from cobalt through pacific into fern (ADR 0031, D5, D7), drawn by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py meditate` from the root): `assets/icon/halkyon_meditate_icon.png`, Lucide's licence beside it, every platform's launcher icon.

View file

@ -5,7 +5,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!-- A dream spoken into the journal (ADR 0038, D15): the microphone, asked
for once the person taps it, and the device's speech recogniser, which
Android 11 and later only shows the app when it is declared below. -->
Android 11 and later only shows the app when it is declared below.
Through a voice input app (D18) the microphone is that app's, and this
one asks for none. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- No backup and no transfer to another phone (ADR 0038, D7, as
Train's ADR 0030, D53): what is sealed comes back from the
@ -73,5 +75,10 @@
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<!-- The voice input app dictation opens first (Meditate's ADR 0038, D18),
FUTO Voice Input or Google's, which records itself. -->
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH"/>
</intent>
</queries>
</manifest>

View file

@ -1,13 +1,24 @@
package app.halkyon.meditate
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
/** The voice input app dictation opens (ADR 0038, D18). */
private val voiceInput = VoiceInput(this)
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
voiceInput.attach(flutterEngine.dartExecutor.binaryMessenger)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!voiceInput.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
}
}

View file

@ -0,0 +1,84 @@
package app.halkyon.meditate
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.RecognizerIntent
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* The device's voice input app for halkyon_app's ChannelVoiceInputApp
* (Halkyon Meditate's ADR 0038, D18): whichever app handles
* ACTION_RECOGNIZE_SPEECH (FUTO Voice Input, Google's) opens over the app,
* records and returns text alone, so this app asks for no microphone.
* `installed` says whether one is (the manifest's <queries> lets Android 11
* on show it); `listen` opens it and answers once it is closed: its first
* result, empty where cancelled, an error where it couldn't be opened. The
* activity hands its results to [onActivityResult].
*/
class VoiceInput(private val activity: Activity) {
private var pending: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, "app.halkyon/voice_input").setMethodCallHandler { call, result ->
when (call.method) {
"installed" -> result.success(installed())
"listen" -> {
if (pending != null) {
result.error("busy", "The voice input app is open already", null)
return@setMethodCallHandler
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
call.argument<String>("language")?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }
call.argument<String>("prompt")?.let { intent.putExtra(RecognizerIntent.EXTRA_PROMPT, it) }
try {
pending = result
activity.startActivityForResult(intent, REQUEST)
} catch (error: ActivityNotFoundException) {
pending = null
result.error("noApp", error.message, null)
} catch (error: SecurityException) {
pending = null
result.error("refused", error.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun installed(): Boolean {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
val packages = activity.packageManager
val found = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packages.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packages.queryIntentActivities(intent, 0)
}
return found.isNotEmpty()
}
/** Whether [requestCode] was the voice input app's, its text handed on. */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST) return false
val result = pending ?: return true
pending = null
val text = if (resultCode == Activity.RESULT_OK) {
data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
} else {
null
}
result.success(text ?: "")
return true
}
private companion object {
/** Any number the app's other activity results don't use. */
const val REQUEST = 0x766f
}
}

View file

@ -12,6 +12,7 @@ class const NoSpeechInput() implements SpeechInput {
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
String? prompt,
}) async => SpeechStart.unavailable;
@override

View file

@ -95,8 +95,11 @@ class _DreamScreenState() extends State<DreamScreen> {
final messenger = ScaffoldMessenger.of(context);
final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
// Shown by the voice input app, where it shows a prompt (D18).
prompt: l10n.dreamTextHint,
)) {
SpeechStart.listening => null,
// Listening in line, or the voice input app has listened.
SpeechStart.listening || SpeechStart.finished => null,
// A phone without Google's services has no recogniser to listen with.
SpeechStart.noRecognizer => words.speechNoRecognizer,
SpeechStart.unavailable => l10n.dreamSpeechUnavailable,

View file

@ -1083,6 +1083,43 @@ void main() {
);
});
testWidgets('with a voice input app installed, the microphone opens it, '
'asking what was dreamt, and what it returns goes where the cursor '
'stands, once; cancelled, nothing (D18)', (tester) async {
speech.app = true;
await newDream(tester);
await tester.enterText(text, 'I was there.');
tester.widget<TextField>(text).controller!.selection =
const TextSelection.collapsed(offset: 5);
await tester.tap(find.byTooltip('Dictate'));
await tester.pump();
expect(speech.appOpen, isTrue);
expect(speech.listening, isFalse, reason: 'nothing else listens');
expect(speech.prompts, ['What did you dream?']);
expect(find.byTooltip('Stop dictating'), findsOneWidget);
speech.answer('at the sea');
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(text).controller!.text,
'I was at the sea there.',
);
expect(speech.languages, hasLength(1), reason: 'not opened again');
expect(find.byTooltip('Dictate'), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
await tester.tap(find.byTooltip('Dictate'));
await tester.pump();
speech.cancel();
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(text).controller!.text,
'I was at the sea there.',
);
expect(find.byTooltip('Dictate'), findsOneWidget);
});
testWidgets("the recogniser's pause doesn't end it: it listens on, the "
'next words after the last, until stopped', (tester) async {
await newDream(tester);

View file

@ -76,7 +76,10 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
(
english:
'A dream you speak is written by your device’s own speech '
'recognition. On Android, Meditate asks for recognition on the '
'recognition. On Android, where a voice input app is installed '
'(FUTO Voice Input, for one), that app listens and hands Meditate '
'the text alone, and it decides whether the sound leaves the device; '
'without one, Meditate asks for recognition on the '
'device, which Android 12 and later offer where the language is '
'installed; otherwise Android’s recogniser may send the sound to '
'Google or the phone’s maker. Apple’s and Windows’ recognisers decide '
@ -84,7 +87,10 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'the instance.',
german:
'Einen Traum, den du erzählst, schreibt die Spracherkennung deines '
'Geräts auf. Unter Android bittet Meditate um Erkennung auf dem '
'Geräts auf. Unter Android hört, wo eine Spracheingabe-App '
'installiert ist (etwa FUTO Voice Input), diese App zu und gibt '
'Meditate nur den Text; ob der Ton das Gerät verlässt, entscheidet '
'sie. Ohne eine bittet Meditate um Erkennung auf dem '
'Gerät, die Android ab Version 12 bietet, wo die Sprache installiert '
'ist; sonst kann Androids Spracherkennung den Ton an Google oder den '
'Hersteller des Telefons senden. Die Spracherkennung von Apple und '

File diff suppressed because one or more lines are too long

View file

@ -136,5 +136,10 @@
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<!-- The voice input app dictation opens first (Meditate's ADR 0038, D18),
FUTO Voice Input or Google's, which records itself. -->
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH"/>
</intent>
</queries>
</manifest>

View file

@ -1,14 +1,19 @@
package app.halkyon.todo
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
/** The voice input app dictation opens (ADR 0038, D18). */
private val voiceInput = VoiceInput(this)
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
SensitiveClipboard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
voiceInput.attach(flutterEngine.dartExecutor.binaryMessenger)
LocationReminders.attach(this, flutterEngine.dartExecutor.binaryMessenger)
}
@ -20,4 +25,10 @@ class MainActivity : FlutterActivity() {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
LocationReminders.onPermissionResult(this, requestCode)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!voiceInput.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
}
}

View file

@ -0,0 +1,84 @@
package app.halkyon.todo
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.RecognizerIntent
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* The device's voice input app for halkyon_app's ChannelVoiceInputApp
* (Halkyon Meditate's ADR 0038, D18): whichever app handles
* ACTION_RECOGNIZE_SPEECH (FUTO Voice Input, Google's) opens over the app,
* records and returns text alone, so this app asks for no microphone.
* `installed` says whether one is (the manifest's <queries> lets Android 11
* on show it); `listen` opens it and answers once it is closed: its first
* result, empty where cancelled, an error where it couldn't be opened. The
* activity hands its results to [onActivityResult].
*/
class VoiceInput(private val activity: Activity) {
private var pending: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, "app.halkyon/voice_input").setMethodCallHandler { call, result ->
when (call.method) {
"installed" -> result.success(installed())
"listen" -> {
if (pending != null) {
result.error("busy", "The voice input app is open already", null)
return@setMethodCallHandler
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
call.argument<String>("language")?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }
call.argument<String>("prompt")?.let { intent.putExtra(RecognizerIntent.EXTRA_PROMPT, it) }
try {
pending = result
activity.startActivityForResult(intent, REQUEST)
} catch (error: ActivityNotFoundException) {
pending = null
result.error("noApp", error.message, null)
} catch (error: SecurityException) {
pending = null
result.error("refused", error.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun installed(): Boolean {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
val packages = activity.packageManager
val found = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packages.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packages.queryIntentActivities(intent, 0)
}
return found.isNotEmpty()
}
/** Whether [requestCode] was the voice input app's, its text handed on. */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST) return false
val result = pending ?: return true
pending = null
val text = if (resultCode == Activity.RESULT_OK) {
data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
} else {
null
}
result.success(text ?: "")
return true
}
private companion object {
/** Any number the app's other activity results don't use. */
const val REQUEST = 0x766f
}
}

Some files were not shown because too many files have changed in this diff Show more