Android · Engineering Within The Rules

How Google's Rules
Shaped Radar

Android gives Radar the low-level radio access that makes an offline mesh possible — but it does so inside a cage of Google Play policies, version-by-version permission clampdowns, and hard radio limits. This is a precise account of the constraints we hit, and exactly how each one changed the way Radar is built.

🤖 The short answer

Radar runs on Android because Android is the only mainstream mobile OS that exposes the radios it needs — Bluetooth LE advertising, Wi-Fi Aware, Wi-Fi Direct, a persistent foreground service, and default-SMS access. None of that is a free pass.

Every one of those capabilities is gated behind a Google Play policy, a restricted permission, an Android version behaviour change, or a hard radio limit. Several features below were built, shipped, and then re-engineered after we hit a policy wall. What follows is the real list — the things that forced Radar to be the way it is.

Part 1 · Google Play Policy Walls

Permissions and behaviours that Android technically supports, but that Google Play restricts to specific app categories — several of which we hit head-on and had to design around.

📞

Full-Screen Intent — Incoming Calls

Ringing call screen over the lock screen Permission removed for Play compliance
  • Radar's native voice/video calling originally used USE_FULL_SCREEN_INTENT with setFullScreenIntent() so an incoming call would launch the full call screen directly over the lock screen — exactly like the system phone app.
  • Google restricts USE_FULL_SCREEN_INTENT to apps whose core purpose is phone calls or alarms. Since Radar is a communication/mesh app — not a dialler — we are not eligible. On Android 14+ the permission is also revoked by default for non-calling apps.
  • How Radar adapted: incoming calls are now surfaced as a high-priority CallStyle heads-up notification with Accept / Decline buttons. Tapping the banner opens the call screen. We lose the automatic lock-screen takeover, but stay compliant.
What Radar needed A ringing call screen that wakes the device and shows over the lock screen for a true phone-call experience.
The constraint USE_FULL_SCREEN_INTENT is reserved for calling/alarm apps only. A non-calling app declaring it risks Play rejection and silent revocation on Android 14+.
🏃

Activity Recognition — Movement Detection

Walking / driving / cycling transport mode Removed under Health-apps policy
  • Radar adjusts its behaviour by transport mode (on foot vs in a vehicle). The natural API for this is ACTIVITY_RECOGNITION, which reads the device's hardware activity classifier.
  • Google Play treats activity-recognition / fitness signals as health data. Apps requesting it are pushed toward the Health-apps policy and Health Connect requirements. Radar is a communication app, not a fitness or health app, so declaring the permission invited policy scrutiny we could not satisfy.
  • How Radar adapted: transport mode is now derived purely from GPS-derived speed plus map context — no health-class permission required.
📍

Background ("All the time") Location

Proximity radar & presence beaconing while closed Restricted permission · manual review
  • Radar's core value is knowing who is near you and broadcasting your own presence — which requires location access even when the app is in the background. ACCESS_BACKGROUND_LOCATION makes this possible. Android API 29+
  • Google Play classifies background location as a sensitive, restricted permission. It cannot simply be shipped: it requires a written justification, a recorded prominent disclosure + runtime consent flow, and a manual policy review (often including a demo video) before the build is approved for production.
  • On Android 10+ the permission can no longer be requested in the same prompt as foreground location — the user must be sent to system settings and grant "Allow all the time" as a separate, deliberate step, which Radar guides them through.
💬

SMS / MMS Access

Unified inbox of SMS alongside mesh messages Restricted SMS/Call-Log policy
  • Radar can surface SMS and MMS next to mesh messages in one inbox. That needs READ_SMS, RECEIVE_SMS, SEND_SMS, READ_MMS and RECEIVE_MMS.
  • SMS and Call Log are a restricted permission group under Play policy. Each requires a Permissions Declaration Form, a qualifying core use-case, prominent disclosure, and policy review. Most use-cases are rejected outright — only a narrow set (default handler, backup, etc.) qualify.
  • Writing to the SMS store requires being the default SMS app. Since Android 4.4, only the user-selected default SMS application may write to the SMS content provider — so any "mark as read"/compose flow is gated behind the user actively choosing Radar as their default messaging app.
  • Even receiving SMS has rules: SMS_RECEIVED is a protected broadcast (exempt from the Android 8.0+ implicit-broadcast ban) and its priority is non-abortable since KitKat — so the manifest receiver had to be declared exactly as the platform dictates.
