All projects

Soundmind

Odiya Child Device App

On a device where the management policy keeps mobile data off and logs are out of reach, the job was to find a way to never miss a location while still conserving battery.

Role
Location-collection state machine design and Android native implementation
Period
2025.07 ~ Present
Stack
React NativeAndroid (Java/Kotlin)FusedLocationActivity RecognitionFCM
App / clientNativeServerWorkerStorageExternal
Mobile data is kept off by the management agent. The app requests access only right before an upload, and server commands ride back on the upload response.Hover a block to highlight its flows. Drag to pan.

Problem found

  • 45s to 357s

    Timer delay measured in doze (136 to 357 seconds)

Outcome

  • 100%

    Delivery success rate (previously, data dropped after an average of 1.17 seconds)

  • 10.2KB to 1.1KB

    Location batch body, 89% smaller once compressed

  • 43MB to 6.8MB

    Monthly upload per device after compression and connection reuse

  • 4/4

    Measured subway trips in which cell-tower coordinates were correctly filtered out

01The problem

The devices this app runs on are not ordinary smartphones. A management policy introduced for child protection keeps the device's mobile data off by default and locks down developer mode, so when something goes wrong there is no way to pull logs. Yet the requirement was that parents must be able to check their child's location at any time. I had to send location periodically from a device with data turned off, and guarantee stability with no way to observe what was going wrong.

  • Mobile data is blocked by default, so every transmission must ask the management agent to open it
  • Developer mode is locked, making log collection from real devices impossible
  • Field bugs that could not be reproduced had to be fixed on guesswork, over and over

02Constraints

Most of the usual solutions were off the table. With the network normally closed, the server could not reach the app first, so push-driven control was out, and keeping GPS always on was not an option because of battery. On top of that, Android's power-saving policy would arbitrarily postpone the timers I scheduled.

what the app stands on

Onboarding does not advance until both grants are held.

  • The app stands on two grants: the Knox management license lets it open data, and the battery optimization exemption lets it wake on a sleeping device. Missing either one means nothing ships
  • Knox management license. The app cannot switch data on by itself; it broadcasts a request to the management agent and asks it to do so. With the license inactive that request is simply not honored, and some capabilities additionally require vendor approval filed separately
  • Battery optimization exemption. Off the exemption list, the system drops the app into a lower standby bucket where even exact alarms fire later and background network access tightens further, making waking on a sleeping device uncertain in the first place
  • Opening data is asynchronous, takes 3 to 5 seconds, and gives no signal when it completes
  • I measured timers scheduled at 45 seconds slipping by up to 357 seconds in power-saving mode
  • Keeping GPS lit at all times drains the battery before the day is over
  • Trusting coordinates on the accuracy value alone gets you fooled by cell-tower positions disguised as GPS

03Alternatives considered

The fork in the road was how to manage opening the network. I compared three approaches, and the first two were actually built and run before their problems surfaced and they were discarded.

OptionStrengthsDrawbacks
Keep data always openSimplest to implement, and transmissions can never failDirectly contradicts the intent of the management policy, and battery and data costs are unsustainable
Open and close per request (single token)Opens only when needed, satisfying both the policy and the batteryWith a shared token, whichever request finished first cut the network out from under one still in flight. Measured median hold time: 1.17 seconds
Single gateway + per-requester countingChosenStays open until the last user finishes, and a minimum hold time can be enforced as a ruleA missed release leaves it open indefinitely, so separate safeguards are needed

04Decision and rationale

I chose the single-gateway approach. The deciding factor was that the open request is asynchronous with no completion signal. Transmitting the moment you ask means the data goes out before the network is actually open, and closing the moment a transmission ends makes the next request wait another 3 seconds. So I count requesters, keep the network open until the last one finishes, and added a minimum 6-second rule. The risk of a missed release is contained by four layers of safeguards.

  • Per-requester counting manages open and release, with a minimum hold of 6 seconds
  • Four layers guarantee release: a wake lock deadline, a watchdog, secondary reclamation on the alarm tick, and generation tokens
  • Generation tokens stop a late-arriving old callback from cutting the network of a new session
  • The GPS trigger was changed to the age of the held location, so a failed transmission can never lock the condition permanently
  • Coordinates are validated in order: an accuracy gate, satellite-count discrimination, then speed plausibility

