Local and cloud builds: the published one contains no cloud code - #44
Merged
Merged
Conversation
The apps are wired to one Firebase project at build time - the google-services plugin compiles the project id, app id and API key into the APK - so there is no such thing as a neutral binary. Publishing ready-made APKs would quietly make this project's maintainer the operator of other families' children's usage data, which is the opposite of the point. So the supported path is that each family builds against a project of their own, and docs/SELF_HOSTING.md is that path written for someone who isn't a developer: create the Firebase project, register both package names, enable the two auth providers, publish the rules, build the two debug APKs from Android Studio, install, pair. It says what it costs (nothing), how long it takes (about 45 minutes), and it repeats the rules-and-apps-ship-together warning in the place where it bites. The install guide no longer assumes someone handed you two APKs, and the README says plainly, where a person looks for a download, why there isn't one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t all
A build of either app is now one of two flavors, never both:
local - everything stays on this phone. No account, no pairing, and no
Firebase SDK compiled in: the :cloud module simply is not a
dependency of this flavor, so there is nothing in the APK that
could reach a server even by mistake. Verified rather than
assumed - the local APKs contain zero references to
com/google/firebase in their bytecode, where the cloud ones have
hundreds, and they are about 5 MB smaller.
cloud - what the apps did before: a parent's phone and a child's paired
through a Firebase project someone runs themselves.
What made this possible is that Firebase only ever leaked into two files
outside the repository package, so the seam was already almost there.
FamilyRepository becomes an interface in :shared; the Firestore version moves
to a new :cloud module as FirebaseFamilyRepository; and LocalFamilyRepository
keeps the profile, the passcode and a day-by-day usage history in this phone's
own storage. Each app has a Backend object per flavor - the only place that
knows which world it is in - so the 100-odd call sites in the UI did not have
to change at all. Firestore's ListenerRegistration is wrapped as Registration
so nothing above the repository names a Firebase type.
The google-services plugin moves to :cloud because it fails any variant that
has no google-services.json, which is precisely what a local build is.
A local build has no history to sync, so it archives today's numbers whenever
they are read. That is what gives it a weekly report and a streak with no
server and no sync worker behind them.
Two things a local build deliberately does not offer, and now says so by
leaving them out rather than failing: adding a kid, and signing out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… needs it docs/LOCAL_AND_CLOUD.md is the honest side-by-side: what a local build does (nearly everything, since most of this project is one phone minding itself), what it cannot do (anything needing a second phone in another place, and why that is not a small gap), how to build each one, and how to check the claim that a local APK has no Firebase in it rather than taking it on trust. It ends by asking for the thing this project most needs from the community: a cloud nobody has to trust. End-to-end encryption over a relay that only ever holds ciphertext would mean one published build, no setup, no operator, and the remote features back - and the pairing step is already a private channel between two phones in one room, so there is somewhere obvious to agree a key. The note also warns off the dead ends, because same-network sync fails exactly when a parent needs it, peer-to-peer means NAT traversal and therefore servers again, and Doze kills long-lived connections, so anyone proposing a design should have an answer for all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ait for an answer "Suggest a change" and asking for more time on the block screen both send a message to a parent's phone. A local build has no parent's phone to send to - and, worth saying plainly because it is the natural assumption, no Wi-Fi or Bluetooth link to one either. Nothing in a local build opens a connection to anything. A button that quietly does nothing is worse than no button, especially on a block screen where a child is waiting to find out whether they get more time. So in a local build both are replaced by what actually works: ask a parent, who can change any of it on this phone in Parent controls with the family passcode. The block screen already said "ask a parent", which is now simply true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It describes the cloud build, and its claim that this project publishes no APKs stops being true with a local flavor that contains no cloud code. Merged here so one branch carries a consistent story rather than two that disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ownload A cloud build carries one Firebase project's id and key, so handing one out makes whoever owns that project the operator of every installing family's data. That was the reason this project published nothing - and it only ever applied to cloud builds. A local build has no cloud code in it at all, nowhere to send anything and nobody who can read what it keeps, so there is nothing to hold back: releases ship local APKs. The install guide points at the release again instead of apologising for not having one. The self-hosting guide is now explicitly the cloud path, opening with the likelihood that a reader does not need it, and its Firebase steps match where the config actually lives: one file in the cloud module, plus the Build Variants step that decides whether any of it is used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tween them
The groundwork for a kid's phone asking a parent's phone for more time, or
suggesting a limit, over the home Wi-Fi. Transport-independent on purpose, so
Bluetooth can slot in later without changing the vocabulary or the screens.
The parts that matter are the ones that decide whether this is safe to have at
all. A link can change what a child's phone allows, and anyone else on the same
Wi-Fi can reach the same port, so a home network is not a safe room:
- Every message is sealed with AES-GCM under a key the two phones agreed in
person, by QR code. A message that was tampered with, or sent by anyone
without the key, does not open at all rather than arriving subtly wrong,
and the caller cannot tell which failure it was - anything finer would be
an oracle for guessing at the key.
- Sealing is nonce'd, so the same request twice is not the same bytes twice.
- Ids are claimed once. Sealing proves who wrote a message, not when, so
without that anyone who captured one grant of fifteen minutes could replay
it every evening and the phone would honour it every time.
- The mDNS name a phone advertises is a hash of the key, never the key, so
only the phone it linked with can find it and nobody can tell whose is
whose on a shared network.
Twelve unit tests cover exactly those: round trip, wrong key, a single flipped
bit, truncation, a future version, nonce freshness, replay, and the QR payload
including what happens when someone scans an unrelated code.
The Wi-Fi transport itself is written but has not run on hardware yet - its
limits are in its KDoc, because the screens have to say them out loud: same
network only, guest Wi-Fi that isolates clients will never work, and the
parent's phone has to be awake, so a request is kept and retried rather than
assumed delivered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… empty Hiding "Add kid" in a local build left a parent who had just installed the app to set up their child looking at a dashboard with nothing on it and no idea why - the exact dead end this project decided never to ship again. In its place, a card that says what is actually true: this build keeps everything on each phone, so this app cannot reach a kid's phone; install the kid app there and set the limits in Parent controls with the family passcode, which is all their phone needs; and the cloud build is what to use to manage it from here instead. Neither build is framed as the mistake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… real sync Two things, both from the same mistake: treating a local build as a cut-down cloud build rather than one that simply has a single profile. "Customize Screen Time goals" sat on "Loading..." for good. The page reads its profile from listenChildren, which the local repository had stubbed out as an empty list because "children" sounded like a paired-phone idea. It is not: a local build has exactly one profile, every screen is entitled to it, and it now exists from first launch instead of only after someone opts into tracking. The nearby link's vocabulary was also too narrow. It carried requests only - more time, a suggested limit - which is not what a family needs between two phones. It now carries a usage report up from the kid's phone and a limits update down from the parent's, so one exchange on the home Wi-Fi leaves the parent's phone knowing today's usage and the kid's phone knowing its limits. That is a sync, which is what was asked for; a request channel was my own narrowing of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etween
The local build stops being two unrelated apps. A parent shows a QR code, the
kid's phone scans it once, and from then on - while both are on the same Wi-Fi
and awake - the kid's phone reports how it has been used and picks up whatever
limits the parent has set. No account, no server, nothing leaving the house.
One round trip does the whole job: the kid's phone reports, the parent's phone
answers with limits. The rules about who decides what live in NearbySync rather
than in the screens, because they are what would otherwise drift between the
apps: limits always come from the parent's phone, so a kid cannot keep a looser
one by staying away; usage always comes from the kid's; and asking for more
time is acknowledged, never granted by the asking.
What the screens have to be honest about, and are:
- Every synced number is shown next to when it arrived. Two phones on a local
link are apart most of the day, and Tuesday evening's usage must not read
as "right now".
- The link screen spends as much space on what this cannot do as on what it
can: nothing while you are apart, and some guest networks never work at
all.
- A kid's phone that cannot find its parent's says nothing and changes
nothing. It keeps enforcing what it already has, which is the behaviour
that makes a local build worth trusting.
- A request reaches a parent as one notification with no "grant" button on
it. Opening the app to decide is deliberate friction; a notification action
would make yes the easiest thing to do while distracted.
Asking for more time and suggesting a limit come back on the kid's phone once
it is linked, and stay absent until then rather than failing silently.
QR drawing and scanning are zxing, which is Apache-licensed and not Play
Services - a local build has to stay free of those.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Kotlin compiler error log and the user's own gradle.properties change were swept in by a wide "git add" on my part; neither belongs in the history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cking Removing my accidental modification from the index took the whole file with it. It is a real project file; this puts it back exactly as main has it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 tasks
…reachable Three things a real phone found straight away, all of them the same mistake in different clothes: treating one phone's state as if the other could see it. Nothing appeared on the parent's phone after pairing. The only thing that ever started a sync was a fifteen-minute background worker, so a parent who had just scanned a code sat looking at an empty card with no way to tell whether it had worked. The kid's phone now syncs the moment it is linked, offers "Send to their phone now", and says when it last got through; the parent's dashboard re-reads while it is open, so a phone syncing in the next room shows up without leaving the screen. Scanning was buried in the kid app's Settings, which is the last place someone setting up a phone looks. It is on the status screen now, as the main action while the phone is unlinked, with a line saying what a parent has to do on theirs. Parent controls on the kid phone never accepted a family passcode, because only the parent app can set one and in a local build nothing carried it across. Two halves to that: the passcode verifier (a salted hash, never the passcode) now travels with the limits, so a linked phone can check a code a parent types on it; and an unlinked phone can set its own, once, since otherwise Parent controls on it could never be opened at all - which quietly contradicted every instruction telling a parent to set limits there in person. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…selves Pairing needed a button on the parent's phone saying the code had been scanned - and the kid's phone, quite correctly, synced the moment it was linked, which was before anyone had pressed it. So the first call always found nobody listening, and a parent was left looking at a code with no sign it had worked. The button is gone. The parent's phone starts listening the moment it puts a code on screen, and learns it has been scanned the way it should: their kid's phone tells it. The page says "Waiting for their phone..." and turns into their name by itself. The kid's phone keeps trying for a few seconds after a scan rather than once, since a parent may still be putting their phone down, and it also syncs whenever the app is opened - which is the best available signal that the phone is awake and probably home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things, all from watching this used on real phones. Sync now had to work on whichever phone someone is holding, so the link is symmetric: both ends listen, each advertising under its own role so they never answer themselves, and either can start the exchange. A kid's phone sends what it has been used for and is answered with limits; a parent's phone sends limits and is answered with usage. Same single round trip, started from either end. Every sync button now carries the reason it might do nothing - both phones on the same Wi-Fi, both awake - said every time rather than only after a failure. A tap that silently does nothing reads as a broken app, when it usually means the two phones are simply in different places. A failed attempt on the parent's side says so in as many words, including the guest-network case that never works. Both phones can be named, because "This phone" twice on a home screen is useless. A parent names their own and their kid's; naming the kid's changes nothing on the kid's phone, since it is a label for the parent's own screen. The kid's phone also reports its model, so there is something true underneath whatever it gets called. Feedback in a local build was a no-op that closed the dialog as if it had sent something. There is no server to send to, and quietly swallowing what someone took the trouble to write is worse than not offering the box at all. It now says so and hands the text to whatever they already use, with the app version and device appended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e truth
An email address compiled into an APK is readable by anyone who downloads the
file, and sending mail without showing it would need a server this build
deliberately does not have - so there is no way to offer in-app feedback to a
private address. The Feedback menu in a local build opens the project's issues
instead, where feedback is public and gets answered. Cloud builds keep the form,
which posts to their own operator's project.
The install guide now covers what people actually meet:
- Play Protect will warn, because the app is unfamiliar rather than because
Google found anything. Each wording it uses is listed with what to tap -
including the one case ("Harmful app blocked") that means stop and tell us,
since a genuine release should never produce it. Turning Play Protect off is
neither needed nor advised.
- Accessibility takes about eight taps across three screens, and Android
refuses the switch the first time on purpose. The whole dance is written
out, including the (i) icon route to App info that is quicker than going
round through Settings, and the note that the "Allow restricted settings"
item often only appears after Android has said no once.
The privacy section says what is true rather than what is comfortable. Nothing
about a family reaches a server, and the only phone-to-phone traffic is the
direct encrypted sync - but the optional website filter forwards DNS lookups to
Cloudflare's 1.1.1.1, which is a third party seeing traffic, so it is named in
the install guide and in LOCAL_AND_CLOUD.md rather than left for someone to
discover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FamilyRepository became an interface when the two builds split, and these tests still constructed it directly. Nothing I build locally compiles this source set, so CI was the first thing to notice - which is what it is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d on launch Moving google-services into the :cloud module took the Crashlytics Gradle plugin out of the apps with it, and the Crashlytics SDK refuses to start without the build id that plugin injects - so a cloud build threw IllegalStateException the moment it opened. All 23 emulator tests failed on it, and the UI test then timed out waiting for a sign-in screen that was never going to appear, which is exactly what those tests are for. Unlike google-services, this plugin needs no json, so it can be applied to the app for every variant. A local build carries one unused string resource as a result and still contains no Crashlytics code at all - checked, not assumed: zero references to Lcom/google/firebase/crashlytics in its bytecode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Putting the plugin back broke the local release instead: its upload task cannot even be created without the google-services plugin, which is the one thing a local build must not have. The ways out were to give local builds a stub Firebase config - reintroducing exactly what was removed, and putting a "crashlytics.com" string in a binary advertised as having no cloud code - or to stop using Crashlytics. The second is the honest one. So neither build has crash reporting now. Nothing is lost that this project did not already have a better answer for: CrashNote keeps the last crash on the phone in a form a person can read and send on themselves, which works offline, works in both builds, and does not upload anyone's stack traces without them choosing to. Docs follow: the privacy note, the publishing checklist and the Play Console answers all said crashes went to Crashlytics, and none of them do now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ignored it A cloud build died the moment it opened: "Default FirebaseApp is not initialized". The google-services Gradle plugin only generates its config resources for application modules, and every piece of Firebase in this project now lives in a library - so applied there it quietly produced nothing at all. It failed silently at build time and loudly at launch, and only a running app could show it, which is what the emulator tests are for. The plugin goes. The cloud module reads the same google-services.json itself, from its own assets, picks the client matching the package it finds itself in, and initializes Firebase before anything reaches for it. Fewer moving parts than the plugin, all the cloud configuration in the one module that is allowed to have any, and a local build still carries no Firebase code, no Firebase config and no project id - checked, not assumed. Both CI workflows wrote their stub config where the plugin used to look; they now write one file where the module actually reads it, with a client per app as a real project's file has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wide git add swept the user's own local Gradle tweak into the previous commit. It is theirs and uncommitted on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # .gitignore
The regression test that came in with the pairing fix was written against FamilyRepository as a class, which it no longer is. Git merged both sides cleanly and produced something that did not compile - the kind of thing only a build catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nish it It does not work well enough to put in front of a family. Replacing a phone's home screen touches the launcher role, the foreground guard and the app list at once, and getting it wrong leaves somebody holding a phone that will not open anything - a bad failure for a tool a family depends on, and a worse one for a child. Switched off in one place, Features.DUMB_PHONE, rather than deleted: every piece of it is good work and a contributor should start from it instead of an empty file (see #46). While it is off, no screen offers it, neither phone asks to become the home app, the launcher activity stays disabled in the manifest, and - the part that matters - a focusMode flag arriving from a parent's phone is ignored rather than enforced, so nothing can take over a home screen that this build offers no way to turn off again. The README, install guide and Play Console notes stop describing a feature nobody can reach, and point at the issue instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes "local only" a real build rather than a setting, and gives the published APK an honest story: there is no server in it.
What changed
Each app now has two product flavors, and a build is one or the other:
FamilyRepositoryin:sharedbecomes an interface. The Firestore implementation moves to a new:cloudmodule asFirebaseFamilyRepository;LocalFamilyRepositorykeeps the profile, passcode and a day-by-day usage history in the phone's own storage. Each app gets oneBackendobject per flavor — the only place that knows which world it is in — so the ~108 repository call sites in the UI didn't change at all.The
google-servicesplugin moves to:cloud, because it fails any variant with nogoogle-services.json, which is exactly what a local build is.The claim is checkable, not a promise
Local behaviour
A local build has no history to sync, so it archives today's numbers whenever they're read — that's what gives it a weekly report and a streak with no server and no sync worker. Limits are set on the phone they apply to, behind the family passcode in Parent controls. Adding a kid and signing out are left out of the UI rather than left to fail.
Docs
docs/LOCAL_AND_CLOUD.mdsets out what each build can and cannot do, how to build each, and how to verify the no-Firebase claim. It ends with a call for the contribution this project most needs: a cloud nobody has to trust — end-to-end encryption over a relay holding only ciphertext, with the key agreed during pairing, which would mean one published build, no setup and no operator. It also names the dead ends (same-network sync fails exactly when a parent needs it; peer-to-peer needs NAT traversal and therefore servers; Doze kills long-lived connections) so a proposal has to answer for them.Not done yet
CrashNoteis unaffected.Kid app "suggest a change" / "ask for more time" do nothing locally� resolved: both now say to ask a parent, who can change it on that phone in Parent controls.🤖 Generated with Claude Code