📴

April 2026 — The End of "Mirror Mode"

Reading incoming SMS without replacing your messaging app Secondary-handler path closed by Google
  • How Radar used to do it. Originally Radar ran as a lightweight secondary SMS handler — a "mirror". It listened for the SMS_RECEIVED broadcast and read the inbox with READ_SMS / RECEIVE_SMS, so your texts could appear alongside mesh messages without Radar ever taking over as your default messaging app. Google Messages (or whatever you use) stayed in charge; Radar just observed.
  • What changed in April 2026. Google's updated SMS & Call Log Permissions policy stopped accepting the "read-only mirror" use-case. Under the new enforcement the review team cross-checks the Permissions Declaration Form against the actual manifest: if an app requests the restricted SMS permissions, it must genuinely be able to become the device's default SMS handler — passively mirroring another app's messages is no longer an approved reason to hold those permissions. Radar's build was rejected with "Issue with default handler capability — your app does not satisfy the requirements for default handler capability."
  • Why "just mirror it" is now impossible. The old path relied on a non-abortable SMS_RECEIVED listener that coexisted with the real SMS app. Google now treats that coexistence as the abuse vector it wants to close: only one app may legitimately touch SMS, and it must be the one the user has deliberately chosen as their default. There is no longer any compliant middle ground between "not touching SMS at all" and "being the full default app."
  • How Radar adapted — and why it now needs MORE permissions. To keep the feature at all, Radar had to become a real, fully-fledged default SMS app that the user can opt into. Being merely able to read messages was the easy part; Android requires a genuine default app to own the entire SMS lifecycle, which pulls in a whole extra tier of permissions and components we never needed as a mirror.
Old "mirror" — minimum footprint A single SMS_RECEIVED receiver + READ_SMS / RECEIVE_SMS. Radar observed; your existing app stayed in charge and kept persisting messages.
New "default handler" — the extra burden To satisfy Google, Radar must now declare all four default-handler components and take on responsibilities a mirror never had:
• A SMS_DELIVER receiver guarded by BROADCAST_SMS (the exclusive delivery path for the default app).
• A WAP_PUSH_DELIVER receiver guarded by BROADCAST_WAP_PUSH for MMS.
• An ACTION_SENDTO compose activity (sms:/mms:).
• A RESPOND_VIA_MESSAGE service holding SEND_RESPOND_VIA_MESSAGE (quick-reply from the dialler).
Plus Radar must now write every received message to the system SMS provider itself — because the default app, not the OS, is responsible for persisting texts.
⚖️ The irony: a policy meant to reduce how much SMS access apps hold ended up forcing Radar to request substantially more. The privacy-lighter "just mirror my texts" option is gone; the only compliant way to show SMS in Radar is for the user to make Radar their default messaging app outright — and every SMS code path is now gated behind a live RoleManager.isRoleHeld(ROLE_SMS) check, so the moment the user switches their default away, Radar stops reading, receiving and sending SMS completely.
🔋

Battery-Optimisation Exemption (Doze)

Reliable background delivery while idle Restricted to eligible categories
  • For a mesh node to relay messages and receive SMS while the screen is off, it must survive Doze. Radar asks for a Doze whitelist exemption via REQUEST_IGNORE_BATTERY_OPTIMIZATIONS.
  • Google Play only permits this permission for specific app categories — and explicitly disallows it where a foreground service or other API would do. Radar qualifies as a messaging / peer-discovery app, but the declaration sits under policy review and must be justified.
  • On strict OEM builds (e.g. OxygenOS) the system dialog throws a SecurityException unless the permission is declared — so it must be in the manifest even though Play scrutinises it.
🛡️

Foreground-Service Types (Android 14+)