05Fighting doze mode

Android drops an idle, screen-off device into doze: it sleeps the CPU, cuts network access, and batches scheduled alarms into periodic windows. The problem is that this service's primary operating condition is exactly that state. A child's device spends the day screen-off in a pocket or a bag, and locations must keep flowing precisely then. Doze had to be the default stage rather than an edge case, with a countermeasure stacked at every point where the system tries to put the app to sleep.

one cycle under doze

The last step books the next alarm. Break that link and it never wakes again.

scheduled versus actual

45s
136~357s

The wall clock, not the timer, decides how much time passed.

  1. The right to wake

    Ordinary alarms are deferred wholesale to a maintenance window under doze. Scheduling uses the exact alarm API that still fires during doze, but the collection logic assumes even that gets throttled onto a roughly nine-minute grid. Instead of fighting to wake several times inside those nine minutes, it catches up on backlogged decisions whenever it does wake

  2. Battery optimization exemption

    Without a place on the exemption list, the app sinks into a lower standby bucket where alarms and network access tighten another notch. Onboarding requests that exemption explicitly and gates progress on it being granted

  3. Staying awake once woken

    An alarm can wake the process and the CPU can still fall back asleep before collection and upload finish, cutting the work mid-flight. A partial wake lock covers the working window but is time-boxed so nothing is held past ninety seconds, with a watchdog and the next alarm tick reclaiming anything not released. Holding a wake lock open is not beating doze; it is burning battery

  4. Correcting the sense of time

    Elapsed-time timers stretch under doze: a 45-second schedule was measured firing anywhere from 136 to 357 seconds later. Timers are therefore never trusted, and every wake recomputes real elapsed time from the wall clock and corrects the backlog

  5. A past that arrives late

    A stale callback from a stretched timer could land after a new cycle had already begun and release its network. Each cycle now carries a generation token, so callbacks from an older generation are ignored

  6. A chain that ends if it breaks

    The alarm is not a repeating schedule but a self-rescheduling chain whose final line books the next one. One missing link means permanent silence, so rescheduling lives in exactly one place in the code, with a separate revival path for chains broken by a reboot or a process kill

  7. Vendor power policies

    On top of standard Android doze sits another layer of manufacturer-specific power saving. Winning the standard exemption does not stop that layer from sleeping the app, so the vendor's own policy had to be checked and the exemptions matched to it

  • When a 45-second alarm actually fired

    up to 357s
    45s scheduled

06The data plan as a constraint

These devices run on cheap child-oriented data plans. The allowance is tight, so if location uploads eat through it, the connection is gone exactly when a parent needs to reach their child. Opening up what actually went over the wire, the location batches were being sent as plain JSON. This is data where compression pays off the most, since the same keys repeat on every item, and none of it was compressed. A single batch of thirty fixes runs about 10.2KB in plain text, where only the coordinates and timestamps change while the field names repeat thirty times over.

what one request costs

before15.7KB
after1.1KB
handshakeplain JSONgzip, reused conn

43MB to 6.8MB per device per month

  • The app now gzips the request body. A thirty-fix batch drops from 10.2KB to 1.1KB, about 89% smaller, and the ratio improves further as batches grow
  • Tomcat and Spring do not decompress request bodies by default. A filter placed first in the chain unwraps them on the server, and requests without the encoding header pass through untouched so older app versions keep working
  • Opening a fresh connection on every upload costs too. With short requests repeating every thirty seconds, the TCP three-way handshake and the TLS negotiation weighed more than the payload itself
  • A front proxy now keeps connections alive for reuse, and TLS session reuse is enabled so renegotiation is not paid for again. Every upload no longer starts over from the connection
  • Right after compression went live, some paths started returning 403, so a plain-text fallback rode along for a while. Once the front-end configuration was corrected, that fallback was sealed off
  • The same logic applies to diagnostics and parked retries: instead of separate requests, they ride along with the location upload, because the number of requests is itself part of the bill
  • Together these bring monthly upload per device from roughly 43MB down to about 6.8MB: compression shrinks the body, and connection reuse strips out the handshake cost that used to repeat ninety-six times a day
  • Finally, devices report their own data consumption, and the admin dashboard shows the monthly average. The point is not to claim a reduction but to make usage visible, which is where the evidence for the next round of cuts comes from
  • Location batch request body (30 fixes)

    10.2KB
    1.1KB
  • Monthly upload per device

    43MB
    6.8MB

