A Flutter app for keeping track of the movies and TV shows you watch — what you've seen, when you saw it, who you saw it with, and what you thought of it.
Titles and artwork come from TMDB. Accounts, your history, your lists, and your friends live in Firebase. The app runs on Android, iOS, macOS and Windows, laying itself out from the width of the window rather than the device it is on (see Platforms and Layout).
- Mark movies and TV shows as seen, and count rewatches (
lib/seen.dart,lib/common/firebase/watched_service.dart). - Keep a watchlist of things you mean to get to (
lib/watchlist.dart). - Keep favorites separately from the watchlist (
lib/favorites.dart). - Every media tile carries badges for the lists it is already on — a heart for
a favorite, a bookmark for the watchlist — so search results, filmographies,
playlists, the calendar and friends' profiles all say what you have already
saved. The Favorites page drops the heart and the Watchlist page drops the
bookmark, since there every tile would carry one
(
lib/common/item_container.dart,lib/common/media_pair_membership.dart). - Rate a title and write a review. Movie and TV reviews are stored separately
(
lib/reviews.dart,lib/popups/rating_popup.dart). - Log what you watched on a calendar — a single date or a date range. A show
entry can also record the last episode you finished that day, which drives
tracking rather than just being written down: the show moves into progress
and every episode up to that point is ticked off. That backfilling is a
setting — turn Fill in earlier episodes off in settings and an entry
records only what it names, which is what someone who joined a long-running
series partway through needs. A show you have already finished gets those
ticks too but keeps its finished status, so the season guide reflects the
entry without the title leaving the Seen list. An entry that names no season
still means the whole title was watched, which is what every entry meant
before episodes were recordable (
lib/calendar.dart,lib/friends_calendar.dart,lib/popups/add_to_calendar_pop_up.dart,lib/common/calendar_episode.dart,lib/common/calendar_progress.dart). - Track what you are part way through. A movie or show is not started, being
watched, or finished, and the control for moving between those states sits in
the icon row on each detail page (
lib/common/watch_progress_widgets.dart,lib/common/firebase/progress_service.dart). A finished show can be picked up again; a finished movie cannot, since rewatches are counted separately. A movie is set to being watched from that control and nowhere else — a calendar entry records a day you watched something, which for a film is a completed act, so the calendar always means finished there. - See when you watched something. A show's viewing history is headed with
the range it covers: the first day recorded through to the day it was
finished, or through to the present while it is still being watched. The
dates come from the calendar, so an account that predates watch progress
keeps the history it always had. A film has no such heading — it is watched
in one sitting, so a range would either repeat one day or draw two separate
rewatches as a single viewing years long; the days themselves are still
listed (
lib/common/viewing_history_range.dart,lib/common/viewing_history_widgets.dart). - Tick episodes and whole seasons off in the season guide. Watching the last
episode finishes the show on its own (
lib/season_guide.dart,lib/common/watch_progress_controller.dart). Specials (season 0) are not counted towards completion and offer no tick. - Pick up where you left off. The home page carries a Continue watching row of
everything started but not finished, most recent activity first, naming the
episode after the furthest one you have ticked. It counts from where you
actually are rather than from the start of the show, so joining a long-running
series at its current run does not send you back to season 1; when nothing has
been released past that point the row says Caught up instead of naming an
episode from the backlog. It is absent rather than empty when there is nothing
to resume (
lib/continue_watching_section.dart,lib/common/continue_watching.dart,lib/common/watch_progress_view.dart,lib/common/firebase/progress_service.dart).
- Build custom lists that can hold both movies and TV shows
(
lib/playlists.dart,lib/objects/Playlist.dart). - Share a list. Each list carries an access code, but new clients send the
list name and code to the
joinPlaylistCloud Function so the code is checked server-side instead of exposing every playlist to the device (lib/popups/list_join_popup.dart,functions/index.js). You can also grant access to specific friends directly (lib/popups/grant_access_dialogue.dart).
- Send and receive friend requests, with an inbox and notifications
(
lib/friends.dart,lib/inbox.dart,lib/notifications.dart). - Recommend a movie or show to friends, which lands in their notifications.
New clients send it to the
recommendTitleCloud Function, which checks the friendship against the recipient's own list and stamps the sender from the caller's token rather than believing what the sending device claims (lib/popups/share.dart,functions/index.js). - Look at a friend's profile, their calendar, and their thoughts on a title
(
lib/friends_profile.dart,lib/friends_calendar.dart,lib/friends_thoughts.dart). - Record who you watched something with, and see which of your friends have
already seen a title (
lib/seenTogether.dart,lib/popups/add_friends_seen_with_popup.dart,SocialService.friendsWhoHaveSeen). - Keep track of the series you are part way through together. Each friend on the
friends list carries a scrolling line of the shows that are started,
unfinished, and recorded as watched with them, and their profile shows the
same thing as a row of posters naming the episode to resume at. Films are
left out, and a friend with nothing running gets no line
(
lib/friends.dart,lib/watching_together_section.dart,lib/common/watching_together.dart).
- One search box across movies, TV shows, and people, using TMDB's multi-search
(
lib/search.dart). - Detail pages for movies, TV shows, and people (
lib/movie_result.dart,lib/tvshow_result.dart,lib/person_result.dart). - Full cast and crew (
lib/cast_and_crew.dart). - Season and episode guide for TV shows, with per-episode and per-season watch
ticks (
lib/season_guide.dart). - Trailers play inline (
lib/common/mediaitembuilder.dart). - Streaming availability, via TMDB's watch providers endpoint.
- Oscar badges on person pages — a win count for the person and a marker on
their award-winning titles. Data comes from the Firestore
Oscarscollection, which is populated bytools/sync-oscars. - Per-user recommendations for movies and TV
(
lib/common/firebase/recommendation_service.dart).
- Set a profile photo — pick from the device, crop it, upload to Firebase
Storage (
lib/profile.dart). - Viewing stats rendered as charts (
fl_chart, inlib/profile.dart). - Favourite actors, directors and writers, worked out from what you have
watched, favourited and rewatched rather than from whose page you happened
to open. A scheduled Cloud Function walks your library whenever it changes
and rewrites the three lists, so someone you have never looked up still
ranks (
functions/people_scores.js,lib/profile.dart). - English and Spanish, switchable at runtime from settings
(
lib/popups/settings_pop_up.dart). - Delete your account, from Settings, confirmed with your password
(
lib/common/auth_session.dart). See Deleting an account.
| Piece | What it's used for |
|---|---|
| Flutter / Dart | The Android and iOS app |
firebase_auth |
Email accounts and session restore |
cloud_firestore |
Users, history, lists, reviews, friends, playlists, Oscars |
firebase_storage |
Profile photos |
| Cloud Functions (Node 22) | Server-verified playlist joins, playlist membership sync, and friend recommendations |
| TMDB API v3 | Movie, TV, person, search, credits (a show's aggregated across every season), videos, watch providers, genres |
table_calendar |
The watch calendar |
fl_chart |
Profile stats |
youtube_player_flutter |
Trailer playback |
image_picker + image_cropper |
Profile photo capture |
cached_network_image |
Poster and backdrop caching |
path_provider |
Locates the on-device file the media sort cache is kept in |
flutter_localizations + intl |
EN/ES localization |
State is handled with plain StatefulWidget, setState, and
FutureBuilder/StreamBuilder. There is no state-management package.
Android, iOS, macOS and Windows.
| Platform | Status |
|---|---|
| Android, iOS | Shipped to testers by CI on every merge to master |
| macOS, Windows | Built by CI on every merge; downloadable from downloads.uractor.com once released |
| Linux | Not a target — see below |
| Web | Not a target |
The desktop apps go through the same two pipelines as the mobile ones, but with no store in the middle: an internal build is an installer attached to the CI run, and a production release publishes it and updates the downloads site. They tell users about new versions themselves, because there is no store to do it for them. See Releasing the desktop apps.
Firebase is where the account, lists, history and friends live, and there is
no part of the app that works without it. firebase_core, firebase_auth,
cloud_firestore and firebase_storage all declare android, ios,
macos, web and windows, and none of them declares linux. A Linux build
would compile and then fail at the first read, so it is left out deliberately
rather than shipped broken. DefaultFirebaseOptions says so if you try.
If FlutterFire ships Linux support, adding it is flutter create --platforms=linux . plus whatever the capability checks below turn out to
need.
Two plugins have no desktop implementation. Neither is hidden everywhere to
suit them; each is handled where it is used, and the decision lives in
lib/common/platform/capabilities.dart as a pure function of the platform so
that the answer for Windows can be tested from a machine that is not Windows.
| Feature | Gap | What happens instead |
|---|---|---|
| Cropping a profile photo | image_cropper is Android, iOS and web only |
The picked photo is uploaded uncropped |
| Playing a trailer inline | the player is a webview, and there is none on Windows | A link out to YouTube |
downloads.uractor.com is the whole of
web/downloads/, deployed to Firebase Hosting. Three files, no build step:
index.html is served exactly as it is committed, releases.js decides what
to offer and page.js puts it on the page.
It asks the GitHub releases API for the answer when someone opens it, rather than being generated during a release. A generated page can only describe the version that was current the last time the release workflow ran, and only that one: re-tagging a build, deleting a bad release or publishing a hotfix by hand all left it advertising something untrue with no way to correct it short of running another release. Reading the API means the page shows what is actually downloadable, and every earlier version comes along in the same response, so it can offer those too.
The installers are not hosted here. They are GitHub release assets, because Firebase Hosting bills per gigabyte past its free tier and these files are over a hundred megabytes each.
Three things it does that are worth knowing before changing it:
- It leads with the platform the visitor is on, and with neither on a phone — an iPhone's user agent says "like Mac OS X", so a careless match offers a 150MB disk image to a device that cannot open it.
- Each platform falls back to the newest release that actually carries its installer. macOS and Windows are built by separate jobs on separate runners, so a release can exist with only one of them attached.
- When the API cannot be read — it allows sixty requests an hour per address,
shared by everyone behind it — the page falls back to
version.json, which is written at release time bytool/build_download_manifest.pyand served from the same origin. That covers the current version only, and the page says so rather than pretending the older ones do not exist. With both gone, every button still leads to the GitHub releases page.
The page is English only, unlike the app. That is a real gap rather than an
oversight: it is a single page of prose with no localization machinery behind
it, and wiring up .arb files for a static page is a larger change than adding
Spanish text to it.
Because nothing generates it, it does not need a release to be correct.
.github/workflows/deploy-downloads.yml publishes it on any push to master
that touches web/downloads/ or firebase.json, and can be run by hand from
the Actions tab. That workflow rebuilds version.json before deploying — a
Hosting deploy replaces the whole site, and the manifest is not in the
repository, so deploying a page change without it would delete the file every
running desktop copy polls for updates.
- macOS builds through CocoaPods, as iOS does. This is pinned in
pubspec.yamlunderflutter: config:rather than left to whateverflutter configsays on a given machine, so a fresh clone and CI agree. - The macOS sandbox denies outgoing connections unless asked. Every screen
here is network backed, so
com.apple.security.network.clientis set in bothmacos/Runner/Release.entitlementsandDebugProfile.entitlements. Without it the app launches to a permanently empty window. lib/firebase_options.dartgives Windows and macOS their own entries, both authenticating with a key that carries no application restriction. That is not laziness about locking the key down; it is the only kind of key a desktop build can use. See Desktop needs a key it can actually use.
A Google API key can be restricted to the callers allowed to use it, and every key this project had was: the web key by HTTP referrer, the Android key by package name and signing certificate, the iOS key by bundle identifier.
A desktop app satisfies none of those. It sends no referrer, no package name and no bundle identifier, so Identity Toolkit answers 403 before the password is looked at — every account, every correct password, alike. Both desktop platforms were shipped pointing at a key of this kind, and neither could sign anybody in:
| Build | Borrowed | Refused with |
|---|---|---|
| Windows | the web key | API_KEY_HTTP_REFERRER_BLOCKED — "Requests from referer <empty> are blocked." |
| macOS | the iOS key | API_KEY_IOS_APP_BLOCKED — "Requests from this iOS client application <empty> are blocked." |
Both now use a single key created for desktop with application restrictions
set to none, which is the only setting a caller that cannot identify itself
can meet. It is restricted by API instead — Identity Toolkit, Token Service,
Firestore, Installations and Storage — and what protects the data behind it is
firestore.rules, not the question of who may hold the key. That is the
ordinary arrangement for a Firebase client key, all of which ship inside the
binary and none of which are secret.
Two things follow, and test/firebase_options_test.dart holds both:
- Never point a desktop platform at another platform's key to avoid adding one. That is exactly the defect this replaced.
- Re-running
flutterfire configurewill undo it. It writes back the restricted keys it finds registered in the console, and the desktop key is not one of them.
The interface reported this as "Something went wrong. Please try again." — advice that could not work, for a failure retrying can never clear.
classifyAuthError in lib/common/auth_error.dart does recognise a refused
caller, but only by the reason token, since the prose beside it is localized
and rewordable. On macOS the token survives inside a wrapped internal-error
and the interface names the failure properly. On Windows nothing survives:
firebase_auth there is the Firebase C++ SDK, whose default branch
flattens anything it cannot classify into
code: unknown-error
message: An internal error has occurred.
The reason token, the status and the server's own message are all discarded before Dart sees them. No change to the classifier can recover what the SDK threw away, so on Windows a refused key is indistinguishable from any other fault — which is why the guard is a test on the configuration rather than a better message at the point of failure.
Dropping the caller restriction does not expose the data. firestore.rules was
always the thing guarding that, and a restriction on who may hold a key never
was. It widens one specific gap instead, in sign in itself: anyone holding the
key can put an address to Identity Toolkit and read the answer, and if that
answer distinguishes "no such account" from "wrong password", the key is an
oracle for whether a given person has an account here.
Email enumeration protection is turned on for this project, which is what
closes it. Firebase stops sending user-not-found and wrong-password and
sends invalid-credential for both, so the two cases become one answer that
says nothing about who is registered.
The cost falls on the interface, which is why it belongs here rather than only
in the console. A failed sign in genuinely cannot say which half was wrong, so
its message — invalidCredentialError, "That email and password do not match
an account." — is deliberately vaguer than the two it replaces. The vagueness
is the point and not an oversight: rewording it to guess at the likelier half
would hand back precisely what the setting withholds. classifyAuthError still
maps the two older codes, since the setting is per project and one without it
goes on sending them.
Unlike Android and Windows, a macOS build is signed with a real identity
rather than ad hoc, and the machine building it has to be registered for a Mac
development profile in team Q8XY8276AC.
This is not incidental. Firebase Auth keeps the signed in session in the
keychain, and a sandboxed macOS app is refused the keychain unless it declares
a keychain-access-groups
entitlement.
That entitlement can only be granted by a real signing identity, so without it
the app builds and runs but nobody can sign in — every attempt fails with
firebase_auth/keychain-error regardless of the password.
The first build on a new machine fails with "Device … isn't registered in your
developer account". Open macos/Runner.xcworkspace, select the Runner target,
go to Signing & Capabilities and click Register Device.
If you are changing this, three things that look like fixes are not: turning
the sandbox off, signing ad hoc with a team set, and matching the bundle
identifier to the one in FirebaseOptions. All three were tried; the keychain
access group is the actual requirement.
The API key above was one of two reasons a macOS build could not sign anybody
in, and fixing it does not clear the other. The keychain entitlement is still
required, and the two are easy to confuse because both refuse every correct
password. Tell them apart by the error rather than by guessing:
keychain-error is the entitlement; a wrapped internal-error carrying
API_KEY_IOS_APP_BLOCKED was the key.
One loose end this leaves: the macOS entry still carries
iosBundleId: 'com.example.uractor', a template leftover matching neither
platform. It is untouched here because nothing observed depends on it, and
changing a bundle identifier alongside a key change would make a failure
impossible to attribute.
The app lays itself out from the width of the window rather than from the
device, so a desktop window dragged narrow gets the phone layout and a tablet
in landscape gets the desktop one. The rules are in
lib/common/layout/breakpoints.dart, which has no widget or network
dependency so the arithmetic is unit tested directly.
| Class | Width | Navigation | Panes |
|---|---|---|---|
| compact | < 600 | bottom bar | one |
| medium | 600–1024 | rail | one |
| expanded | 1024–1440 | rail | list and detail |
| large | ≥ 1440 | rail with labels | list and detail |
Two things follow from this that are worth knowing before changing a screen:
- Never size a tile as a fraction of the window. Doing so ties the tile's
proportions to the window's, which is only ever right on a phone held
upright. Posters are a fixed 2:3 and take a width in logical pixels from
posterWidthFor; episode stills are 16:9. Grids count how many columns fit withgridColumnsForinstead of hard coding a number. - A grid that caps a tile's width still needs a minimum column count. Use
gridColumnsForMaxTileWidth, which isgridColumnsFor's counterpart for a ceiling rather than a target, and never Flutter'sSliverGridDelegateWithMaxCrossAxisExtenton its own. That delegate cannot be given a floor, and a ceiling with no floor becomes a target on a narrow window — one column is always enough to keep every tile under the limit. A 360pt cap on the home page's playlist cards did exactly that on any phone 390pt or narrower, turning a two column grid into one column of cards at twice the height. - Count columns from the box, not from a
LayoutScopeyou did not create. Wrap the grid in aResponsiveRegion, which measures the space it is actually given, and read the width inside its builder.LayoutScope.widthOfwalks up the tree, so calling it from a screen's owncontextmisses the scope the two pane layout publishes further down and silently falls back to the whole window. The playlist grid did this and counted four columns for a 1210pt iPad while being drawn into a 565pt pane, so every card overflowed. The two failures above are the same mistake twice: deciding from a number that is not the width of the box. - Never size a panel as a fraction of the window's height either. The rule
above is usually said about width, but height is where it bites hardest,
because the app bar and the navigation rail have already taken their share
before the body sees any of the window. A month grid is as tall as its
header, its day-of-week strip and however many week rows the month has; a
row of cards is as tall as a card. Let the content say so — inside a scroll
view a widget with an intrinsic height needs no box at all, and a
horizontally scrolling row that has to have one takes it from
posterRowHeight, not fromMediaQuery. The calendar gave its grid 57% of the window and the sheet a day opens 37.5%; the first clipped any month needing six week rows, and the second clipped its cards on a short window and left a band of empty sheet under them on a tall one. - Open detail pages with
openDetail, notNavigator.push. It puts the page in the detail pane when there is one and over the whole window when there is not, so a call site does not have to know which layout it is in. A plain push still covers the window, which is right for a dialogue and wrong for anything a list points at.
LayoutScope publishes the width of a region rather than the window, so a
list occupying half a wide window sizes its contents to the half it has.
Inside a pane, read the size class from context.sizeClass, never from
MediaQuery.
Every popup is an AppDialog (lib/common/widgets/app_dialog.dart). It owns
the inset, the panel, the scrolling and the action row, so a popup supplies a
title, a body and a list of AppDialogActions and nothing else. Do not build
a Dialog or an AlertDialog by hand: twelve popups each did, and each
re-derived the same layout slightly differently wrong.
What the shell settles once, so no popup has to:
- The panel takes the width it is offered, up to a 560pt ceiling. A dialogue whose content never asks for a width sits at the framework's 280pt minimum instead, which on a phone throws away a third of the screen.
- Only the body scrolls. The title and the buttons are pinned, so a long form moves under a heading that stays put and the buttons cannot scroll off.
- The action row is an
OverflowBar, so it stacks when the labels grow rather than clipping. That is what makes it safe at a large text scale and in a language whose words are longer than English's. - The panel is the
Dialog's ownMaterial. Painting it with aContainerinstead puts theMaterialabove the list tiles, and their ink splashes have nothing to land on. - A busy action spins and stops answering. Set
busyon theAppDialogActionthat is running and the shell swaps its icon for a spinner and drops its handler, whether or not the caller also nulled it.
A save that leaves the dialogue open has to say it is running. Logging a
title is a dozen round trips — the user's calendar, every tagged friend's
calendar, both seen lists, the rewatch counter, the seen-with record, progress
— each waiting on the last. A dialogue that looks untouched for that long reads
as a tap that missed, and the second tap wrote the entry again. Mix
SingleSubmission (lib/common/single_submission.dart) into the state, call
submit instead of runVisibleAsyncAction, and drive busy and
dismissible: !submitting from it. The mixin ignores a call that arrives while
one is already running, so the guard does not depend on the buttons alone;
dismissible closes the two ways out that never touch them, the back gesture
and the barrier.
The same rule as tiles applies inside one: never give a list a fixed
height. A vertical list should shrink-wrap and let the dialogue scroll —
FriendPicker (lib/common/widgets/friend_picker.dart) is the shared one, and
it is what the four hardcoded 125pt friend lists became. Only a horizontal
strip needs to be told how tall it is.
ScrollingLine (lib/common/widgets/scrolling_line.dart) is a one-line label
that scrolls itself when it does not fit and sits still when it does. Use it
where a subtitle has to say more than its width allows and a second row would
push the thing above it around. Note that a scrolling line never stops
animating, so a widget test on a screen containing one must pump fixed
durations rather than call pumpAndSettle.
- Flutter 3.47.1. CI installs that exact SDK in
.github/actions/setup-flutter-android/action.yml, so match it locally: verifying against a different SDK is not verifying what CI builds.pubspec.yamlstill declares the wider Dart constraintsdk: ">=3.5.4 <4.0.0", but the checked-in toolchain pin is the one to match for local verification. - A Firebase project with Authentication, Firestore, and Storage enabled.
- A TMDB API key (get one here).
- Node 22 if you intend to deploy the Cloud Functions.
- For an iOS build, Xcode 16 or newer and CocoaPods. Firebase 12 requires both that and an iOS 15 deployment target. See Building for iOS.
flutter pub get
flutter gen-l10n
flutter run --dart-define=TMDB_API_KEY=your_key_hereThe --dart-define is required — see Configuration.
To run it as a desktop app, name the device:
flutter run -d macos --dart-define=TMDB_API_KEY=your_key_here
flutter run -d windows --dart-define=TMDB_API_KEY=your_key_hereBoth open wide enough to start on the two pane layout. Drag the window narrow to check the phone layout without a simulator — the app follows the window, so every breakpoint is reachable by resizing.
lib/firebase_options.dart is committed, and it points at the original
project (actordb-cf981). A fresh clone cannot write to that project, so
point the app at your own:
dart pub global activate flutterfire_cli
flutterfire configureThat regenerates lib/firebase_options.dart and the native config files. Also
update .firebaserc if you plan to deploy functions.
Committing this file is normal for FlutterFire: the values are project and app
identifiers, not secrets. Access is controlled by firestore.rules, not by
keeping them private.
The committed native config is partial. android/app/google-services.json is
present; there is no ios/Runner/GoogleService-Info.plist. That file is not
required to build or run the app today, because main.dart passes
DefaultFirebaseOptions.currentPlatform to Firebase.initializeApp, so iOS
configures itself from Dart rather than from a bundled plist. Anything that
reads the native config directly — APNs, firebase_messaging, App Check —
would need it added.
The iOS entry in firebase_options.dart is stale in one respect: it declares
iosBundleId: 'com.example.uractor' while the Xcode project builds
com.uractor.uractorios. Auth, Firestore and Storage key off the API key and
the app id, so the mismatch stops neither a build nor a sign in, but re-running
flutterfire configure against your own project corrects it along with the
rest.
The app expects each user to have a document tree keyed by their Firebase Auth
UID, including a Settings document. lib/objects/User.dart is the clearest
description of the shape it reads.
Apple dependencies are managed with CocoaPods on both platforms;
ios/Podfile, ios/Podfile.lock and macos/Podfile are committed. Flutter
reaches for Swift Package Manager first, which this project is not set up for
and which fails while resolving. That is now turned off for the project rather
than per machine, in pubspec.yaml:
flutter:
config:
enable-swift-package-manager: falseSo there is nothing to configure locally — flutter config --no-enable-swift-package-manager is no longer needed, and a fresh clone and
CI build the same way. Build as usual:
flutter build ios --simulator --debug --dart-define-from-file=dart_defines.json
flutter build macos --debug --dart-define-from-file=dart_defines.jsonThe first build resolves Firebase 12 and compiles gRPC and Firestore from
source. Budget around ten minutes and several gigabytes of free disk. A full
disk surfaces late and misleadingly here, as an rsync failure copying
grpc.framework into Runner.app rather than as an out-of-space message from
the compiler. If CocoaPods cannot find a pod version the plugins ask for, its
local index is behind: refresh it with pod repo update.
A simulator build needs no code signing. A device or release build does: the
project sets DEVELOPMENT_TEAM = Q8XY8276AC, so that Apple Developer account
has to be signed in to Xcode.
CI covers this on macOS runners. The Build and launch on a simulator job in
.github/workflows/pr.yml builds the app and launches it on a simulator for
every pull request, and the iOS stage of
.github/workflows/release-internal.yml ships to TestFlight. Both run on
macos-26, because Apple refuses uploads built with an SDK older than iOS 26
and the older image defaults to Xcode 16. The pull request workflow carries no
path filter at all: a check skipped by one is left pending rather than reported,
and a required check that never reports would block the pull request forever.
The iOS job is not required to merge yet, and not filtering it is what makes it
requirable. See docs/releases.md.
Run flutter clean after any change to ios/Podfile.lock. flutter build will not do it for you, and a stale build/ directory holding the
previous version of a plugin produces a compiler error about "different
definitions in different modules" that names neither the stale file nor the
pod that changed.
The key is not committed. It is read at build time from a --dart-define,
in lib/common/constants.dart:
// lib/common/constants.dart
const String TMDB_API_KEY = String.fromEnvironment('TMDB_API_KEY');
const String API_KEY = "?api_key=$TMDB_API_KEY";Pass it on every run or build:
flutter run --dart-define=TMDB_API_KEY=your_key_here
flutter build apk --dart-define=TMDB_API_KEY=your_key_hereIf the define is missing, assertTmdbApiKey() throws at startup with a message
naming the flag. That is deliberate — without it, every TMDB request comes back
401 and the app just looks empty.
test/constants_test.dart asserts that no endpoint hardcodes a key, so a
regression fails the test suite rather than reaching a release.
The same key is also needed server side, as a Firebase secret, because
recomputePeopleScores fetches credits without a device involved:
firebase functions:secrets:set TMDB_API_KEYUnlike OMDB this one is genuinely both: the app cannot route every TMDB request through a function, so the build define stays. Without the secret the function logs that it is not configured and returns, and the favourite people lists stop updating while the rest of the app carries on.
A key was previously committed to this repository and is still reachable in git history. That key has since been revoked and reissued at TMDB, so the one in history is dead. Keep passing the live key by define — never commit it, because removing it from the working tree does not remove it from history.
The recommendation feature reads its key the same way:
const String OPENAI_API_KEY = String.fromEnvironment('OPENAI_API_KEY');flutter run --dart-define=TMDB_API_KEY=... --dart-define=OPENAI_API_KEY=...Unlike the TMDB key this one is optional: with no define, the recommendation call is skipped rather than failing startup, since the rest of the app does not depend on it.
An OpenAI key was also committed and remains in git history. It has since been revoked at https://platform.openai.com/api-keys, so the one in history no longer works. Supply the current key by define only.
The IMDb rating shown on a title, and the "sort by IMDb rating" option, come from OMDB, which needs its own key. Unlike the TMDB and OpenAI keys, this key is not a Flutter build input and is never compiled into the app. It lives only in the Cloud Functions environment:
firebase functions:secrets:set OMDB_API_KEYThe app calls the authenticated omdbLookup Cloud Function with an IMDb id, and
the function returns only the rating and year the UI consumes. If the user is
signed out or the function cannot reach OMDB, ratings degrade to 0.0 and the
year to None rather than blocking the screen.
This key was committed as a hardcoded
defaultValuein the source and therefore reached every build shipped to Play. A key compiled into an app is extractable by anyone who downloads it, and it remains in git history regardless of the working tree. The replacement key must stay in the Firebase secret, not in Dart source or CI build defines.
Rather than retyping the defines on every command, keep them in a file:
cp dart_defines.example.json dart_defines.jsonFill in the real values, then:
flutter run --dart-define-from-file=dart_defines.json
flutter build apk --dart-define-from-file=dart_defines.jsondart_defines.json is gitignored; dart_defines.example.json is committed as
the template and holds only placeholders. The VS Code launch configurations in
.vscode/launch.json already pass the flag, so Run and Debug works with no
extra setup.
Prefer this over exporting shell variables: String.fromEnvironment reads
compile-time defines, not the process environment, so an exported variable has
no effect.
lib/
main.dart Firebase init, MaterialApp, locale switching, auth gate
firebase_options.dart Generated by `flutterfire configure`
login.dart signup.dart Auth screens
search.dart Multi-search across movies, TV, people
movie_result.dart Movie detail
tvshow_result.dart TV detail
person_result.dart Person detail, Oscar badges
season_guide.dart Seasons and episodes, with watch ticks
cast_and_crew.dart Full credits
list_result.dart A single list
seen.dart watchlist.dart favorites.dart playlists.dart reviews.dart
continue_watching_section.dart Home page "Continue watching" row
calendar.dart Watch calendar
seenTogether.dart Titles watched with a given friend
watching_together_section.dart "Watching together" row on a friend's profile
friends.dart friends_profile.dart friends_calendar.dart friends_thoughts.dart
inbox.dart notifications.dart
profile.dart Profile photo, stats charts
objects/ Media, Movie, TVShow, Person, Playlist, User
common/
constants.dart TMDB endpoints; the TMDB key is read from
--dart-define
utils.dart
continue_watching.dart Continue watching ordering and TMDB derivations
watching_together.dart Which shared shows are still in progress, and how
they group under each friend
item_container.dart mediaitembuilder.dart tabView.dart
watch_progress_view.dart Pure watch-progress rules
watch_progress_controller.dart Per-show episode tick state
watch_progress_widgets.dart The season, episode and detail controls
calendar_progress.dart What a calendar entry means for tracking
viewing_history_range.dart When a title was started and finished
viewing_history_widgets.dart The range shown above a show's history
playlist_grid.dart The home page's playlist grid, and how many
columns it fits into the space it is given
media_sort.dart Pure ordering rules for the media grids
media_sort_loader.dart Looks up what those rules need, from the
cache first and the network only for what
is missing
media_metadata_cache.dart The stored form of that cache: versioning,
the 30-day staleness rule, eviction
media_metadata_store.dart Reads and writes it, one file per account
api/apiutils.dart All TMDB HTTP calls; a show's credits come from
aggregate_credits and are flattened to the shape
/credits returns
api/tmdb_titles.dart Cached show-name lookups, for surfaces that want a
title and nothing else
navigation/ appbar.dart, app_scaffold.dart, destinations.dart
layout/ breakpoints.dart (pure window-size rules),
responsive.dart (LayoutScope, poster helpers),
two_pane.dart (list-and-detail, openDetail)
platform/capabilities.dart What each platform's plugins can do
firebase/ One service per domain: calendar, favorites,
OMDB lookup, playlist, progress,
recommendation, review, social, watched,
watchlist, plus account_deletion (everything an
account leaves behind), firestore_core and
firebaseutils
popups/ Dialogs: add to calendar, add friends seen with,
rating, share, settings, list add/edit/join,
grant access, movie add, tv add, profile sections
l10n/ app_en.arb, app_es.arb and their generated output
functions/ Cloud Functions (Node 22): OMDB lookup,
playlist join, member sync, join-attempt cleanup,
friend recommendations, favourite-people scores
firestore-tests/ Firestore rules tests against the local emulator
tools/sync-oscars/ Firestore Oscars sync job
web/downloads/ downloads.uractor.com: a static page that lists
the macOS and Windows installers by reading the
GitHub releases API in the browser
assets/ Logos, tab icons, the cover and person
placeholders, oscars_api.json
test/ Flutter tests
The Seen, Watchlist and Favorites grids store nothing but [type, id] pairs,
so ordering them by anything other than the order things were added means
looking every title up first. Sorting by IMDb rating costs more again: TMDB
details do not carry a rating, so each title needs an OMDB call, and a show
needs a third call to /external_ids because only films carry an imdb_id.
Those answers are now kept on the device, in a JSON file per account under the
app support directory, so a cold start reads them instead of fetching them
(lib/common/media_metadata_store.dart). The detail pages write through to the
same cache, since they already fetch exactly these fields — so a title you have
opened is usually free to sort by. Only what is missing is fetched, still six
requests at a time.
Three things are worth knowing about it:
- Your own rating is never stored. It is read from the signed-in user on every load instead. That is the only field belonging to a person rather than a title, so keeping it out of the file means no cache can show one account another's data — the file is also named per account, and deleted on sign-out, but neither of those has to work for that guarantee to hold.
- Entries last 30 days. Release dates get corrected and ratings drift. Anything older is simply fetched again by the same path that fetches a title never seen before, so there is no separate refresh to go wrong.
- The file records the language its titles were fetched in. A detail page asks TMDB in the user's language and the sort does not, and a grid ordered by a mixture of Spanish and English names would sort by whichever titles you had happened to open. A mismatched entry has its name refetched; its IMDb id and rating, which have no language, are kept.
A cache that cannot be read or written is never fatal — the app fetches exactly as it did before it existed. A file from an older version of the format is discarded rather than migrated, since everything in it can be fetched again.
functions/ holds seven gen 2 Cloud Functions for project actordb-cf981, all
running on Node 22 in us-central1:
omdbLookupis a callable function. It requires auth, accepts only a narrow IMDb id (ttplus digits), calls OMDB with theOMDB_API_KEYFirebase secret, and returns onlyimdbRating,YearandResponse.joinPlaylistis a callable function. It requires auth, normalizes the list name and access code, throttles repeated misses inJoinAttempts, queriesWatchlistsbyName, compares the submitted code on the server, and adds the caller asApprovedwhen it matches. The device no longer has to read every playlist just to test an access code.recommendTitleis a callable function. It requires auth, bounds every field of the payload, and for each recipient checks that the caller appears in that person'sFriendsdocument — the recipient's list, not the caller's, since anyone can write their own. It then appends one notification inside a transaction, withsendertaken from the caller's token and their ownSettingsdocument. The sending device used to assemblesenderitself and be believed, so a friend could file a recommendation in someone's inbox under a third party's name; it also rewrote the whole inbox unmerged, which lost one of two recommendations sent at the same moment. This is a verified route, not a closed door: builds already on people's phones still write toNotificationsdirectly, and that branch stays in the rules until they are gone. What those builds cannot do is forge a sender —validNotificationpinssender.uidto the authenticated caller, and the release deploys the rules on every merge that touches them, so that check is live rather than waiting for someone to runfirebase deployby hand. See Where a recommendation lands.syncPlaylistMembersis a FirestoreonDocumentWrittentrigger onWatchlists/{listId}. It derives a flatmemberUidsarray from the legacyUsersrole maps so clients can query their own playlists, and it exits when the projection is already current to avoid recursion.cleanupJoinAttemptsis a scheduled function that runs every 24 hours and deletes stale join-throttle documents.markPeopleScoresDirtyis a FirestoreonDocumentWrittentrigger on{uid}/{docId}. Every user owns a top-level collection named after their uid, so it has to watch the whole database and filter; a write to one of the six library documents sets a flag inPeopleScoreJobs/{uid}and everything else returns without touching Firestore.recomputePeopleScoresis a scheduled function that runs every five minutes and rebuildsFavActors,FavDirectorsandFavWritersfor the users flagged since the last run. See Favourite actors, directors and writers.
Deploy from functions/ with the Firebase CLI after authenticating to the
Firebase project:
npm install
firebase functions:secrets:set OMDB_API_KEY
firebase functions:secrets:set TMDB_API_KEY
npm test
npm run deploynpm run deploy is --only functions, so it leaves the rules and the indexes
alone. The release workflow does not: it deploys all three together on every
merge to master that touched functions/, firestore.rules or
firestore.indexes.json. See CI and releases.
A recipient's notifications live in one document, {uid}/Notifications, whose
keys are "0", "1", "2" and so on. That numbering is load-bearing rather
than cosmetic.
firestore.rules has to validate an appended notification — that sender.uid
is the authenticated writer, and that read starts false — and to do that it
has to read the value that was added. Rules cannot: diff() reports affected
keys as a Set, and a Set cannot be indexed. The way out is that the key is
predictable. Every writer appends at String(number of keys already present),
so appendsOneNotificationOnly rebuilds it as
string(resource.data.keys().size()) and reads the entry there.
So recommendTitle appends at exactly that key too, inside a transaction so
the count it read is the count it writes against. A uuid, a timestamp or a
random number would leave the document sparse; from then on the key the rule
rebuilds is one that already exists, a write from a build already on someone's
phone reads as a change rather than an add, and it is refused — silently,
because those builds swallow the error. For the same reason nothing deletes a
notification, and a document that is somehow already sparse is skipped rather
than written over.
The three lists on your profile used to be written by the person page: opening someone's profile fetched their filmography, intersected it with your library and saved the score. That made the ranking a record of whose page you had opened rather than of what you had watched. An actor you never tapped never appeared however many of their films you had seen, a score you did have stayed frozen at the moment of that visit, and unfavouriting a film never took the points back.
recomputePeopleScores runs it the other way round, over your own library, so
every person who could rank is reached whether or not anyone ever looked them
up:
markPeopleScoresDirtyflags you whenMovies,TVShows,Favorites,Watchlist,RewatchedorRewatchedTVchanges — including when a friend marks something seen for you.- Every five minutes the worker takes the oldest flagged users and reads each library in one round trip. The flag is cleared only once the scores are stored, and only when nothing re-flagged the user in the meantime — so a run killed by its timeout leaves the user queued rather than marked done, and a write that lands mid-run is picked up next time rather than lost.
- Each title's cast and crew is resolved through
Credits/{type}_{id}, a cache shared by every user: a title is fetched from TMDB once, ever. Shows use/aggregate_credits, since/tv/{id}/creditsonly answers for the newest season. An id TMDB does not recognise is cached as missing so it is not asked about again, and one that keeps failing for some other reason is written off on its fifth attempt, in the same run that then scores its owner — nothing is stored until every title resolves, so a single broken id would otherwise hold a whole library back forever. A rejected key is the one failure never blamed on a title: it stops the run and is logged as an error, because it would otherwise write off every title in the database. - Every title scores as it always did — 2 for having seen it, or the rewatch count when higher, 3 more when it is a favourite, 1 when it is only on the watchlist — and those points go to everyone the title credits.
- The three documents are written whole, not merged, so someone who has dropped out of your library loses their score instead of keeping the one they last had.
A library too large to resolve before the run's deadline is not written at all:
the run banks the credits it fetched, leaves you flagged, and the next run
finishes from a warmer cache. A ranking computed from half a library would be
wrong rather than incomplete. Deferring does not move dirtyAt, so a large
library keeps its place at the front of the queue and converges rather than
being overtaken every five minutes.
Both limits that shape the result were set against a real library of 4,593 titles rather than guessed:
- How many people are stored per role (
MAX_RANKED_PEOPLE, 2,000). That library credits 42,683 distinct actors with a non-zero score — about 630 KB in a document Firestore caps at 1 MiB — so a limit is necessary. It is also what decides where the person page's ranking goes flat, since everyone below the cut has nothing stored to separate them. At 2,000 the cut falls at a score of 16, roughly a face from eight titles. - How long a run fetches for (
RUN_DEADLINE_MS, 8 minutes of a 9 minute timeout). Throughput is around 30 credits a second, so that whole library fits inside one run.
The person page still computes its own score locally, because that is the only
value that accounts for a film marked seen since the worker last ran, and ranks
against the stored lists (lib/common/people_ranking.dart). It no longer
writes anything.
Accounts that predate all this have scores from the old person page and no job
record, so PeopleScoresService asks for one rebuild the first time such an
account loads (lib/common/firebase/people_scores_service.dart). The job
record's existence is the marker, so it happens exactly once per account.
Push notifications are not implemented. The old friend-request notification trigger was removed because the app never registers an FCM token and the legacy FCM send API it used was decommissioned.
Google Play requires that an app offering accounts also deletes them, and that
the deletion reaches the data as well as the login. The data model makes that
less obvious than it sounds, so the whole sequence lives in one place —
AuthSession.deleteAccount, which calls AccountDeletionService.purge
(lib/common/firebase/account_deletion_service.dart).
A user owns a top-level collection named after their uid, and it is tempting to believe deleting it is the job. It is not. Three things sit elsewhere and used to survive an account in full:
| What | Where it lives | Why the collection wipe missed it |
|---|---|---|
| Playlists | Watchlists/{listId} |
Top-level, not under the owner |
| The claimed username | usernames/{docId} |
Top-level, and it holds the uid |
| Friend requests | {uid}/Friends/FriendRequests/{senderUid} |
A subcollection, and Firestore does not delete those with their parent document |
The uid also stayed in the friends array of everyone who had added the
account, leaving a friends-list entry that could never load.
The purge deletes the playlists the account owned, steps it out of the ones it had merely joined rather than destroying someone else's list, releases the username so the name can be claimed again, drains the friend requests, and removes the uid from each friend's list.
Two orderings matter and are easy to get wrong:
- Re-authenticate first. The password check used to run after the wipe, so a typo destroyed the diary and then failed to delete the account it belonged to. There is no way back from that.
- Erase before deleting the login. Every one of these writes is authorised
against
request.auth, so deleting the Firebase user first leaves the client holding data it may no longer touch.
Writes into other people's documents are best effort. They are the ones most likely to be refused — a friend may have removed the user already — and a refusal must not strand someone with an account they cannot close. Writes to the account's own data are not: if those fail the caller hears about it rather than going on to delete the login.
What this does not reach is a friend request the account sent to someone who never answered. It sits under the recipient, keyed by the sender, and nothing records who was asked, so the client cannot find it without reading every user's collection. Closing that needs a Cloud Function running with admin credentials.
The user-facing description of all this is uractor.com/profile.html, which is the URL declared in the Play Console Data safety form. If this behaviour changes, that page has to change with it — it lives in larabail/uractor.
firestore.rules and firestore.indexes.json are deployed by CI, together with
the Cloud Functions, in a single command on every merge to master that touched
any of the three:
firebase deploy --only functions,firestore:rules,firestore:indexesThat was not always true, and the way it failed is worth knowing. The
release pipeline deployed --only functions. firebase.json named the other
two files but no workflow ever read them, so merging a change to
firestore.rules did not change what the live database enforced — a tightened
rule stayed inert, and so did a mistaken one. Nothing reported it either,
because neither file has a build step it can fail. It surfaced when a composite
index was merged in the same pull request as the scheduled function whose query
needs it: the function failed on every five-minute run with
FAILED_PRECONDITION while the index sat committed and reviewed in the
repository. Both the code comments and several issues had assumed the opposite
all along, which is why this section used to say the reverse.
Deploying by hand is still occasionally the right thing — a hotfix, or a rules change that should not wait for a merge:
firebase deploy --only firestore:rules --project actordb-cf981The rules suite in firestore-tests/ runs twice:
once on the pull request, and again in the release job immediately before it
deploys, on the reasoning that a merge commit is a combination neither branch
tested on its own.
A deploy applies to every client at once — there is no staged rollout the way
there is for an app release — which is why the KNOWN GAPS section at the bottom
of firestore.rules gates some changes on Play Console adoption of a client
that no longer needs the permission being removed.
English and Spanish. Configured in l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: l10n.dart
output-class: SGenerated output lands in lib/l10n/ and is committed, so run flutter gen-l10n
after touching any .arb file and commit the result.
The chosen language is stored in the user's Firestore Settings document,
applied on load in main.dart, and changed from lib/popups/settings_pop_up.dart.
An unsupported locale falls back to en. The same language code is passed to
TMDB as &language=, so titles, overviews, and trailers come back localized
too.
To add a language:
- Copy
lib/l10n/app_en.arbtolib/l10n/app_<code>.arband translate the values. Keep the keys identical. - Set
"@@locale": "<code>"at the top. - Run
flutter gen-l10n. - Add a label for it in the language picker in
lib/popups/settings_pop_up.dart, which currently hardcodes the choice between English and Español.
The repo has four local suites:
flutter test
cd functions
npm install
npm test
cd ../firestore-tests
npm install
npm test
cd ..
node --test web/downloads/*.test.jsflutter test currently runs 971 tests with no emulator, credentials or
network access. Firestore and HTTP are reached through two seams —
FirestoreCore.db and AppHttp.client — and callable context through
CallableContext. They default to the real implementations and are pointed at
fakes by the tests.
test/support/harness.dart installs those fakes and restores them afterwards.
The Flutter suite covers pure logic, TMDB/OMDB request parsing with a stubbed
HTTP client, auth/session helpers, search and playlist ordering, playlist join
handling, settings, inbox, calendar/list services, calendar episode detail,
what a calendar entry does to watch progress, viewing history ranges,
in-memory Firestore service behaviour, watch-progress rules and controls, the
media sort cache — including that a warm start makes no requests at all and
that one account's cache cannot outlive its sign-out — the media and person
data objects, every popup under lib/popups except the profile section editor,
the reviews and Continue watching screens, and the pre-commit hook itself.
npm test in functions/ runs the Node 22 unit tests for the playlist, OMDB
and people-score helper modules. npm run test:emulator additionally runs the
end-to-end suite against the Firestore, Functions and Pub/Sub emulators, which
needs a JDK on the PATH. That run stands a stub in front of TMDB and points the
functions at it with TMDB_API_BASE_URL, so nothing in CI touches the real,
rate-limited API.
npm test in firestore-tests/ starts the Firestore emulator with
firebase emulators:exec and runs the rules suite. It currently reports 97
passing tests. It expects firebase already on PATH; CI runs npm run test:ci
instead, which uses the same pinned CLI through npx that the functions suite
does. If port 8080 is already held by an emulator you started separately, run
npx mocha rules.test.js --timeout 20000 from firestore-tests/ instead. The
release job runs it a second time, immediately before it deploys the rules.
node --test web/downloads/*.test.js runs the downloads page's release logic — which
installer belongs to which platform, which of two versions is newer, and what
the page falls back to when the GitHub API cannot be reached. No dependencies
and no browser: it uses Node's own test runner against the same module the
page loads.
For coverage:
flutter test --coverage
python tool/coverage_summary.pyCoverage of lib/common and lib/objects is enforced at a floor of 68% in CI,
set in both .github/workflows/pr.yml and
.github/workflows/release-internal.yml. Move the two together, or a pull
request and a release will disagree about what passes. The floor deliberately
covers the tested layers rather than every screen in lib/, so a useful
regression signal is not diluted by UI code that still lacks widget tests.
Every pull request to master runs .github/workflows/pr.yml, which has six
jobs — one to work out what the change touches, and one per thing that can
break:
- Scope works out whether anything outside
web/downloads/changed, and the jobs below skip their real work when nothing did. See what a downloads-only change skips. - Analyze, test and build installs Flutter 3.47.1 plus the pinned Android
NDK through
.github/actions/setup-flutter-android, then runsflutter analyze,flutter test --coverage, the coverage floor, and a release app bundle build usingTMDB_API_KEYandOPENAI_API_KEYfrom GitHub Actions secrets. Pull request bundles use debug signing and are checked to ensure no release signing material is present. - Functions installs Node 22 dependencies in
functions/, runsnpm test, and confirmsindex.jsloads. - Firestore rules installs Node 22 dependencies in
firestore-tests/and runs the rules suite against the Firestore emulator, which needs a JDK. The release workflow runs the same suite again before it deploys the rules, so this is the first of two checks rather than the only one. See Deploying the rules. - Downloads site runs
node --test web/downloads/*.test.js. The page atdownloads.uractor.comis served exactly as it is committed, so nothing else in CI would notice its script breaking. - Version runs the unit tests under
tool/and then enforces the version policy from AGENTS.md against the pull request title and commits. Do not edit the+BUILDsuffix: the release pipeline builds with a code derived from Play and opens a pull request recording it. - Build and launch on a simulator builds for the iOS simulator on a macOS runner, then installs and launches the app and checks it is still running fifteen seconds later — because the iOS failure that matters most, Firebase failing to configure, happens at launch rather than at compile time. No signing: a pull request must never hold the distribution certificate, and the branch may come from a fork.
The iOS job used to be a separate workflow, because a macOS build takes around
twenty-five minutes against roughly five on Linux and path filters apply to a
workflow rather than to a job. It runs alongside the Linux jobs now, and the
workflow has no path filter at all. A required check skipped by a path
filter is left pending forever rather than reported as passing, so filtering out
Markdown and docs/ did not save five minutes on a documentation-only pull
request — it made one unmergeable. The iOS check had already dropped its filter
for that reason; the Linux jobs have now followed.
Three of them are required to merge: branch protection on master waits for
Analyze, test and build, Functions and Version, and nothing else. It
matches them by name, so renaming one silently stops it being required — which
is why none of the names changed when the two workflows were merged.
The iOS job is not required yet. Running on every pull request is what makes it requirable; adding it to the required list is a separate, deliberate step, and it would make a macOS build the slowest gate on every pull request.
There is no XCTest job. ios/RunnerTests still contains only the empty
testExample stub that flutter create generates, and the app has no native
code beyond an AppDelegate that registers plugins, so running it would spend
macOS minutes asserting nothing. If real Swift is ever added to ios/Runner,
that is the point to wire xcodebuild test into the iOS job.
Xcode Cloud used to archive the app as well, reporting as
uractorapp | Default | Archive - iOS, and was removed. It never gated
anything — it was not a required check — and it told us strictly less than the
workflow above: no analyze, no tests, and no launch check, which is the iOS
failure that matters most. On master it rebuilt what
release-testflight.yml already builds and then discarded the archive, because
its distribution was set to None. Every failure it ever reported was about its
own configuration rather than the app.
If it is ever reconnected, note that it runs xcodebuild on a bare clone and
knows nothing about Flutter, so it needs a ios/ci_scripts/ci_post_clone.sh to
install the SDK, write ios/Flutter/Generated.xcconfig and install the pods —
none of which are committed. There was one; git log -- ios/ci_scripts has it.
Two things that cost a day the first time: the workflow must point at
Runner.xcworkspace and not Runner.xcodeproj, or the pods never enter the
build graph and every plugin module comes back not found, and CocoaPods has to
be installed before flutter build --config-only, which runs pod install
itself.
Every merge to master that is not docs-only runs
.github/workflows/release-internal.yml, the one pipeline that puts a build in
front of testers on all three platforms. It analyzes and tests once, on Linux,
then deploys the Firebase backend to actordb-cf981 — the Cloud Functions,
firestore.rules and firestore.indexes.json, in one firebase deploy — and
only then starts the three platform stages:
- Android builds a signed app bundle with a Play-derived version code, generates release notes and uploads to Play internal testing.
- iOS builds a signed IPA on a macOS runner and uploads it to TestFlight.
Its build number comes from App Store Connect through
tool/appstore.py, the counterpart totool/play.py, and is deliberately not written back topubspec.yaml: the+BUILDsuffix there records Play's version code, and the two stores count independently. - Desktop builds the macOS and Windows installers, signing and notarising
the macOS one, and attaches them to the run. Nothing is published and
downloads.uractor.comis untouched — desktop has no test track, so "not published" is what internal testing means for it. The macOS signature is checked against the embedded provisioning profile bytool/check_macos_entitlements.pybefore notarisation, because a mismatch there signs, notarises and passes Gatekeeper and then refuses to launch on the user's machine — which is how 3.18.9 shipped an app that could not be opened. See docs/releases.md.
The numbering is the order they are reported in, not the order they run in: all three start together, so a release costs roughly twenty-five minutes rather than the fifty-five they would add up to. A stage whose secrets are missing is skipped rather than failed, and the run summary states which platforms shipped.
The rules and the indexes ship with the functions because until this was fixed
they shipped with nothing. firebase deploy --only functions never read either
file, so a security rule or a composite index could be written, reviewed and
merged and still not exist in the project, with nothing to say so — neither
file has a build step it can fail. It is not hypothetical: a scheduled function
was merged alongside the composite index its query needs, and failed on every
five-minute run with FAILED_PRECONDITION until the index was deployed by
hand. The deploy stage still does nothing at all unless the push changed
functions/, firestore.rules or firestore.indexes.json, and it re-runs both
the functions tests and the firestore-tests/ rules suite before deploying —
the same reasoning that already applied to the functions, with more force, since
a mistake in the rules reaches every installed app rather than one screen.
Worth knowing when reading a failure shortly after a release: firebase deploy
returns once Firestore has accepted an index, not once the index is built, and
building one over a large collection takes minutes. A FAILED_PRECONDITION
naming a required index, in the first few minutes after a deploy that added
one, is usually that index still building rather than a missing one.
Internal builds are not tagged; that summary records the version code and the commit, which is what a production promotion is given. Production is a separate, manual pipeline. See docs/releases.md.
.github/workflows/deploy-downloads.yml publishes
the downloads site on any push to master touching
web/downloads/ or firebase.json, and can be run by hand from the Actions
tab. It is separate from the release pipelines because the page is static and
has nothing to do with a release, and because it was previously deployable only
by running a full production release — which also tags, publishes a GitHub
release and ships to both stores. It creates the Hosting site if it is missing,
and rebuilds version.json before deploying, since a Hosting deploy replaces
the whole site and that file is not in the repository.
Once the Android upload succeeds, a last job records the version code that
shipped in pubspec.yaml, so the +BUILD suffix in the repository matches the
newest build on the internal track. It arrives as a pull request from
github-actions[bot] rather than as a direct commit: master requires a pull
request, and the GitHub Actions identity cannot be granted an exception to that
on a user-owned repository. Approve and merge it like any other — it is one
line, there is at most one open at a time, and each release rewrites it. Its
checks do not start on their own, because GitHub does not trigger workflow runs
for a pull request opened with the built-in GITHUB_TOKEN; close and reopen it,
or use the administrator override. If the pull request cannot be opened at all,
the run says so in its summary and still passes: the app is already on Play by
then, and failing would report a shipped release as a broken one. See
docs/releases.md.
"Every merge to master ships" is the premise the version policy rests on, and
it has been broken at least once. A commit whose message mentions a skip-ci
marker anywhere, including in a body explaining what the marker does, runs no
workflows at all — GitHub reads the whole message, not just the subject. The
merge succeeds, no run is created, and there is nothing to go red because there
is no run to be red.
This bites at merge, not at review. A pull request that touches only
workflow files still gets its checks, and they run the edited definitions: the
workflow above has no path filter, and a pull request is evaluated from its own
merge commit. Workflow changes are self-verifying at review time. It is the
push to master that can silently produce nothing. Green checks say the
workflow is right; they say nothing about whether merging it releases.
What is actually known about the cause, since it is easy to learn the wrong lesson here:
- Confirmed: a commit message mentioning the marker anywhere suppresses
every workflow. That is what happened to
7347803, whose body explains the marker while using it. - Not the cause on its own: touching
.github/workflows/. The merge that added this very watchdog (5612c18) added a workflow file and produced a release run normally. - Undetermined: GitHub does document that pushes made with an integration
token —
GITHUB_TOKENor a GitHub App — create no run.5612c18was merged with a personal access token, which is not one, so it neither demonstrates nor refutes that path for the merge button. If it applies, it depends on the credential that pushed, not on what the diff touched.
One consequence worth knowing: release-internal.yml cannot verify a change
to itself. Its merge is exactly the push that may produce no run, and its
record job only runs after a successful upload, so a bad edit to it is
invisible until the next unrelated merge. Dispatch it by hand after changing it.
The watchdog below is what notices when nobody does.
.github/workflows/check-release-gap.yml is the backstop. Once a day, and on
demand, it collects the head commits of every recent Release to internal testing run and walks master back from its tip to the first commit that has
one. Anything in between should have shipped, unless it deliberately asked for
no run — a marker in the subject, which is what the write-back uses every
release — or changed nothing outside the release workflow's paths-ignore. It
reads that filter out of release-internal.yml at run time so the two cannot
drift apart.
It has three outcomes, and keeping them apart is the point:
- A gap opens or updates one tracking issue and fails the run. While the same commit is still stuck it says nothing further, so a condition somebody is already looking at does not produce a notification every morning.
- No gap closes that issue if it is open, so a dealt-with alarm does not sit around making the next real one look like old noise.
- It could not tell — no runs came back, or the run history did not reach far enough to say what shipped — fails loudly and leaves the issue alone. An expired token or a rate limit must never be able to look like a release gap.
It alerts and never releases. Dispatching a release from a scheduled job would
need a personal access token, because a workflow_dispatch sent with the
built-in GITHUB_TOKEN creates no run — the same suppression the watchdog
exists to catch. docs/releases.md already weighed a long-lived write
credential in repository secrets and rejected it.
The logic lives in tool/check_release_gap.py and is unit tested, including
against the merge that shipped nothing. Run it by hand with:
gh api "repos/larabail/UrActorAPP/actions/workflows/release-internal.yml/runs?branch=master&per_page=100" \
--jq '.workflow_runs[].head_sha' > runs.txt
python tool/check_release_gap.py --tip master --runs-file runs.txt \
--format markdown --fail-on-gapweb/downloads/ is a static site served by Firebase Hosting. No Flutter build
reads it and no release packages it, so a change confined to it cannot alter
what any app does. Four things follow, and each is arranged differently for a
reason worth knowing before changing any of them.
| On a downloads-only change | |
|---|---|
pr.yml — Analyze, test and build |
runs, skips its steps, reports |
pr.yml — Functions |
runs, skips its steps, reports |
pr.yml — Build and launch on a simulator |
skipped whole |
release-internal.yml |
does not run: no build reaches testers |
tool/check_version_bump.py |
requires no version bump |
The two required jobs are gated step by step rather than by a path filter
on the workflow. This is not fussiness. A workflow skipped by paths-ignore
reports nothing at all, and a required check that never reports leaves the
pull request permanently unmergeable — the same trap that took the path filter
off the iOS workflow. Gated by step, the job still runs and still reports; it
just has nothing to do. The iOS job is skipped whole instead, by a condition on
the job rather than on the workflow, because a skipped job does report a
conclusion and because starting a macOS runner to skip everything on it wastes
the scarcest runner there is.
release-internal.yml is the one place a plain path filter is safe: it runs
after the merge and is nobody's required check. Without it, editing a sentence
on a web page would put a build in front of every internal tester under a new
version number containing nothing they could find.
The version exemption is by path, not by kind. A change to a public web page is
honestly a feat or a fix, so the kind alone would demand a minor bump the
app has no reason to make. tool/check_version_bump.py therefore drops the
requirement when nothing outside web/downloads/ changed. Bumping anyway is
still allowed; a version that moves backwards, or a minor bump that leaves the
patch number behind, is still refused.
tool/play.py— Google Play release helper used by the release workflows and runnable by hand. See Releasing.tool/generate_desktop_icons.py— derives the macOS and Windows app icons from the iOS one.flutter createwrites a Flutter logo intomacos/andwindows/and nothing replaces it, so both desktop builds shipped with the toolchain placeholder until this existed. Run it after changing the iOS icon; it needspip3 install --user Pillowand is deliberately not wired into CI, because the icons it produces are committed.tool/build_download_manifest.py— writesweb/downloads/version.jsonduring a production release. That file is what a running desktop copy polls to discover it is out of date, and what the downloads page falls back to when the GitHub API is rate limited. It does not generate the page; see the downloads site.tool/check_release_gap.py— answers whether every commit onmasterthat should have produced a release actually did. Run daily bycheck-release-gap.yml; see a merge can ship nothing without saying so. It takes the run history as a file and shells out to git, so it is testable without touching the API.tools/sync-oscars— a standalone Node 18+ script (no npm dependencies) that populates the FirestoreOscarscollection from the UrActor API, resolving winners to TMDB ids. It has its own README covering name resolution, overrides, and known gaps.firestore.rules— the checked-in Firestore security rules. They constrain who may write, the shape of what a friend may write, and — wherever the written key can be named — the value inside it. Read the rules' own KNOWN GAPS section before treating them as complete. The matching tests live infirestore-tests/and run against the local emulator, on every pull request..githooks/pre-commit— runs analyze and the tests before a commit. Enable it withgit config core.hooksPath .githooks. It clears git's own environment variables before invoking flutter, for the reasontest/pre_commit_hook_test.dartspells out.
UrActor is source-available, not open source. The code is published so it can be read and audited; reading it grants no right to ship it. Reuse beyond quoting and evaluation needs written permission. See LICENSE.
Security reports go through SECURITY.md, privately — not through a public issue.
AGENTS.md is the working agreement: no commits on master, tests
with new code, both .arb files when strings change, and the commit and pull
request conventions. Read it before opening a pull request.
Outside pull requests are not being taken. The repository is public to be read, and every change still goes through review by the owner.
Things that are true today and worth knowing before you start:
| Gap | Detail |
|---|---|
| Push notifications are not implemented | The old notification function was removed because the app never registered FCM tokens and its legacy FCM API would no longer send. A future implementation needs firebase_messaging, token persistence, current FCM sends, APNs setup, and device testing. |
| iOS Firebase config is partial | There is no ios/Runner/GoogleService-Info.plist, and firebase_options.dart declares iosBundleId: 'com.example.uractor' while Xcode builds com.uractor.uractorios. Neither stops an iOS build or a sign in, because Firebase is configured from Dart, but APNs, firebase_messaging and App Check would all need the plist. |
| iOS is not released to the App Store automatically | Stage 2 of .github/workflows/release-production.yml submits a version for review and, as a second deliberate run, releases it once Apple approves. Neither happens on merge: Apple reviews every version by hand, so there is no equivalent of Play's promote-and-it-is-live. The version record is created by the pipeline and its release notes written from the commits, but a changed description, new screenshots or an app's first release still want a person in App Store Connect. |
| Coverage is uneven | The API layer, the data objects and the popups are covered; the full screens under lib/ still have very few widget tests. See Tests. |
| A friend's watch progress cannot be set from your device | Tagging a friend on a calendar entry writes to their calendar and seen-with records, but firestore.rules lets a client write its own Progress document and nobody else's. So an entry naming an episode no longer marks the show fully seen for them — which would be a lie — but it cannot record them as part way through it either, and the show reads as not started on their side until they log it themselves. Closing this needs the write to move behind a Cloud Function. |