The always-on background process Declared type + Play justification
  • Radar's persistent background work runs in a single foreground service. On Android 14+ (API 34) every foreground service must declare a type, and Radar declares location|connectedDevice.
  • Omitting the right type is a hard crash, not a warning. A service that does BLE scanning/advertising or GATT without connectedDevice throws MissingForegroundServiceTypeException on launch — confirmed on strict OEM builds.
  • Each declared type also carries its own runtime permission requirement (FOREGROUND_SERVICE_LOCATION, FOREGROUND_SERVICE_CONNECTED_DEVICE) and a Play Console justification for why the service runs at all.
  • Knock-on effect: because exact alarms (SCHEDULE_EXACT_ALARM) are also a restricted permission on Android 12+, Radar deliberately avoids them and restarts its service via WorkManager expedited jobs instead — no special permission needed.

Part 2 · Android Platform & Radio Limits

Hard technical constraints baked into Android's Bluetooth, Wi-Fi and process model. These aren't policy choices — they're the physics of the APIs, and each one dictated a specific piece of Radar's architecture.

📡

BLE Advertising — 31 Bytes & No Live Format Change

Presence beacons carrying location + identity Hard radio limit
  • A legacy BLE advertisement packet is only 31 bytes. After flags and the manufacturer-ID header, Radar has just 24 usable bytes of payload — 25 bytes fails outright with "Data too large". Everything a beacon carries (a 128-bit UUID, latitude, longitude, age, gender) had to be hand-packed at the bit level into those 24 bytes.
  • You cannot change a beacon's format on the fly. Android's BluetoothLeAdvertiser won't let you mutate the advertisement structure while it's running — switching format means stopping and restarting the advertiser, which drops the beacon, risks interrupting in-flight peer handshakes, and is throttled by the OS if done too often.
  • How Radar adapted: a single fixed beacon format plus heavy throttling — payload updates are skipped unless the device has moved ≥5 m or several seconds have passed, and live updates reuse the same format so the advertiser never has to restart. Anything that won't fit in 24 bytes is exchanged later over GATT.
🔀

Wi-Fi Aware Must Be Bootstrapped Over BLE GATT

High-bandwidth offline data path between peers Two-radio handshake forced
  • Wi-Fi Aware (NAN) gives Radar a fast, router-less data link — but it cannot find a peer on its own in the way Radar needs. Discovery is publish/subscribe on opaque service names, and a data path can't be reliably negotiated until both sides have already agreed on identity and roles.
  • So the handshake rides on Bluetooth first. Peers are discovered via the 24-byte BLE presence beacon; a BLE GATT connection is then opened, and identity, role (publisher vs subscriber, decided by UUID comparison) and the NAN_READY signal are all exchanged over GATT. Only once that completes is the Wi-Fi Aware data path brought up.
  • This is a genuine two-radio dance: BLE advertise → BLE scan → GATT connect → handshake → then Wi-Fi Aware. The Android APIs don't offer a single combined discovery+connect primitive, so Radar had to build and sequence the entire state machine itself.
  • Side effect: BLE advertising has to be carefully paused/coordinated during the GATT handshake so restarting the beacon doesn't tear down a connection that is mid-negotiation.
What Radar needed "Find nearby peer, open a fast Wi-Fi link, send data" — in one step.
The constraint Wi-Fi Aware discovery can't carry the handshake, so identity & readiness must be brokered over BLE GATT off the back of a BLE advert before NAN can connect.
📦

BLE GATT Payload & MTU Ceiling

Delivering messages when only Bluetooth is available ~512-byte MTU cap
  • When Wi-Fi Aware isn't available, messages fall back to BLE GATT. But a GATT characteristic write is capped by the negotiated MTU (max ~512 bytes), leaving roughly 480 bytes of usable payload after overhead.
  • Anything larger must be compressed and/or fragmented and reassembled by hand. Radar squeezes encrypted message envelopes under that ceiling and chunks larger content across multiple writes — there is no "just send a big buffer" option on BLE.
🚫

No Background Activity Starts (Android 10+)