07An app you had to visit a service center to fix

Normal deployment does not reach this app. Data is blocked by default on these devices, so store auto-updates never run, and fixing a single bug meant the user carrying the device to a service center for a manual update. In practice the version in the field was frozen. Most devices sat on older builds, and urgent fixes had to be walked out one handset at a time.

how a fix reaches a device

Native still needs a visit, so old builds never disappear.

  • Screen logic lives in a JavaScript bundle, so I built a code-push path into the app that swaps only the bundle without reinstalling the native side
  • Fetching a bundle still costs network, though. To hold the rule that data is never opened for anything else, bundle checks and downloads ride the window that already opens for location uploads. There is no separate opening for code push
  • With a tight data plan the bundle is a cost too, so devices fetch only what changed, and a download interrupted mid-window resumes in the next one
  • Application timing splits in two: urgent fixes restart as soon as they land, while everything else applies quietly on the next launch so a child using the device is not interrupted
  • If a new bundle fails to boot, the previous one is restored automatically. Remotely bricking a device that can only be fixed at a service center is not an acceptable failure mode, so rollback was a premise rather than a feature
  • Native changes still require the store and a visit, and that boundary is documented so it stays clear what code push can and cannot cover
  • That turns backward compatibility from a preference into a constraint. If the native side cannot be fixed, old versions in the field never disappear, and every future server change has to keep meshing with them
  • Server APIs therefore only ever gain fields. Removing one or changing its meaning would instantly strand every device that cannot be updated. Even introducing compressed uploads kept requests without the encoding header passing through untouched, so older builds carried on unaware
  • Older versions do not send the fields new features rely on, so screens built on that data check the child app version and simply do not appear. Rather than filling in defaults for data that does not exist, the feature is treated as absent
  • The server consequently serves several generations of the app at once. Every deploy is checked not only against the newest build but against the oldest one still alive in the field
  • Rollout widens in stages rather than going to everyone at once: a slice first, confirmation that nothing broke, then a wider net

08Implementation and missteps

Even built to the design, it went wrong three more times in the field. With no way to see logs, I narrowed things down each time by forming a hypothesis and piggybacking diagnostic records onto transmissions to retrieve them.

  • ① I first set the minimum hold time to 3 seconds and failures persisted. After measuring that the open request takes 3 to 5 seconds, I raised it to 6 seconds, and the failures disappeared.
  • ② I built the stationary-state check on a time window, and a device that failed one transmission could never satisfy the condition again and went silent. I rebuilt it with the age of the held location as the threshold instead of a window.
  • ③ For a long time I could not find why collection stopped in power-saving mode. Logging scheduled timers against actual clock time showed 45-second schedules slipping by up to 357 seconds. I corrected it with a real-time backstop.
  • ④ Since developer mode is locked and logs cannot be pulled, I piggybacked diagnostic records onto location transmissions and had the server absorb duplicates. Opening the network separately for this path was forbidden.

09Outcome

Data no longer cuts out mid-transmission. Previously the data connection held for only 1.17 seconds on average, so requests often failed to complete; after adding the minimum 6-second hold rule, the delivery success rate reached 100%. Since changing the GPS trigger condition, the app no longer stays silent just because one transmission failed. Coordinate validation correctly filtered out cell-tower coordinates in all 4 of 4 measured subway trips, and because nothing is sent when confidence is low, the parent app never draws a location it cannot back up.