bifold 1.0.2
bifold: ^1.0.2 copied to clipboard
Fold awareness for iPhone Duo and Android foldables: hinge angle, pose, fold regions, capabilities, rear display, fold-aware layout widgets and test fakes.
1.0.2 #
Fixes for defects on the boundary between Dart and the two native writers — the layer no test in this repository could see, because each side was self-consistent and the failure lived in the contract between them — plus two on the widget layer, where a layout did not do what three separate documents said it did. Several of them contradicted the package's own documented semantics. No published API changes, and no deprecation is warranted: the fixes move behaviour towards what the README and the doc comments already promised.
Fixed
-
BifoldGridnow breaks the columns at a vertical crease, as it always said it did. The class documentation, the README's layout table and the 1.0.0 release note all state that no tile is ever drawn across the crease. That was true only for a division running across the display — a flip-style fold, where the break is between rows — because the split was gated onFoldRegion.isHorizontal. A book-style fold runs down the display, so the gate rejected it and the widget fell through to a plainGridView.count: on the shape most foldables actually are, every tile was drawn straight through the hinge. Each side of a vertical crease now works out its own column count, because the crease does not sit at the midpoint: a single count taken from the whole width overflows each side, and one taken from the narrow side leaves the wide side cramped. -
BifoldGridstarts both panes at the top of the box. The Row that holds them centres its children by default, and the near pane is a fixed-width box around a shrink-wrapped grid, so it floated to the vertical middle of whatever the far pane measured — 24 logical pixels below the far pane's first tile with twenty tiles, and a third of the way down the display with three. Tiles stopped at the crease either way, so the straddle tests were blind to it. The Row is nowCrossAxisAlignment.start, which fixes the alignment without forcing a tight cross-axis size on the children the waystretchwould. -
The
BifoldGridclass comment says what it actually needs. It claimed the widget falls back to a plain grid when it cannot split, which is true of one guard and was written as if it were true of all of them: with an unbounded axis — the widget inside aSingleChildScrollVieworListView— Flutter's own viewport assertion fires before either path here can run. That is not a defect this release introduced and not one it fixes; it is a constraint, and the comment now states it plainly, with the two messages observed (Vertical viewport was given unbounded heightand... unbounded width, one per axis) and the workaround, instead of implying a fallback that never happens. The guard itself is now one predicate in front of both branches rather than an axis-specific check inside each: the two splits need the same thing, and stating it twice is how they came to disagree. -
iOS now advances
capabilityRevision. The plugin stamped every fold payload with a constant zero, so Dart queried capabilities exactly once — at whatever the first payload happened to say. The gating that exists to avoid re-querying at sensor rate then suppressed the second query, which is the one that would have carried a real answer. A foldable iPhone that had not yet moved therefore sat withstatusOf(FoldFeature.fold) == unknownandhasFoldfalse for the life of the process, and nothing could ever promote it: exactly the conflation the tri-state model exists to prevent, since a consumer readinghasFoldcannot tell "not yet known" from "no fold". A newCapabilityRevisionTrackerderives the revision from the only twoCapabilityReaderinputs that can change within a process — whether a hinge and whether a division have been seen — and bumps it only when one of them does. The revision is stamped after the reader's own observations rather than passed in ahead of them:readRegionsis what discovers a division, so computing it first reported a division found on the last event of a gesture under the previous revision, which is a change Dart's gating then suppresses — leavinghasSeparatingFoldunknown until the device moves again. A fold in progress still costs zero round trips. -
Both native sides now settle the rear-display payload with
isResolved. Neither writer sent the key, and Dart reads it with== true, so the settled availability state was unreachable on both platforms:hasRearDisplayand the per-mode statuses worked, butRearDisplayAvailability.nonecould never be produced, and a consumer gating its first frame on resolution waited forever. iOS is authoritative — the SDK either exposes the capture-accessory API or it does not — so it reportstrueon every path. Android reportsfalseuntil the first window-area callback andtrueafter, which is the same "not yet reported versus reported nothing" distinctionCapabilityReaderalready draws. The rule moved intoRearDisplayStatus, a pure type, so it is unit-tested on the JVM rather than only on an emulator. -
Android rebuilds the rear-display holder when it has to.
bind()recreated the readers and the window-area controller but notRearDisplay, while a real detach ends the session and drops the instance. After the first genuine detach and reattach,rearDisplaywas therefore null for the life of the engine: every status read fell through to the unresolved fallback, and neitherpresent()nortransferActivity()could run again. The rebuild is null-guarded rather than unconditional, becausebind()also runs for a configuration-change reattach that deliberately leaves a session alive — replacing that instance would have dropped a live transfer. -
The Android hinge sensor is released when the app is not on screen. It followed whether a Dart listener existed, not whether anyone could be reading it, so an app that held its subscription kept
TYPE_HINGE_ANGLEregistered for its whole background lifetime. It now follows a listener and whether an activity of the app is on screen, tracked throughApplication.ActivityLifecycleCallbacks— no new dependency, which is why this is a patch and not a design note. On screen isonActivityStarted/onActivityStoppedrather than resume and pause, because resume tracks focus: split screen before API 29, a translucent activity on top and picture-in-picture all leave a visible app paused, and gating on pause would release the sensor —HingeReader.stop()clearing the angle with it — while the app was on screen and foldable. A fold stops and recreates the activity, so a stop flaggedisChangingConfigurationsis skipped and its replacement's start is not counted: a configuration change is not a backgrounding, and a count that drifted on every fold would never fall back on the next real one. Acting on that stop would have reintroduced the mid-fold dropoutBifoldPluginalready documents as fixed once. Counting is only safe because a start sets the flag unconditionally — the skipped count is a correction to the total, never a reason to doubt the callback, since nothing but an activity coming on screen can produce it. The gate fails open throughout: it starts set, and only a real stop clears it. -
The deprecated iOS capture-accessory stream speaks below 27.1.
setAccessorySinkemitted only inside its#available(iOS 27.1, *)branch, so on an older OS the stream said nothing on listen at all —BifoldCaptureAccessory.availabilitydocuments that it emits on listen so a UI can bind unconditionally. There is no accessory to report there, so the value is a plainfalserather than silence. -
Two unguarded dynamic sends on iOS are now guarded.
ArrangementOraclecalled-setAxes:throughobjc_msgSendwithout aresponds(to:)check, andNativeApiProbecalled-reservedRegionsOfKind:options:the same way, while its siblingdescribeLiveRegionsguarded it. An OS that exposes the class but not the method raisesdoesNotRecognizeSelector, which aborts rather than throwing — from the diagnostic path, which documents itself as unable to crash the app. Both now guard, matching the calls around them.
Changed
@OptIn(ExperimentalWindowApi::class)is now applied on Android. The import was there and everyandroidx.window.areasymbol is@ExperimentalWindowApiatRequiresOptIn.Level.WARNING, so the whole rear-display feature compiled without anything acknowledging that it rests on an API that may change without a major version — andAPI_NOTES.mdalready claimed the annotation was present. Nothing about behaviour changes; the opt-in now matches the documentation.
Tests
- New Kotlin JVM tests for the two extracted rules —
RearDisplayStatusTest(unresolved before any report, settled after; each status spelling; a session outranking the report; a later report replacing an earlier one, including a report naming no area) andForegroundGateTest(a backgrounding clears the flag, a configuration change does not, the activity reborn after one does not drift the count, a second activity on screen keeps it set, and a start answering a skipped stop restores it even when the count said otherwise). - New Swift
testCapabilityRevisionTracksOnlyRealChanges, driving the extracted tracker directly becauseRunnerTestsbuilds a plugin with no registrar and cannot reach a real capability change. - New Dart contract tests: the rear-display payload a native side sends before
any report decodes to
unresolvedand one that carriesisResolved: truedecodes tonone; and a scan asserting both platforms' rear-display writers still name the key Dart reads strictly. anchoring_test.dartcovers both ways a crease can run rather than the horizontal one alone, and asserts that tiles land on both sides of it. The straddle check on its own would be satisfied by a grid that dumped every tile on one side without splitting at all, which is close to what the bug was. A second test pins the per-side column count, and a third pins the pane alignment the straddle checks cannot see: it reads the two panes' own top edges with twenty tiles and with three, because the centring was barely visible in one and a third of the display in the other.
Docs
-
Four claims in the README did not survive being checked against the source and were corrected.
BifoldGrid's "never" is the defect fixed above, and its section now says which way each crease breaks. The iOS cell of Open, half-open and closed ticked afullyOpenreading that no record in this repository contains — every iOS observation was taken shut or part-way open at 127.8° — so that row is now Closed and part-way open, with Flat open beside it marked as not recorded and a fourth entry added to the list of gaps, since the checklist step that would produce the reading is still unchecked. A quotation about using hinge data for animation rather than layout decisions was attributed to Apple but appears in no source recorded inAPI_NOTES.md. That file does quote Apple twice on the hinge angle — that its rate and granularity are system policy, and thatstatusshould be preferred over the angle when only closed, part-way open or open matters — but neither sentence says what the removed claim did, so it stays out, and the advice remains this package's own. And a blanket "verified against the SDK headers and artifacts" describedUISplitArrangement, whichAPI_NOTES.mdmarks unverified as a header symbol and which was found by runtime introspection instead. The count of rear-display situationsRearDisplayFakescovers is six, matching the six values it defines.doc/manual-tests.mdfollows: its rotate-while-half-open step now expects the grid to break along the new orientation too, since rotating a book-style fold is how the vertical path reaches a real device. -
The README's verification matrix legend now says where each kind of ✅ is recorded — simulator and emulator observations in
doc/manual-tests.md(iOS) andAPI_NOTES.mdunder Observed emulator behaviour (Android, with the AVD and the values read back), native symbols inAPI_NOTES.md, and automated tests in the CI workflow. The Android rows were never unbacked, but the old legend pointed every column atdoc/manual-tests.md, whose Android result column is still blank — which read as a claim nobody had made. Test counts corrected to 7 Swift and 27 Kotlin, and the requirements table now says plainly that only Flutter 3.44.4 has actually been exercised. -
SECURITY.mdsays which versions are supported — 1.0.2, and 1.0.1 / 1.0.0 / 0.2.0 / 0.1.0 not. It no longer implies that table is exactly what pub.dev has published (it is not, while 1.0.2 is still being prepared), and it no longer sends 1.0.1 users to theCHANGELOG"for the migration" when this release changes no published API. TheCHANGELOG's entries for 0.3.0 and 0.1.1 are still noted as releases that never happened. -
API_NOTES.mdnow records the two Android platform symbols the foreground gate introduced —Application.ActivityLifecycleCallbacksandActivity.isChangingConfigurations()— checked against the local SDK sources with line numbers, together with why the gate readsonActivityStarted/onActivityStoppedrather than resume and pause, and notes that neither needs a permission, which is what let the gate avoid a new dependency. -
The 1.0.0 entry's claim that every
FoldInfoFakesvalue reportsisResolved: truewas wrong:FoldInfoFakes.unsupporteddeliberately models a platform that has not answered and stays unresolved. -
Bifold.debugDescribeNativeApi()'s deprecation said it is removed in 0.4.0; the code and the 0.2.0 entry both say 2.0.0. The changelog now agrees. -
BifoldSplit's doc comment said the default fallback "stacks them"; the default isadaptive, as the enum it points at says.
Platforms
- iOS and Android. Nothing here has run on physical hardware on either platform. The two extracted rules — the capability revision tracker and the foreground gate — are unit-tested, in XCTest and on the JVM respectively. The changes around them are plugin wiring that nothing automated covers: rebuilding the rear-display holder on reattach, clearing the event sink on cancel, and the hinge gate's lifecycle callbacks all need a running engine, so they are source-verified and depend on an emulator or simulator run to exercise. The README's verification matrix records what has actually been observed there, and it still carries rows marked unverified — rear-display transfer, and ending a session on the emulator among them.
1.0.1 #
Packaging and project documentation. No change to any published API, and no change to behaviour on either platform.
Fixed
-
The published archive now carries an example.
.pubignoreexcluded the whole ofexample/— correct for a directory holding two native projects, but it also hidexample/README.md, which is the first path pana looks for when deciding whether a package ships an example. So pub.dev scored 0/10 for "Package has an example" while the example app sat in the repository, fully built and running on both platforms. That one file is now published; the rest of the directory still is not.Note the pattern it took:
example/with a trailing slash stops pub descending into the directory at all, so a negation inside it can never re-include anything. It has to beexample/*followed by!example/README.md.
Changed
example/README.mdis now an example, not the Flutter template. It was the untouched "A few resources to get you started if this is your first Flutter project" boilerplate, which is what pub.dev would have rendered as the Example tab. It now covers the minimum setup, the aspect-scoped accessors, a two-pane layout, the capability API, and what each tab of the example app demonstrates.
Added
-
The example app demonstrates how to read fold state, not just what it says. A new Reads tab counts the builds each accessor causes, so folding the device shows
Bifold.ofclimbing whileBifold.poseOf,hingeAngleOf,regionsOfanddisplayOfsit still until their own aspect moves. The same tab drivesBifoldArrangement.measure(), including therelease()it asks for — both were documented in the README and demonstrated nowhere.The app now also practises this.
HingeGaugetook a wholeFoldInfoand so rebuilt on every update, which for the one widget redrawn at sensor rate is the exact pattern the README warns against; it now takes theradiansandposeit draws, and its caller watches those two aspects. -
Project documentation:
CONTRIBUTING.md,SECURITY.md, and a Contributor Covenant 2.1CODE_OF_CONDUCT.md, plus issue forms and a pull request template under.github/.The device report form is the one that matters. Nothing in this package has run on physical hardware on either platform, so it asks for the output of
Bifold.diagnosticReport(), which poses were reported correctly, and what did not match — including the three rear-display paths that have never completed a round trip.SECURITY.mdstates the attack surface rather than implying one: no network code, no permissions, nothing persisted.
The archive grows from 152 KB to 158 KB, still well inside the 2 MB ceiling CI enforces.
1.0.0 #
A stable API, and the correctness pass that earns the version number. Six bugs below were each reproduced by a test before being fixed; those tests ship.
Breaking
BifoldSplit.fallbacknow defaults toBifoldSplitFallback.adaptive, which lays the panes side by side where there is room and stacks them where there is not. The old default,stack, was wrong for the case two-pane layouts exist for: a fold is only reported as active while the device is part-way open, so a flat open inner display took the fallback path and got two panes one above the other on a tablet-sized screen. Passfallback: BifoldSplitFallback.stackto keep the previous behaviour.FoldInfo.isResolvedis read strictly from the platform payload. A payload that omits the key now reportsisResolved: false. Both platforms send it. This matters because the old default oftruemeant a payload arriving before the platform could establish anything — the iOS view not yet loaded, Android before its first window-layout callback — decoded as a confident "there is no fold", which is the exact conflationisResolvedwas added to remove.- A platform with no native side now reports
FoldInfo.none, notFoldInfo.unsupported. Having no implementation is a conclusive answer, so it resolves.FoldInfo.unsupportedkeeps its meaning and is now only the pre-answer seed aBifoldScopeholds before anything has reported. Before this,FoldInfo.nonewas unreachable from the real platform path, so a layout that gated its first frame onisResolved— as the field's own documentation recommends — waited forever on web and desktop.
Deprecated
BifoldCaptureAccessoryis deprecated in favour ofBifoldRearDisplay, which covers the same behaviour on both platforms:present()replacesregister()plussetEnabled(true),end()replacesunregister(), and availability reports four states rather than a boolean. It keeps working and will be removed in 2.0.0.
Added
- Aspect-scoped fold access.
Bifold.poseOf(context),Bifold.hingeAngleOf(context),Bifold.regionsOf(context)andBifold.displayOf(context)each rebuild their caller only when that part of the state changes.Bifold.of(context)still depends on everything and remains the default. Measured before: 240 hinge updates across 30 fold-aware widgets produced 7170 builds, becauseFoldInfoequality includes the hinge angle and one inherited widget carried the whole state, so a widget reading onlyposerebuilt on every sensor sample. Bifold.listenable, aValueListenable<FoldInfo>for code outside the widget tree — a controller, a bloc, an animation. One object for the process, one platform subscription behind it, subscribed on its first listener and released with its last. Previously every adopter wrote this by hand overBifold.stream.- Rear display goes through
BifoldPlatform.rearDisplayStatus(),rearDisplayAvailabilityStream(),presentOnRearDisplay(),transferToRearDisplay()andendRearDisplay()are now part of the platform interface, soFakeBifoldPlatformcan drive them. Testing the four rear-display states used to mean hand-writingStandardMethodCodecplumbing against the unexported channel namedev.bifold/rear_display, and only one case could be covered per test file because the availability stream was a memoised process-global. BifoldScaffold.showNavigationBeforeResolved, defaulting to false: until the platform has answered it renders the body with no navigation rather than guessing, which stops an app launching on an open foldable showing a bottom bar for a frame and then swapping it for a rail.RearDisplayFakes— unresolved, unsupported, unavailable, available, presenting and both-modes — plusBifoldCapabilityFakes.dualScreen()andBifoldCapabilityFakes.bookWithTransfer(), the only profile offeringRearDisplayMode.transfer.FakeBifoldPlatformgained the affordances its own tests needed:emitRearDisplay,rearDisplayRequestsSucceed,rearDisplayCalls,holdNextRead/completePendingRead(for the seed-query-versus-stream race),emitError,foldStreamRequestsandfoldStreamIsSubscribed.RearDisplayAvailability.unresolved, andisResolvedonRearDisplayAvailability. Four statuses could not express "nothing has reported yet": both it and "this device cannot present" read asunsupported, so a UI had to hide its control before the platform had spoken.kBifoldRegularWidthBreakpoint, the single documented width at which this package decides two panes fit when no size class has been reported. 600 logical pixels, matching Material's compact-to-medium boundary.FoldInfo.capabilityRevision. Plumbing rather than something to read, and deliberately not part ofFoldInfoequality.- Fakes that can drive the paths the shipped ones could not reach:
FoldInfoFakes.flat(a resolved non-foldable phone — the most common device in any install base, and a caseFoldInfo.unsupportedcannot stand in for),FoldInfoFakes.silentHinge(a sensor present but never delivering), and size classes on the inner-display fakes soFoldInfo.isRegular, and thereforeBifoldScaffold's rail, can be tested at all. EveryFoldInfoFakesvalue that models a platform which answered reportsisResolved: true, because a consumer gating on resolution could not previously be driven out of its pending state by any fake the package shipped. The exception isFoldInfoFakes.unsupported, which deliberately stands in for a platform that has not answered at all and so keepsisResolved: false.
Fixed
- A rear-display session no longer ends when the device folds. On Android
the session was torn down from the activity's configuration-change path, and
folding is a configuration change — so the one gesture this package exists
to report closed the presentation it was presenting. Because
transferActivityToWindowAreacauses a configuration change by design,transferActivity()could not previously complete at all. - The hinge angle no longer drops to null mid-fold on Android. The sensor
listener was activity-scoped, so every fold unregistered it and pushed a
payload with
hingeAngle: nullin the middle of the gesture. It reads through aSensorManager, which needs aContextand not anActivity, so it is now engine-scoped and follows whether anyone is listening. - Android no longer reports an authoritative "no hinge sensor" on a device
that has one. The reader was built when an activity attached, so any
capability query before that found it absent and reported
unsupportedwith an authoritative source. - A capability can no longer regress from
unsupportedtounknown. A later report that merely explained why it saw nothing replaced a settled negative, turning "this device has no hinge sensor" back into "nobody knows". Only positive evidence may now overturn a negative. hasFoldcan no longer be true alongsideformFactorofnone. The form factor latched onnone— documented as "not a folding device" — and a later report proving a fold while the shape was still unknown left the two contradicting each other. Reachable in practice, because a missing plugin or a null payload absorbsBifoldCapabilities.nonebefore a real device reports in.BifoldCapabilities.rawis merged across reports rather than replaced. It is documented as the escape hatch for reading a capability a newer native build added, and reports do not all carry the same keys, so overwriting dropped what the previous one knew.BifoldScope.fakenow honours a changedcapabilitiesargument. It compared only the fold state, so pumping the sameFoldInfowith a different capability profile — the natural shape of a table-driven capability test — silently kept the old profile and the test passed while asserting the wrong thing.- Capabilities are no longer re-queried on every fold event. The stream
called
getCapabilities()per event, and the fold stream carries the hinge angle, so a fold in progress cost one platform round trip per sensor sample — measured at 60 events for 60 round trips and one useful emission. The native side now stamps each fold payload with acapabilityRevisionit bumps only on a real capability change, and Dart queries on that. - iOS no longer claims every iPhone is foldable. Until the first hinge
update the fallback was the phone idiom, so an ordinary iPhone on a release
carrying the fold APIs reported
isFoldable: true— and, because the display reading returnsouterfor any non-regular trait collection,pose: closedin portrait. It now answers from evidence, agreeing withCapabilityReaderinstead of guessing. - iOS no longer reports an error when its view has not loaded yet.
getFoldInforeturned aFlutterError, which Dart surfaced throughFlutterError.reportError, putting a red error in the console on a healthy launch. It now answers with an unresolved payload, as Android does. - Android reports
unsupportedfor rear display where the window extensions are absent. An empty window-area list was treated as "not yet reported" and stayedunknownforever on every non-foldable device, when it is in fact an authoritative negative. - The Android form factor no longer flips between
bookandflip. The hinge orientation was sticky while the rotation was read live at payload time, and the two only mean anything together, so an orientation observed in landscape combined with a later portrait rotation changed the device's shape. - The iOS fold stream can no longer be born dead. If the Flutter view had not loaded when Dart subscribed, the plugin emitted one unsupported payload and returned without creating an observation and with no retry — so the stream stayed open and silent for the life of the engine, reporting no fold forever on a real foldable. It now retries attachment until the view appears, bounded so it cannot keep a timer alive on a device with no fold.
- The iOS layout sentinel no longer leaks on hot restart. Re-listening
assigned a new observation without cancelling the old one, and
FoldObservationheld its sentinel weakly while the superview held it strongly — so the old sentinel could never be reached to remove it, stayed in the view hierarchy with a live layout callback, and double-emitted, one extra copy per restart. BifoldScaffoldno longer renders phone chrome on a wide desktop window.isRegulardepends on size classes that only a native plugin supplies, so at 1600x1000 with no native side it drew aNavigationBar. It now falls back to the window width againstkBifoldRegularWidthBreakpointwhen no size class was reported, and a reported size class still wins over the measured width.- iOS capabilities no longer claim OS facts as device facts.
halfOpenedPosture,coverDisplayandreservedRegionswere keyed off the running iOS declaring the fold symbols, so a non-foldable iPhone on a release that has them reportedhasCoverDisplay: true— and an iPad reportedfold: unsupportedbesidecoverDisplay: supported, which is not a state that exists. They are now gated on fold evidence, so a capability can never be more certain than the fold it depends on. - iOS no longer claims every iPhone is foldable. Until the first hinge
update the fallback was the phone idiom, so an ordinary iPhone reported
isFoldable: trueand, because the display reading returnsouterfor any non-regular trait collection,pose: closedin portrait. Fold evidence now comes only from an observed hinge or an observed division region. - Both rear-display surfaces work at the same time on iOS.
CaptureAccessory.onAvailabilityChangedis a single slot on a process-wide singleton, andBifoldCaptureAccessoryandBifoldRearDisplayeach assigned it directly — so whichever registered last silently deadened the other stream, which is the one thing deprecating rather than removing the accessory must not do. The plugin owns the slot now and fans out to both. - Rear-display availability no longer depends on a fold subscription.
refreshAvailability()was driven only by the fold stream's layout sentinel, so an app that boundBifoldRearDisplay.availabilitywithout also listening to the hinge got one value and then nothing, ever. The accessory has its own layout watcher now. - The iOS plugin tears down when its engine is destroyed.
detachFromEngine(for:)was never implemented, so the layout sentinel, the hinge interaction and — the live one — the arrangement oracle's child view controller stayed parented to a host whose engine was gone. - Android region bounds are translated into view coordinates.
FoldingFeature.boundsis window-relative while Dart documents, and iOS delivers, view-relative — soFoldRegion.isSeparating(viewSize), whichBifoldSplitkeys off, could decide a full-width fold did not span the box. The remaining gap (an embedded Flutter view that does not fill the content area) is marked// UNVERIFIEDand recorded inAPI_NOTES.md. - The Android presented engine is told it is resumed. Nothing called
lifecycleChannel.appIsResumed()for an engine hosted outside an activity, which can leave the presented surface never producing a frame. - The Android form factor no longer flips between
bookandflip. The hinge orientation was sticky while the rotation was read live at payload time, and the two only mean anything together. - The diagnostic report reads its
androidx.windowversion fromBuildConfiginstead of a hardcoded string, which would have kept reporting 1.2.0 after the dependency moved — in the one string whose whole purpose is to be accurate in a bug report. Bifold.diagnosticReport()no longer force-unwraps a hinge angle while formattingFoldInfo.
Packaging
- The published archive is 112 KB, down from 7 MB. A
.pubignorekeeps the README animation, the example app and the research notes out of it;pub publish --dry-runhad been reporting 0 warnings while shipping a 9 MB screen recording, so CI now fails if the archive grows past 2 MB. - The README's install snippet said
^0.1.0while the package was at 0.3.0. - The podspec carried its template metadata: version
0.0.1, summary "A new Flutter plugin project.", homepagehttp://example.com.
Hardening
- The diagnostic report now says when an expected fold symbol did not
resolve. This package resolves Apple's fold symbols by name at runtime so
that one binary builds on an older Xcode and still runs the fold path on a
new OS — but the cost is that a renamed or withdrawn symbol fails silently,
reporting "no fold" forever with nothing to distinguish it from an ordinary
phone.
UIHingeInteractionandUIHingewere still flagged beta on iOS when this shipped, which is exactly when a rename is most likely. On iOS 27.1 or later, a symbol that fails to resolve is now called out in the report as a bug in this package. - The native API probe can no longer crash the app it is diagnosing. It
invoked
reservedRegionsOfKind:options:with noresponds(to:)guard, unlike every other runtime call in the package. describeConstants()is removed. It useddlsymto look for globals namedUIViewReservedRegionKindDivisionand friends, whichAPI_NOTES.mdrecords as not existing — the kinds come from class methods instead. It answered a settled question, shipped in release builds, and put UIKit-prefixed symbol strings in the binary for an App Store scanner to flag.- The dead
PrivacyInfo.xcprivacyis removed. It was bundled by neither SwiftPM nor CocoaPods, which also made it an unhandled-resource build warning and swept it in as a source file. This plugin calls no required-reason API, so no manifest is required — and a dead one is worse than none. android/build.gradle.ktsno longer carries abuildscriptblock whose AGP and Kotlin classpaths nothing applied. Kotlin compiles via AGP 9's built-in support, which is now stated in the file rather than left to be discovered by whoever downgrades AGP.- The main-thread executor keeps one
Handlerand runs inline when already on the main thread, instead of allocating per dispatch and always costing a frame. - Every
androidx.window.areasymbol is@ExperimentalWindowApiatRequiresOptIn.Level.WARNING, which is why the rear-display feature compiled with nothing acknowledging it. The opt-in is now explicit.
Testing and CI
- The example's tests, including the three downstream consumers that are this
package's evidence that no app needs a
Platform.isIOSbranch, now run in CI. They had only ever run on a developer's machine. - A cross-language contract test reads
BifoldPlugin.swiftandBifoldPlugin.ktand asserts that the channel names, the method names Dart invokes and the payload keys Dart reads strictly all appear on both sides. Nothing could previously catch a native rename: the test that claimed to asserted that a constant equalled its own literal. - The example app finally demonstrates
BifoldRearDisplay, with arearDisplayMainentrypoint wrapped in its ownBifoldScope— a second engine is a separate isolate and does not inherit the scope frommain, so without one, content on the rear display could not react to the hinge. - The iOS native side is tested for the first time. The only Swift test in
the repo was the untouched Flutter template: it called
getPlatformVersion, which this plugin does not implement, and force-cast the resultingFlutterMethodNotImplementedtoString— so it would have crashed the moment anything ran it, and nothing ever did. It is replaced with six tests that run against the real plugin on a simulator and now run in CI. One of them asserts that no capability claims support while the fold itself is unestablished, which a non-foldable simulator is exactly the right device to prove. - The Android capability rules are unit-tested.
CapabilityReaderneeds aContextand so could not be built in a JVM test, which is why none of this was covered. The sticky observations and the revision rule are extracted intoCapabilityObservations, following the patternFormFactorsalready set, and tested without adding Robolectric: 13 Kotlin tests now, up from 6. They pin the rule the revision exists for — that 60 identical observations during a fold must not bump it. API_NOTES.mdhas an Arrangements section, which it had never had, coveringUIArrangementViewController,UISplitArrangement(recorded as runtime-only, with no header located),UIAxisandUIArrangementViewState— the riskiest iOS surface in the package, because it mutates the host view hierarchy. It also records the Android window-versus-view coordinate space, what a presented engine can and cannot see, and the experimental-API opt-in.doc/manual-tests.mdhas a rear-display checklist and a result column. It had claimed rear display was unsupported while the 0.3.0 changelog recorded it as emulator-verified.
Still not verified on physical hardware, on either platform. The iPhone
Duo ships on 2026-10-23. Everything above was exercised on the iOS simulator
and the pixel_9_pro_fold and flip-style Android emulators; see the
"what is verified where" table in the README.
0.3.0 #
Tagged and released on GitHub, but never published to pub.dev. Everything below reached pub.dev in 1.0.0.
Added — rear display
Content on the display that faces the rear camera, behind one API on both platforms.
BifoldRearDisplay.present(entrypoint:)runs a second Flutter engine whose content appears on the other display while the app stays where it is. On Android this isOPERATION_PRESENT_ON_AREA; on iOS it is the camera capture accessory. The entrypoint needs@pragma('vm:entry-point').BifoldRearDisplay.transferActivity()moves the whole app across. Android only — iOS reportsunsupportedand does nothing, rather than pretending.BifoldRearDisplay.availabilityand.currentreport both modes with a four-stateRearDisplayStatus:unsupported,unavailable,available,active. Four rather than a boolean because "this device cannot" and "it could, but not now" call for different UI — hide the control versus disable it.BifoldRearDisplay.end()ends whichever session is running.
BifoldCaptureAccessory is unchanged and still works. It remains the right
choice for iOS-specific code; BifoldRearDisplay is the cross-platform
surface over the same thing.
Known limitations
- Ending a presentation session is unverified. On the Android 37.2
emulator
close()produces noonSessionEndedcallback and the platform keeps reporting the area asactiveindefinitely. The second engine is destroyed correctly so nothing leaks, but the status staysactive. That value is passed through rather than replaced with anavailablethe platform is not reporting. A physical device may behave correctly. - Starting a session is verified:
availabletoactivewith real Flutter content, on apixel_9_pro_foldemulator. - Nothing here has run on physical hardware, on either platform.
0.2.0 #
Android foldables are supported. The same FoldInfo, the same widgets and the
same tests now work on both platforms, and consumer code does not branch on
which one it is running on.
Added — capabilities
A device answers two different questions now. FoldInfo says what it is
doing; BifoldCapabilities says what it can ever do.
Bifold.capabilitiesOf(context)in a widget,Bifold.capabilitiesoutside one, plusBifold.capabilitiesStream,Bifold.capabilitiesReadyandBifold.initialize().hasFold,hasHingeAngle,hasHalfOpenedPosture,hasRearDisplay,hasCoverDisplay,hasReservedRegions,hasSeparatingFoldandhasFoldOcclusion, on both platforms.- Every capability is really tri-state.
statusOf(feature)exposessupported/unsupported/unknown, because a boolean cannot tell "this device has no hinge" from "nothing has reported a hinge yet", and at startup those mean very different things.hasXis true only forsupported. BifoldCapabilities.unresolvedandBifoldCapabilities.none— both reporthasFoldfalse, and only one of them means no.sourceOf(feature)names the platform signal behind each answer, andBifold.diagnosticReport()prints the lot for a bug report. Nothing is transmitted; it returns a string.formFactor(book,flip,none,unknown; neverdualScreen, which fold orientation cannot establish) andrearDisplayModes.FoldInfo.isResolvedandFoldInfo.none, for the same reason: they differ fromFoldInfo.unsupportedonly in whether the platform has answered.FakeBifoldPlatform, so a test can drive the stream itself rather than reimplement a controller, andBifoldCapabilityFakesprofiles named after device shapes rather than products.- Three downstream examples under
example/lib/downstream/— a hinge-reactive creature, a two-pane layout and a rear-display camera — each written with no platform checks at all, with tests covering every device shape.
Added — Android
- Android implementation, in Kotlin, over Jetpack WindowManager
(
androidx.window1.2.0, pinned to the version Flutter's own embedding already resolves so the plugin cannot cause a conflict with the engine). FoldInfo.poseon Android fromFoldingFeature.State:FLATmaps tofullyOpen,HALF_OPENEDtopartiallyOpen.FoldInfo.regionson Android fromFoldingFeature, reported as aRegionKind.division.isActivefollowsisSeparating, which is the same thing the active division means on iOS.FoldInfo.hingeAngleon Android fromSensor.TYPE_HINGE_ANGLE(API 30+), converted from the degrees the sensor reports to the radians the API documents.FoldInfo.isFoldableon Android fromPackageManager.FEATURE_SENSOR_HINGE_ANGLE, which answers on a closed device, where no folding feature is reported at all.- Size classes on Android, derived from the current window against the
Material breakpoints (600dp wide, 480dp tall). Without these
isRegularwas always false andBifoldScaffoldrendered its compact layout on every Android device, tablets and unfolded foldables included. - An Android target in the example app.
API_NOTES.mdgains an Android section: every symbol with the artifact it was verified against, the observed emulator behaviour, and what remains unverified.
Fixed
- No more
MissingPluginExceptionin the console on platforms with no native implementation. Every Android, web and desktop launch previously loggedMissingPluginException(No implementation found for method listen on channel dev.bifold/fold_info)from the services library. Fold state was correct throughout and nothing threw, but the error looked like a broken plugin.EventChannel.receiveBroadcastStreamreports its own failedlistenthroughFlutterError.reportErrorrather than through the stream, so no error handling in this package could suppress it;foldInfoStream()now asks the method channel whether an implementation is registered and subscribes only if one is.
Changed
FoldInfo.fromMap({})now decodes toFoldInfo.nonerather thanFoldInfo.unsupported. A payload arriving at all means the platform answered, and the two differ only inisResolved. Code comparing a decoded value againstFoldInfo.unsupportedwill see a difference.Bifold.debugDescribeNativeApi()is deprecated in favour ofBifold.diagnosticReport(), which includes it alongside capabilities and current state. It still works, and is removed in 2.0.0.
Known limitations
-
hasCoverDisplayisunknownon Android, and will probably stay that way. Whether an app may run on a cover display is OEM policy, and there is no public query behind it. Reportingunsupportedwould be a stronger claim than the evidence allows. -
hasHalfOpenedPostureisunknownon Android until a half-opened posture has actually been observed. No static signal for it exists, and never having seen something is not evidence it cannot happen. -
Rear display reports capability and availability only. Actually presenting content on the second display is not implemented on Android; the iOS capture accessory is unchanged.
-
FoldInfo.displayisnoneon Android when no fold is visible. A folding feature is only reported for the display that has the fold, so seeing one meansinner; the absence of one means closed, or on a cover display, or not a foldable, and Android offers no documented way to tell those apart. -
FoldInfo.poseisunknown, neverclosed, on Android.FoldingFeaturehas no closed state and a shut device reports no feature, soclosedwould have to be inferred from an absence. -
Region margins are zero on Android. The platform reports hardware bounds with no clearance around them, so
frameandreservedRectare the same rectangle — unlike iOS, where the frame arrives with clearance built in. -
The capture accessory remains iOS-only. On Android its methods answer rather than fail, so shared code can call them unconditionally.
-
The hinge angle is passed through unvalidated on Android. The sensor is not range-checked by the platform, and readings outside 0..180 are delivered as they arrive — 270 was observed reaching Dart as 270°. Clamping without knowing a real device's convention would hide exactly the vendor differences worth characterising, so the raw reading is reported. Prefer
poseoverhingeAnglefor layout. -
Verified on the Android emulator only. No physical foldable hardware, on either platform.
0.1.1 #
Tagged and released on GitHub, but never published to pub.dev. The fix below reached pub.dev in 0.2.0.
Fixed
- Subscribing to the fold stream no longer logs a
MissingPluginExceptionon every Android, web and desktop launch.EventChannel.receiveBroadcastStreamreports a failedlistenthroughFlutterError.reportErroritself, so the stream now asks the method channel whether a native side is registered before it subscribes. Fold state was always correct and nothing ever threw; the error simply read as a broken plugin.
0.1.0 #
First release. Fold awareness for the foldable iPhone.
Fold state
FoldInfowithpose,hingeAngle/hingeAngleDegrees,display,regions,division,horizontalSizeClass,verticalSizeClass,isRegularandverticalBarEdge.BifoldScopeandBifold.of(context)to read it in the widget tree, plusBifold.currentandBifold.streamoutside it.posecomes from the platform's ownUIHingeStatus, falling back to region-derived state until the first hinge update lands.
Layout
BifoldSplit, a two-pane layout that aligns to the fold and falls back to stacking when there is nothing to split across.BifoldGrid, which never draws a tile across the crease.bifoldAnchorPoint, so dialogs and sheets land in a half rather than on the fold.BifoldDisplayFeatures, an opt-in bridge that republishes the fold asMediaQuery.displayFeaturesfor packages built against Android foldables. It steps aside automatically once the engine populates that field natively.
Platform geometry
BifoldArrangement.measure, which reports where the platform's own split arrangement would place two panes, measured from a real arrangement rather than modelled.
Camera
BifoldCaptureAccessory, which presents app content on the outer display during camera capture, in its own Flutter engine.
Tooling
BifoldDebugOverlay, which draws the reserved regions the platform reports.FoldInfoFakes, so fold-aware layouts can be tested on any machine with no simulator and no iOS SDK.
Platforms
- iOS only. Every other platform reports
FoldInfo.unsupportedand never throws. - Builds on Xcode 26.x and 27.0 — the iOS 27.1 SDK is not required, because fold symbols are resolved through the Objective-C runtime.