Skip to content

Local and cloud builds: the published one contains no cloud code - #44

Merged
jsconu merged 24 commits into
mainfrom
feat/local-only-flavor
Sep 21, 2026
Merged

jsconu merged 24 commits into
mainfrom
feat/local-only-flavor

Conversation

@jsconu

@jsconu jsconu commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

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:

local cloud
Data This phone only A Firebase project someone runs
Account / pairing None Yes
Firebase in the APK None Yes
Setup None A Firebase project

FamilyRepository in :shared becomes an interface. The Firestore implementation moves to a new :cloud module as FirebaseFamilyRepository; LocalFamilyRepository keeps the profile, passcode and a day-by-day usage history in the phone's own storage. Each app gets one Backend object 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-services plugin moves to :cloud, because it fails any variant with no google-services.json, which is exactly what a local build is.

The claim is checkable, not a promise

unzip -p kid-local-debug.apk 'classes*.dex' | grep -ac 'com/google/firebase'
APK matches size
kid-local 0 10.6 MB
parent-local 0 11.9 MB
kid-cloud 67 16.0 MB
parent-cloud 67 17.2 MB

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.md sets 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

  • Not run on a physical device — built and verified at the bytecode level only.
  • Cloud release builds no longer upload Crashlytics mapping files (the plugin couldn't stay in the app modules). Crashes still report, obfuscated; the local CrashNote is 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.
  • Overlaps Self-hosting guide, and no published APKs #43, which says the project publishes no APKs. With a local flavor that reasoning no longer holds — local builds are safe to publish. Needs reconciling before either lands.

🤖 Generated with Claude Code

jsconu and others added 6 commits September 21, 2026 06:59
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>
jsconu and others added 6 commits September 21, 2026 07:59
…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>
jsconu and others added 12 commits September 21, 2026 09:37
…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>
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>
@jsconu
jsconu merged commit ae3b10f into main Sep 21, 2026
2 checks passed
@jsconu
jsconu deleted the feat/local-only-flavor branch September 21, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant