What does an old build show when the server no longer supports it?
Apps on phones do not update when the server does. Sooner or later the server changes in a way an old build cannot follow, and that build keeps running on someone's phone — sending calls the server refuses, one screen at a time, each failure looking like a different bug.
DartWay makes it one state with one screen: the server says "this build is too old" once, the client remembers it, and the app shows a full-screen "update the app" page over everything.
Two causes, one family
| Code | Cause | Who fixes it |
|---|---|---|
dw.updateRequired (DwCoreRefusal.updateRequired) | The app was compiled with an older breaking line of the project's contract than the server's. | The user: the store has a newer build. |
dw.protocolUnsupported (DwCoreRefusal.protocolUnsupported) | The client speaks a framework protocol version the server does not. | The operator: a framework version skew between app and server. |
Both are DwCoreRefusal.incompatibilities — refusal.isIncompatibility is true for either. They are
two codes rather than one because the cause differs, and so do the words on the page.
How the server decides
Every call carries two headers the client sets itself:
Dw-Protocol— the framework's protocol version (dwProtocolVersion);Dw-Contract-Version— the version of the project's contract the app was compiled with: the shared package'sversion:, whichdart run dartway_cli:dartway generatewrites into the protocol both sides use.
The server answers 426 with status incompatible when the protocol differs, or when the app's
contract belongs to an older breaking line than the server's — the major version, or the minor one
below 1.0 (DwContractVersion); a call with no contract version counts as the oldest. The live
socket's upgrade carries the same versions and is closed with DwCloseCode.incompatible for the same
reason. An app on a newer line than the server is not refused: the server is behind, and a call it
does not know is answered as unknown.
The minimum lives in the code. Whoever removes or renames anything an installed app sends or reads raises the breaking line of the shared package's version in the same pull request; nothing is set in an environment at deploy time. See wire and versions.
Dw-App-Version (DwFlutterConfig.appVersion, <semver>+<build>) still travels: it labels the
session key the app signs in with. It decides nothing.
What the client does
The first incompatible answer makes the client incompatible for good:
dw.incompatibility— aDwCallRefusal?provider — becomes the refusal;dw.liveStatusbecomesDwConnectionStatus.incompatible, and the live socket is closed and not reopened;- every later call answers
DwCallRefusedwith that refusal without reaching the network, and every watched request shows it as aDwRefusalException.
Nothing under the app can reach the server any more, and nothing is retried: only another build can.
DwFlutterConfig.updateRequiredScreen
The project supplies the page:
DwFlutterConfig(
// ...
updateRequiredScreen: (context, refusal) => UpdateRequiredPage(refusal: refusal),
)
DwAppBootstrapper — mounted by DwAppRunner — watches the client and, once it is incompatible,
builds this page in place of the app, over the loading and error screens too. It is built above
the app's MaterialApp, so the page brings its own. From
template/dartway_starter_flutter/lib/core/update_required_page.dart:
class UpdateRequiredPage extends ConsumerWidget {
const UpdateRequiredPage({required this.refusal, super.key});
/// `dw.updateRequired` or `dw.protocolUnsupported`.
final DwCallRefusal refusal;
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(appLocaleProvider);
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light,
debugShowCheckedModeBanner: false,
home: Builder(
builder: (context) {
final l10n = context.l10n;
final updateRequired = refusal.isCode(DwCoreRefusal.updateRequired);
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppIconView(AppIcon.brandMark, size: 64),
const SizedBox(height: 24),
AppText.title(
updateRequired
? l10n.updateRequiredTitle
: l10n.serverMismatchTitle,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
AppText.body(
updateRequired
? l10n.updateRequiredBody
: l10n.serverMismatchBody,
textAlign: TextAlign.center,
),
],
),
),
),
);
},
),
);
}
}
Why the project writes it. The framework knows neither the app's language nor where the app is published. The skeleton's page has no store button yet; once the app is in a store, the link to its store page goes under the text.
Without updateRequiredScreen the app stays on screen and every call answers the refusal:
dw.action shows it through refusalText like any other refusal, and each read renders its error.
That is why the skeleton's refusal_text.dart still gives both codes a sentence — for a place that
renders a refusal on its own. With the page set, dw.action shows nothing for an incompatibility: the
page is already the message.
Proven by
example/dartway_example_flutter/test/app/update_required_test.dartand the last test oftemplate/dartway_starter_flutter/test/auth/sign_in_test.dart: a fake server whose contract moved to a newer breaking line answers the app, which is mounted throughDwAppBootstrapperexactly as the runner mounts it; the client reportsdw.updateRequired, "Update the app" is on screen, and nothing of the app is.packages/dartway_core_flutter/test/dw_flutter_core_test.dart— "a build the server no longer supports gets the update-required screen over the app".
Related
- Flutter core —
DwFlutterConfigand the bootstrap. - Refusals and statuses —
incompatibleamong the statuses.