Bug log
Bug log
Section titled “Bug log”This file lists every real bug found while writing tests. Each entry names:
- the test that caught it
- what the user would have seen
- the commit that fixed it
The fix and its test always share a commit, so git show <sha> shows both.
| # | Area | Symptom | Caught by | Fix |
|---|---|---|---|---|
| 1 | Songs tab | The Songs screen drew a category chip strip, and the empty band behind it, even though Songs has only one feed. The if (widget.showTabStrip) guard in FeedTabsShell was lost in the 5a33f885 refactor, so the showTabStrip: false that CategoryScreen passes did nothing. |
category_screen_test “Songs paints no category strip”, feed_tabs_shell_test “a single-feed shell paints no strip at all” |
see git log --grep "bug-log #1" |
| 2 | API parsing | Any model that builds a JsonHelper from a nested object failed the whole page when the API left that object out or sent [] (for example features, location, links, user, data). The repository turned the TypeError into a generic error screen. JsonHelper now reads a missing or non-map value as an empty object. |
json_helper_test “a missing or malformed nested object” |
see git log --grep "bug-log #2" |
| 3 | Geo rules | A content item, section or subscription plan with an allow rule and an empty except list was added twice, so rails and plan lists showed duplicates. |
json_helper_test “allow with no exceptions -> shown once” |
see git log --grep "bug-log #3" |
| 4 | Hero slider | A slider with an allow rule was hidden in every country not on its except list (the opposite of content, sections and plans), and did nothing for listed countries. |
json_helper_test “checkGeoBlockSlider allow except US -> shown once” |
see git log --grep "bug-log #4" |
| 5 | API parsing | Several models read nested values as json['a']['b']['c']. If the API left out sections_paginator, its links, a list’s data, providers, pivot, or the analytics, cms, social_link or min_app_version objects in settings, parsing threw a NoSuchMethodError. The home page, collection page, artist archive, subscription list or settings then failed as a whole. /v2/settings is stored before it is parsed, so a bad copy also broke the cached-settings fallback. Those lookups are now null-aware (?[]); no existing null check was removed. |
home_page_model_test, settings_model_test, subscription_model_test, catalog_models_test (the “without …” cases) |
see git log --grep "bug-log #5" |
| 6 | Reactions | Opening a title as a signed-in viewer who had never reacted to it threw RangeError inside ContentReactionBloc: the reaction check reads list[0] and the API returns an empty list when there is no reaction yet. The like/love row never settled. |
content_reaction_bloc_test “a viewer who has not reacted yet has no reaction” |
see git log --grep "bug-log #6" |
| 7 | Stream limit | A viewer who hit the concurrent-stream limit from the player (starting the next episode) or from their purchases list was told “Content Geo Restricted” instead, and never saw the device-management guidance. geo_content_bottomsheet.dart declared a second class named StreamLimitBottomSheet, and those two screens imported that file. The imports now point at the real sheet, the duplicate file is gone, and a contract test fails on any new duplicate public class name. |
contract/duplicate_class_names_test |
see git log --grep "bug-log #7" |
| 8 | Subscription screen | Opening Subscribe before /v2/settings had been read — an offline cold start, or a failed settings call — threw Null check operator used on a null value while building the screen, so the viewer got an error box instead of the plans. ContentHelper.onlinePaymentEnable() dereferenced the payment version lists with !, and they are null until settings arrive. |
subscription_screen_test (every case builds the screen with default settings) |
see git log --grep "bug-log #8" |
| 9 | Content info sheet | The “more info” sheet’s title row overflowed its width (a hairline, but Flutter reports every overflow, so each open sent a layout error to Crashlytics and painted the striped overflow bar in debug). The title had a fixed 270.h width next to a close button; it is now Expanded, which also keeps long titles inside the row on narrow screens. |
content_info_bottomsheet_test (every case builds the sheet; an overflow fails the test) |
see git log --grep "bug-log #9" |
| 10 | Downloads | With “download over Wi-Fi only” on and no Wi-Fi, tapping a paused or queued download in Downloads (or on a series page) opened the “Waiting For WiFi” notice, and its Cancel Download button crashed the screen with Null check operator used on a null value. The notice is only ever opened by ContentHelper.checkDownloadSettings, which passes no index, and the button read index!. The index is not used by any handler in DownloadScreenBloc, so the button now falls back to -1, as the Pause row already did. |
download_bottomsheet_test “a DownloadStatus.download_enqueued / download_paused download can be cancelled from the notice” |
see git log --grep "bug-log #10" |
| 11 | Downloads | The download action sheet’s header row overflowed for any title longer than about 25 characters — visible as the striped overflow bar in debug, and a layout error sent to Crashlytics on every open in release. The title sat in a shrink-wrapping Column inside a Row, so it never wrapped. It is now Expanded, keeping the same left-aligned header. |
download_bottomsheet_test “a long title fits the action list on a small phone”, “a short title still starts at the left edge” |
see git log --grep "bug-log #11" |
| 12 | Download settings | Reaching Download Settings from the “Waiting For WiFi” notice and then tapping Remove all downloads → Remove crashed with Null check operator used on a null value. The screen takes its bloc as a route argument and the notice pushes DownloadSettingsRoute() without one; only the Downloads screen passes it. The screen now falls back to getIt<DownloadScreenBloc>() and closes that fallback in dispose, so no watchAll() subscription is left behind. A bloc it was handed is still never closed by this screen. |
download_settings_test “removing everything works when the caller passed no bloc”, “leaving does not close a bloc it was handed” |
see git log --grep "bug-log #12" |
| 13 | Watchlist | A watchlist whose name did not fit on one line overflowed its row — the striped overflow bar in debug, a layout error per frame in release. The row is a spaceBetween Row whose left half (thumbnail + name + count) shrink-wrapped, so the name never wrapped. Both copies of the row, phone list and tablet grid, now put the left half and the name column in Expanded. |
watchlist_screen_test “a long list name fits a small phone” |
see git log --grep "bug-log #13" |
| 14 | Search | The last result on screen was replaced by a loading placeholder whenever the API had a next page, so one match was always hidden — most visibly a single-result search whose only row disappeared. The list built itemCount: contents.length and returned the shimmer for index >= contents.length - 1. The shimmer is now an extra row past the end instead of a replacement. |
search_screen_test “every result is listed while more pages are waiting” |
see git log --grep "bug-log #14" |
| 15 | Search filters | Clear Filter in the advanced-search sheet cleared the search but left the filter chips on the search screen behind it, so the screen claimed filters were still applied to unfiltered results. It told the chip strip about bloc.selectedMetaList read in the same tap handler that had just queued the clear — a bloc processes events asynchronously, so that read still returned the old list. It now sends the empty list it means. |
advance_search_widget_test “it clears the chip strip on the screen behind it” |
see git log --grep "bug-log #15" |
| 16 | Profile & settings | Tapping Edit Profile in the Profile & Settings header crashed with Null check operator used on a null value whenever the signed-in user carried no profile yet — the window between signing in and /profiles answering, and any cold start where that call fails. The header already handled a null profile when labelling the action, but the tap handler read userProfileEntity!.id in its else branch. A viewer with no profile now opens the plain manage page, as a parent profile does. |
profile_settings_test “a viewer whose profile has not loaded yet can still tap it” |
see git log --grep "bug-log #16" |
| 17 | Localization | watchlist_name was declared twice in both intl_en.arb and intl_bn.arb. gen-l10n keeps the last value without warning, so the hint text on the “create watchlist” field silently became the label “Watchlist Name” instead of “Watchlist name …”. The duplicate is gone (the surviving value is the one that was already in effect, so nothing on screen changes) and a contract test now fails on any repeated key. |
contract/l10n_arb_test “declares every key once” |
see git log --grep "bug-log #17" |
| 18 | Localization | Bengali was missing fill_in_captcha, so the captcha warning on all three sign-in steps showed in English to Bengali viewers. It is translated now. The other two keys the parity test flagged, welcome and createdBy, were leftovers from the gen-l10n template that nothing referenced, and are deleted. |
contract/l10n_arb_test “every English message is translated” |
see git log --grep "bug-log #18" |
| 19 | Artist page | An artist whose latest_content the API left out — a newly added one, or one whose only title is unpublished — crashed their page with Null check operator used on a null value before anything was drawn. The header’s backdrop read meta.latest_video!.thumbnail_background. It now falls back to the artist’s own background image. |
artist_profile_test “an artist the API sent no latest title for still opens” |
see git log --grep "bug-log #19" |
| 20 | Playback | Starting a film before /v2/settings had answered threw Null check operator used on a null value from EncryptionHelper.playerHeader() — the KMS headers dereferenced security_players_referrer and security_players_cdn_auth_token with !, and both are null until settings arrive. The player opened to a crash instead of a picture. Those two reads are now null-tolerant; decrypt already passes an empty string straight through, so a viewer in that window gets an empty referrer rather than no player. No existing null check was removed. |
video_player_screen_test “the player still opens” |
see git log --grep "bug-log #20" |
| 21 | Quality gate | tool/quality.sh never formatted anything. Its “Format (changed Dart files)” stage used mapfile, which is bash 4; macOS ships bash 3.2, where the stage printed mapfile: command not found and then failed on an unbound variable — so the gate’s first check had never actually run on this machine. It now reads the changed files with a while read loop. |
Running tool/quality.sh on macOS |
see git log --grep "bug-log #21" |
| 22 | Crash reporting | The promo-popup banner was the one CachedNetworkImage in the app with no errorWidget. With none there is no error listener on the image stream, so a banner that failed to load threw an unhandled SocketException, which main() hands to FirebaseCrashlytics.recordFlutterFatalError — filed as a Fatal Exception. Viewers on a flaky connection were damaging the crash-free rate with an image, not a crash. A contract test now fails on any image with no error handling. |
contract/image_error_widget_lint_test |
see git log --grep "bug-log #22" |
| 23 | Downloads | The committed Hive adapter for CacheContentEntity was stale: it wrote contentAccessToken (field 22) but never read it back, so every downloaded title lost its KMS token on the next app start and stopped playing offline. The same adapter also bare-cast settlementAttempts (field 21), which threw a TypeError on any row written before that field existed. @HiveField(21, defaultValue: 0) plus a regenerate fixes both, and a contract test now round-trips a download through the real adapter and pins every field number. |
contract/hive_schema_test “a download survives a round trip” |
see git log --grep "bug-log #23" |
| 24 | Security | The sign-in path logged credentials in the clear, in release builds. print is not compiled out: on Android it lands in logcat, on iOS in the unified log. Between them the auth datasource, repository, bloc and widgets printed the login email with its password ("this is login email <email> <credential>"), the OTP with the password and email as a map, the social access token, the session token after a social login, the captcha token’s length, and every raw auth response body. EncryptionHelper also printed the HMAC signature that authorises the CDN and KMS. All 45 of those calls are gone, and a contract test now fails on any print in those seven files. |
contract/no_credential_logging_test |
see git log --grep "bug-log #24" |
| 25 | Crashes across the app | 38 places used a BuildContext after an await with nothing checking the screen was still there. The commonest was the play path — await ContentHelper.streamLimitReached(content) is a network call, and every caller then opened a sheet or pushed the player with a context that a viewer who pressed back mid-check had already disposed. Same shape in the download-storage check, the purchase-stream callback, the deferred deep link and the popup dialog. All 38 now check context.mounted (or the State’s mounted where the context is the State’s own) before touching the context, and the rule is promoted to an error in analysis_options.yaml so a new one cannot be merged. |
flutter analyze — use_build_context_synchronously is now an error, at zero |
see git log --grep "bug-log #25" |
| 26 | Home rails | A square rail whose CMS section does not ask for item titles overflowed by 22 pixels on every build — the striped bar in debug, a layout error per frame in release. SectionWidget.heightFunction() gave that case 100.v on a phone while squareCard draws itself at 120.v; the numbers agree for every other shape and for square with titles, so this one branch had been copied from the landscape case. |
section_widget_test “a ContentShape.square rail fits a phone” |
see git log --grep "bug-log #26" |
| 27 | Watchlist | WatchlistCollectionEntity.copyWith silently dropped four fields: thumbnail and poster were passed straight through with no ?? this., and contentIds and items fell back to []. The watchlist bloc calls it on every save and unsave — entity.copyWith(itemAdded: itemAdd) — so toggling a title blanked that row’s artwork and emptied the content ids the “is this saved” check reads. Found by replacing a type-only isA<WatchlistScreenCollectionLoadedState>() with an assertion on the payload. |
watchlist_collection_entity_test “changing one field carries every other one over” |
see git log --grep "bug-log #27" |
| 28 | Sign-in | The Bengali social-login failure message read ককারিগরি ত্রুটি — a doubled ক, not a word. It is কারিগরি (“technical”) everywhere else in the app, including intl_bn.arb. Every Bengali viewer whose social login failed saw it. Nothing caught it because the six UserAuthError assertions in the bloc test checked only the state’s type, never its message; those now assert the message. The string is hardcoded in the bloc rather than coming from intl_bn.arb, and LoginSocialEvent calls the real Google/Apple/Facebook SDKs before any seam, so no bloc-level test reaches this branch — the fix is unpinned. |
Not pinned; found while strengthening user_auth_bloc_test |
see git log --grep "bug-log #28" |
| 29 | Offline dialog | People on good internet got “You’re Offline”. DioApiProvider._send raised the dialog for every request that came back without an HTTP response, with no connectivity check: a receive timeout on a slow endpoint, a TLS-intercepting office Wi-Fi (badCertificate), and any throw inside the response pipeline (unknown, e.g. a decode failure). The screen often rendered fine from stale cache underneath. Separately, Reachability flipped the whole app offline on two “transport failures” — counting receive timeouts, counting a burst of parallel failures on one dead HTTP/2 connection as several votes, never forgetting old failures, and publishing before the recycled connection got a chance — after which RetryInterceptor stopped retrying, so nothing could prove the network was fine. |
dio_api_provider_offline_test “a receiveTimeout is a failed request, not being offline” (it used to pin the opposite), reachability_test “a burst of failures on one connection is one piece of evidence” and “the server is asked first, and the app stays online meanwhile”, offline_notifier_test “stays down while the app believes it is online” |
see git log --grep "bug-log #29" |
| 30 | Offline dialog | Six places put up their own no-internet dialog behind three separate visibility flags plus one with none (ContentHelper.guardOffline), so two could stack. All six now go through OfflineNotifier. |
offline_notifier_test “a blocked tap while the notice is up does not stack another”, “the notice can only be left by retrying” |
see git log --grep "bug-log #30" |
| 31 | Player | Closing the player while the film was playing left a one-second timer running for the rest of the session, one more for every film opened: TotalPlayTime.start() arms a periodic timer on playing, and _VideoPlayerScreenState.dispose cancelled its three other timers but never stopped this one. |
pages/content_pages/video_player_screen_test.dart: closing the player while the film is playing stops the play-time clock |
see git log --grep "bug-log #31" |
| 32 | Ads | When an ad break was announced and no ad arrived, the player stayed in ad mode for good: no seek bar, no settings, taps ignored, the film paused behind a back button. contentPauseRequested set showingAds and only a later ad event could clear it; on iOS kVideo printed the IMA error and sent nothing. A late Android adProgress after the film resumed could re-enter the same state. The break now also ends on a fetch error, kVideo reports the error as a resume, and the app gives up on a break that starts no ad within ten seconds. |
widgets/player/ad_lifecycle_test.dart: an ad that never shows after the break was announced gives the screen back when the film plays on; chorki_player_controller_ads_test.dart: a break that never starts an ad gives the screen back and resumes the film |
see git log --grep "bug-log #32" |
| 33 | Ads | Backing out of the player during an ad carried ad mode into whatever played next on the shared controller — the next title, or the content page trailer, opened with the ad overlay and dead gestures. Nothing reset showingAds when media was stopped or replaced. |
widgets/player/ad_lifecycle_test.dart: after an ad the next title starts as content, not as an ad |
see git log --grep "bug-log #33" |
| 34 | Ads | In a break of two ads the film’s controls flashed up between them: complete ended ad mode and the next started began it again. The break, not the single ad, now owns the screen. |
widgets/player/ad_lifecycle_test.dart: a mid-roll with two ads keeps the screen between them |
see git log --grep "bug-log #34" |
| 35 | Player | The full-screen volume and brightness sliders overflowed their column by a fraction of a pixel on every layout: the column was height / 2.56 and its slider height / 2.97 plus a 20-point icon, two fractions that do not add up. |
widgets/player/ad_chrome_consistency_test.dart: the film's own controls fit the screen in full screen |
see git log --grep "bug-log #35" |
Flagged, not changed
Section titled “Flagged, not changed”These are covered by tests that pin today’s behaviour. Each one is a product decision rather than a defect with an obvious right answer, so none of them has been changed.
| Area | What the tests pin | Why it is worth a look |
|---|---|---|
Entitlement (ContentHelper.isUserHasPermission) |
A signed-in viewer with no profile loaded yet is refused every title: the whole content page is replaced by “Sorry! This video isn’t available for you.” A profile with maturityLevel of 0 is refused everything too, because the check is maturityLevel > 0 before it compares ages. A title the API sent no content_rating for is refused as well. |
The live API sends a rating on every item (min_age: 0 for “G”) and a profile on every signed-in user, so none of these is visible today — but each is one API change away from locking viewers out of the catalogue, and DownloadVisibility already reads maturityLevel == 0 as unrestricted, the opposite of this. single_content_screen_test → “the entitlement gate”. |
print inside the matomo-tracker fork |
Nothing pins it — the call is in a git dependency, not in lib/, so contract/no_print_in_release_test (which walks lib/ only) cannot see it. Found while tracing test-run noise: it is ~126 lines of a full suite run. |
traceable_widget_mixin.dart:128 in the pinned fork does print("MatomoTracker not initialized, skipping tracking for $actionName"). That is the same class as #24 — print is not compiled out of a release build, so on Android it lands in logcat and on iOS in the unified log, once per traceable widget built. It leaks screen names rather than credentials, so it is far milder, but the fix is one word (debugPrint) in a fork we control. |
| Paging mutates the entity it already emitted | Two blocs append a page by mutating the list inside the entity they last emitted and then re-emitting that same instance: watchlist_screen_bloc.onRemoveContentFromWatchlistCollection does prevWatchlistContents.contents.removeAt(i), and search_screen_bloc does .addAll(...) at three places (lines 143, 153, 310). The tests pin only the final state. |
Both recorded states share one object, so the “before” state retroactively shows the “after” list — a test cannot assert what was on screen before the page arrived, and two such assertions had to be dropped (see the comments in watchlist_screen_bloc_test and search_screen_bloc_test). In the watchlist case itemCount is also left stale, so the entity says 2 while holding 1. Nothing visibly breaks today, but any rebuild that compares the old state to the new one is comparing an object with itself. |
| A section size the app does not know | SectionModel.fromJson maps portrait, landscape, square and rounded, and sends every other value — a new CMS shape, a typo, an empty string — to ContentShape.hero_slider. It only swaps items for sliders when the value is literally "hero_slider", so an unrecognised size keeps its items and carries no sliders. AdsSectionWidget therefore hands it to SectionWidget, and PosterCard has no hero_slider card: every item is an empty Container. Pinned by section_model_test “a size it does not know keeps its items and reads no sliders” and section_widget_test “a section whose size it does not know draws its heading and no cards”. |
On screen that is a heading and “View More” over a strip of invisible cards. The captured API sends only the five known values today, so nothing is broken now — but the first new shape the CMS adds becomes a blank rail on the home feed rather than a fallback. What an unknown shape should draw is a product call. Found because two “fits a phone” layout tests for hero_slider were measuring those empty boxes and could never fail. |
| Cast remote poster fallback | CastExpandedRemote._artwork uses content.poster only; the thumbnail and background fallbacks were commented out in 310ddccd. A title with no poster draws no poster card, while the blurred backdrop still falls back through the thumbnail. Pinned by cast_expanded_remote_test “falls back to the thumbnail when there is no poster”, which now checks the backdrop and the missing card instead of only that nothing threw. |
Probably deliberate — a 16:9 thumbnail in the 2:3 card would crop badly — but the doc comment on _artwork still promises that “a missing poster never leaves a bare grey placeholder”, and the test kept its old name and passed through the change because it asserted nothing about the picture. |
Dead BlocProviders on the content page |
SingleContentScreen and ContentDataWidget each wrap a DownloadScreenBloc provider whose create sends GetDownloadList / CheckContentEvent. BlocProvider is lazy and nothing under either one reads that bloc from the context, so neither event is ever sent. |
Harmless today — the download button watches the DownloadManager directly — but the code reads as if those events fire. single_content_screen_test → “it asks the download bloc for nothing”; content_data_widget_test → “it does not ask the download bloc anything”. |
| Filter chips announced without their label | SearchFacetBar._FacetChip wrapped its chip in Semantics(button: true, selected: …) above an InkWell and a Text, with no MergeSemantics. The flags and the tap action landed on one node and the label on another, so the node a screen reader focuses said “selected, button” with no name. The new GlassChip merges them; pinned by search_filter_sheet_test “a picked genre is a selected glass capsule with a tick”, search_screen_test “an applied facet is a selected glass capsule, not rose” and glass_test “it is one labelled, selectable button to a screen reader”. |
VoiceOver and TalkBack users heard which chip was active, but not what it filtered. Found when the first semantics assertion on the new chip failed with label was ''; the old chip had the same structure. |