os_intents 0.3.0
os_intents: ^0.3.0 copied to clipboard
Declare OS-level app actions in Dart. Generates iOS App Intents and Android AppFunctions — no Swift or Kotlin by hand.
0.3.0 #
IntentResult.failure, and an exception no longer reaching the user #
A handler can now say why it could not run:
if (!await auth.isSignedIn) {
return IntentResult.failure('Sign in to add tasks.');
}
Both platforms report it the way they report any failed action — a thrown error carrying the message — so the sentence written here is the one the user hears.
Behaviour change, and the reason this was added. An unhandled exception
used to cross as its own toString(), and the system reads that out: Bad state: no auth token for alice@example.com was a thing Siri could say. A
handler that throws is now reported as The action could not be completed,
with the real message logged instead. OsIntents.unexpectedErrorMessage
replaces that fallback — set it from main() to localise it — and
OsIntents.debugIncludeErrorDetail, true in a debug build, still passes the
exception through, since during development the only reader is whoever wrote
the handler.
A test surface beyond the harness #
package:os_intents/testing.dart and FakeOsIntentsPlatform. Everything the
package offers past running a handler — donate, publishStatic,
pushShortcut and the rest — goes through the platform, whose default
implementation answers false and an empty list so a plugin-less build keeps
working. A test of code that donates therefore passed whether or not it
donated. The fake records what it was given, in wire form, and can drive an
invocation the way the OS does, through the installed dispatcher.
IntentHarness gained entityTypes, entitiesByIds, entitiesMatching and
suggestedEntities: an @EntityQuery runs before any handler does, and
nothing in a flutter test could reach one. OsIntents.debugReset() forgets
the installed registry so a second test does not dispatch through the first
one's handlers.
providesDialog on @AppIntent #
IntentResult.done() was documented as "succeeded, nothing to say or show",
but the generated perform() always declared ProvidesDialog and spoke an
empty string instead — there was no way for the generator to know a handler
never speaks. @AppIntent(providesDialog: false) says so: the generated
Swift drops ProvidesDialog from perform()'s return type and answers with
a bare .result().
Opt-in and additive: every existing intent keeps declaring ProvidesDialog
(the default), so nothing that compiled before changes.
0.2.1 #
Documentation only. The install snippet on the package page still named 0.1.0 a release after 0.2.0 shipped; it now tracks the current version. The status block links the public roadmap to 1.0 and invites pilot apps into the repository discussions.
0.2.0 #
Dynamic Android shortcuts #
OsIntents.pushShortcut, shortcuts, removeShortcuts and maxShortcuts,
with a DynamicShortcut to describe one. The Android half of what donate
does on iOS, and under its own name on purpose: a donation is a hint to a
ranking model that iOS may act on, while a dynamic shortcut is an entry the app
puts on the launcher and then owns. Both answer "the user just did this, offer
it back", and each returns false on the platform without a counterpart, so both
are safe to call unconditionally.
A dynamic shortcut runs the named intent through exactly the path a generated
app shortcut uses, so the handler sees args as if the system had filled them
in — converted the same way a donation's are.
Pushing the same id replaces the entry rather than adding a second, which is
what makes "the last five things you did" cheap to keep. At the cap, Android 11+
drops the lowest-ranked entry itself — the platform's own policy, via
pushDynamicShortcut. Below that there is no such call, and pushShortcut
returns false rather than picking one of the app's shortcuts to throw away.
Verified on an emulator, not only in the wire format: dynamic_shortcuts in
probe/android_appfunctions pushes, lists, replaces and removes through the
real ShortcutManager.
More kinds of parameter #
Uri, Duration, Measurement and IntentFile join String, int,
double, bool and DateTime. Additive throughout — nothing that compiled
against 0.1.0 changes.
-
Uribecomes aURLon iOS and its text on Android. -
Durationbecomes the system's own "how long" control. It crosses as microseconds, which is Dart's own integer form of aDuration— the same rule by which aDateTimecrosses as itsmillisecondsSinceEpoch. -
Measurementis a quantity with a unit picker. Which picker is part of the generated Swift type, so it is declared rather than inferred:@Param(title: 'Distance', dimension: Dimension.length). Whatever unit the user chooses,valuearrives in the SI base unit.Seven dimensions —
length,mass,duration,speed,temperature,volume,energy. App Intents has 22 and the other fifteen are iOS 17, which was measured against the SDK rather than read off documentation. -
IntentFileis iOS only. The bytes are already staged on disk when the handler runs, sopathcan be read straight away.androidx.appfunctionshas no file type at all — Android's model is a content URI plus a permission grant — so an intent taking one is left out of what Android offers to agents, andsync --androidsays which and why rather than coercing it to aString.
returns: accepts Uri, Duration and IntentFile too. Not Measurement: a
bare Type has nowhere to put a dimension, and the generator refuses it rather
than emitting Swift that will not build.
0.1.0 #
First released version. Pre-alpha: both pipelines work end to end and are verified on a device, but nothing here has carried a real user's app yet. What ran on a device, what only compiles, and what has never been observed at all is listed in docs/verified.md — read that before trusting anything below.
Declaring actions #
@AppIntenton a top-level function: title, description, spoken phrases, SF Symbol, and a stableidentifier— which is what users' saved shortcuts point at, so it is worth setting before you ship.- Three execution modes.
foregroundopens the app,backgroundruns with no UI,static_answers from published state with no Dart running at all. @Paramfor parameters:String,int,double,bool,DateTime, an@AppEntityor an@AppEnum.requestValueDialogis what the system asks when a value is missing.@AppEntity+@EntityQuery, so the system can resolve, search and suggest values that live in your data. iOS only —androidx.appfunctionshas no entity concept at all.@AppEnumfor a closed set, which works on both platforms: a realAppEnumon iOS, a value constraint on Android.returns:hands a value to the next step of a Shortcut.showsSnippet: trueanswers with a card, andSnippetSpec.actionsputs buttons on it that run your other intents. Buttons need iOS 17; below that the card renders without them.confirmBeforeRunning:makes the system ask first; the handler is not called at all if the user declines.
Reaching the user #
OsIntents.publishStatickeeps thestatic_answers fresh.OsIntents.donatetells the system an action just happened, so it can suggest it later. iOS only, and returns false on Android rather than pretending.- Android app shortcuts and Assistant capabilities are generated by default.
AppFunctions — the headless layer — is opt-in behind
sync --android, because it forcescompileSdk 37, AGP 9.1.1 and Gradle 9.3.1 on your app.
Testing #
IntentHarnessruns a handler with no device, simulator or Siri involved.
Known gaps #
Apple's iOS 26 SnippetIntent, where a card reloads itself after a button runs;
AssistantIntent schemas; and entities or snippet cards on Android. Siri
invoking a phrase by voice has never been observed — the OS calling
perform() has, from the Shortcuts app.
verified.md has the evidence for every claim above; troubleshooting.md covers the ways this fails silently.