Showing an incoming call / message from the service startActivity() blocked
  • When a call or message arrives, the work is happening inside Radar's background service — but on Android 10+ a background process cannot call startActivity(). The launch is silently dropped.
  • This even applies to CATEGORY_CALL notifications and broadcast receivers: a BroadcastReceiver trying to start an activity is blocked too.
  • How Radar adapted: the UI is never launched directly from the background. Instead Radar posts a notification whose PendingIntent carries the foreground-start right — the system itself opens the call/chat screen when the user taps it.
♻️

The OS (and OEMs) Keep Killing the Service

Keeping the mesh alive when the app is closed Aggressive process death
  • A mesh node has to keep running after the user swipes the app away. By default Android kills the service on task removal, so Radar sets android:stopWithTask="false" to survive "Close All".
  • On Android 14+ the user can swipe away the foreground-service notification itself, and many OEM skins (OxygenOS, MIUI, etc.) run aggressive task-killers that ignore these hints entirely.
  • Starting a foreground service from the background is itself restricted — doing it at the wrong moment throws ForegroundServiceStartNotAllowedException.
  • How Radar adapted: a ServiceWatchdog monitors the sub-services and restarts any that die; a boot receiver re-launches after reboot; and recovery is scheduled through WorkManager expedited jobs (rather than exact alarms) so the radios resume within seconds without needing a restricted permission.
🔵

Bluetooth Permissions Split (Android 12+)

Scanning, connecting and advertising Permission model rewrite
  • On Android 12 (API 31) the old BLUETOOTH / BLUETOOTH_ADMIN permissions were replaced by runtime permissions BLUETOOTH_SCAN, BLUETOOTH_CONNECT and BLUETOOTH_ADVERTISE — each prompted at runtime.
  • The manifest has to carry both the legacy permissions (capped with maxSdkVersion="30" so they don't show up for modern users) and the new ones, with runtime guards branching on the API level throughout the BLE code.
  • Before Android 12, BLE scanning also legally required location permission, conflating "find nearby Bluetooth" with "track my location" in the user's eyes — a recurring source of permission friction.
🗂️

Scoped Storage & the Photo Picker

Sharing and saving photos / attachments No more free file access
  • From Android 10, scoped storage ended broad file-system access. Radar can no longer just read/write arbitrary paths; saving media goes through MediaStore, and mismatched parameters throw IllegalArgumentException on Q+.
  • Photo selection uses the system Photo Picker with READ_MEDIA_IMAGES and READ_MEDIA_VISUAL_USER_SELECTED (API 34), so the user can grant access to only the photos they pick — broad storage permissions are capped at older API levels (maxSdkVersion).
  • The blunt alternative, MANAGE_EXTERNAL_STORAGE ("All files access"), is a restricted Play permission granted to almost no app category — so it was never an option.
🔍

Package Visibility (Android 11+)

Offering installed apps as call/share fallbacks Apps hidden by default
  • On Android 11 (API 30), apps can no longer see what else is installed. queryIntentActivities() and getLaunchIntentForPackage() return empty/null for anything not explicitly declared.
  • So that Radar can offer to hand off a call to WhatsApp / Telegram / Signal / Meet (when a native call isn't possible), every such app must be listed in a <queries> block in the manifest — visibility has to be requested package by package.
💡 And it isn't even on every Android phone. Wi-Fi Aware (NAN) and UWB are optional hardware features — many mid-range and older devices simply don't have them. Radar marks them required="false" and every transport gracefully degrades: Wi-Fi Aware → Wi-Fi Direct → BLE GATT → LAN → web relay, depending on what the specific handset actually supports. The "build once, run everywhere" promise has a lot of runtime branching behind it.
None of this is a complaint about Android — it remains the only platform that lets an app like Radar exist at all. Google's policies exist to protect users' privacy, battery and security, and most of the constraints above are reasonable defaults for the 99% of apps that don't need raw radio access.

But Radar lives in that last 1%. Building an off-grid mesh on a modern, locked-down mobile OS means working with every one of these rules: packing beacons to the byte, brokering Wi-Fi over Bluetooth, surfacing calls through notifications, and re-architecting features when a policy wall appears. The result is an app shaped as much by Google's rulebook as by its own ambitions.

→ Why Radar can't run on iOS at all  ·  → The radio tech under the hood  ·  → How we handle your permissions