<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Android - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Android - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 05 Aug 2026 22:41:36 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/android/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ A Deep Dive into Gabeldorsche: The Bluetooth Stack Android Rebuilt on Purpose ]]>
                </title>
                <description>
                    <![CDATA[ The Android Bluetooth stack spent about a decade being the reason your headphones disconnected during the good part of a song. Gabeldorsche is Google's attempt to fix that at the architectural level,  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/a-deep-dive-into-gabeldorsche-the-bluetooth-stack-android-rebuilt-on-purpose/</link>
                <guid isPermaLink="false">6a5660e4c55512c39277bc80</guid>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Gabeldorsche ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 16:16:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f25fd623-83c1-44fa-abc5-7100ca07af0c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The Android Bluetooth stack spent about a decade being the reason your headphones disconnected during the good part of a song.</p>
<p>Gabeldorsche is Google's attempt to fix that at the architectural level, not with another patch on top of the old code but with a ground-up rewrite of the stack internals.</p>
<p>This article explains what Gabeldorsche actually is, how its architecture is put together, and how the pieces fit at the implementation level.</p>
<p>We'll walk through the OS abstraction, the module system, the threading and queue model, the HCI layer, the packet parsing generator, the ACL data path, L2CAP, security, the neighbor and storage modules, the shim and facade layers, the build system, and the testing infrastructure, with real code patterns for each.</p>
<p>The scope here is the internal architecture of the stack, not the public Android Bluetooth APIs your app calls. If you've ever wondered what happens between <code>BluetoothDevice.createBond()</code> and the actual radio, this is that layer, and this article goes deep into it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-gabeldorsche-is-and-why-it-exists">What Gabeldorsche Is and Why It Exists</a></p>
</li>
<li><p><a href="#heading-a-short-history-and-the-migration-strategy">A Short History and the Migration Strategy</a></p>
</li>
<li><p><a href="#heading-the-layered-architecture">The Layered Architecture</a></p>
</li>
<li><p><a href="#heading-the-os-abstraction-layer">The OS Abstraction Layer</a></p>
</li>
<li><p><a href="#heading-the-module-system">The Module System</a></p>
</li>
<li><p><a href="#heading-the-stack-bootstrap">The Stack Bootstrap</a></p>
</li>
<li><p><a href="#heading-the-queue-abstraction">The Queue Abstraction</a></p>
</li>
<li><p><a href="#heading-the-hci-layer">The HCI Layer</a></p>
</li>
<li><p><a href="#heading-the-packet-definition-language">The Packet Definition Language</a></p>
</li>
<li><p><a href="#heading-the-acl-manager-and-connection-management">The ACL Manager and Connection Management</a></p>
</li>
<li><p><a href="#heading-the-round-robin-scheduler-and-the-acl-data-path">The Round Robin Scheduler and the ACL Data Path</a></p>
</li>
<li><p><a href="#heading-l2cap-and-the-data-pipeline">L2CAP and the Data Pipeline</a></p>
</li>
<li><p><a href="#heading-security-and-pairing">Security and Pairing</a></p>
</li>
<li><p><a href="#heading-gatt-and-att">GATT and ATT</a></p>
</li>
<li><p><a href="#heading-the-neighbor-and-storage-modules">The Neighbor and Storage Modules</a></p>
</li>
<li><p><a href="#heading-the-shim-and-facade-layers">The Shim and Facade Layers</a></p>
</li>
<li><p><a href="#heading-build-system-integration">Build System Integration</a></p>
</li>
<li><p><a href="#heading-logging-metrics-and-dumpsys">Logging, Metrics, and dumpsys</a></p>
</li>
<li><p><a href="#heading-testing-with-cert-tests-and-rootcanal">Testing with Cert Tests and RootCanal</a></p>
</li>
<li><p><a href="#heading-floss-gabeldorsche-beyond-android">Floss: Gabeldorsche Beyond Android</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with modern C++ (C++17 idioms like lambdas, <code>std::unique_ptr</code>, move semantics, and template basics), and you should have a rough mental model of the Bluetooth protocol stack (HCI, L2CAP, ATT, GATT, the difference between Classic and Low Energy).</p>
<p>Familiarity with event-driven and message-passing concurrency helps a great deal, because Gabeldorsche leans on it hard. Some exposure to epoll or a reactor-style event loop will make the OS layer feel familiar.</p>
<p>You don't need to have hacked on AOSP before, but knowing that AOSP is enormous and slow to build will emotionally prepare you.</p>
<h2 id="heading-what-gabeldorsche-is-and-why-it-exists">What Gabeldorsche Is and Why It Exists</h2>
<p>Gabeldorsche, usually abbreviated as GD, is the rearchitected core of the Android Bluetooth stack. The name is a running Google tradition of picking place names in the Bavarian Alps region as codenames. And yes, nearly everyone mispronounces it, which is arguably part of the charm.</p>
<p>In the source tree it lives under the Bluetooth module at <code>packages/modules/Bluetooth/system/gd/</code>, having graduated from its original home at <code>system/bt/gd/</code> when Bluetooth became an updatable Mainline module.</p>
<p>The stack it replaces is usually called Fluoride, and before that BlueDroid. That older code worked, in the sense that a bridge held up by hope also works.</p>
<p>It was built around a large web of global state, callback chains that were difficult to reason about, and threading that varied by subsystem. When something went wrong, reproducing it was a coin flip, and unit testing individual layers in isolation ranged from painful to impossible. Bluetooth bugs became famous for being non-deterministic, and non-deterministic bugs are the ones that survive to production and then to angry reviews.</p>
<p>Gabeldorsche was designed around a small number of firm opinions. Every layer should be a self-contained module with explicit dependencies. Concurrency should be message passing on well-defined threads instead of shared locks scattered through the code. Packet parsing should be generated from a formal specification rather than hand-written byte arithmetic, because hand-written byte arithmetic is where security bugs go to be born. Every layer should be testable in isolation against a virtual controller, so continuous integration can catch regressions before your earbuds do. And the whole thing should be portable enough to run somewhere other than Android, which turned out to matter more than anyone expected.</p>
<h2 id="heading-a-short-history-and-the-migration-strategy">A Short History and the Migration Strategy</h2>
<p>Gabeldorsche was announced publicly around 2019 and 2020 as a multi-year effort, and it was never going to land as a single commit. You can't atomically swap the Bluetooth stack on a billion devices, so the rollout was designed to be gradual, reversible, and boring, which are three excellent adjectives for infrastructure work.</p>
<p>The migration proceeded one layer at a time from the bottom up. The HCI layer moved to GD first, because it sits closest to the controller and has the cleanest boundary. Then the ACL and connection management layers, then L2CAP, then security, and so on upward.</p>
<p>Each layer was gated behind a flag so that a device could run the new GD implementation of one layer while still using the legacy implementation of the layers above and below it. This is why the shim layer, discussed later, exists at all.</p>
<p>The flags themselves lived as system properties and later as aconfig flags, and they let the team ship a GD layer to a small population, watch the crash and connection metrics, and roll it back instantly if something regressed.</p>
<p>Because each layer was independently toggleable, a regression could be bisected to a single layer by flipping flags rather than by staring at stack traces. This unglamorous flag discipline is a large part of why the rewrite reached production without a catastrophe, and it's worth studying even if you never touch Bluetooth.</p>
<h2 id="heading-the-layered-architecture">The Layered Architecture</h2>
<p>Gabeldorsche is organized as a stack of layers, each one a module that depends on the layers beneath it. The following diagram shows the major layers from the radio hardware at the bottom to the Android framework at the top.</p>
<pre><code class="language-plaintext">        +------------------------------------------+
        |     Android Framework (Java / AIDL)      |
        +------------------------------------------+
        |     BTIF / BTA (legacy profile logic)    |
        +------------------------------------------+
        |     Shim layer (GD &lt;~&gt; legacy bridge)    |
        +------------------------------------------+
        |  GATT | Security | L2CAP | Neighbor      | 
        +------------------------------------------+
        |  AclManager | Controller | HciLayer      |   
        +------------------------------------------+
        |     hci_hal (HAL: AIDL / HIDL interface) |
        +------------------------------------------+
        |     Bluetooth Controller (radio chip)    |
        +------------------------------------------+
</code></pre>
<p>The diagram reads bottom to top as a dependency ladder. The controller chip speaks HCI over a physical transport. The <code>hci_hal</code> layer wraps the Android hardware abstraction interface so the rest of the stack doesn't care whether the transport is UART, USB, or a virtual socket.</p>
<p>Above it, the HCI layer manages command flow control and demultiplexes events, the <code>Controller</code> module caches the chip's capabilities, and <code>AclManager</code> owns connections.</p>
<p>The upper layers implement L2CAP channels, security, neighbor operations like inquiry and page scan, and the GATT database. The shim layer is a temporary bridge that lets the new GD modules coexist with the older BTIF and BTA profile code during migration. At the very top sits the Android framework that applications talk to.</p>
<p>The important structural point is that each layer only reaches downward through explicit interfaces, never sideways into another layer's internals.</p>
<h2 id="heading-the-os-abstraction-layer">The OS Abstraction Layer</h2>
<p>Before any Bluetooth logic exists, Gabeldorsche defines its own small operating system abstraction in the <code>os/</code> directory. This is the foundation everything else stands on, and it exists so the stack can run unchanged on Android, Linux, and inside tests.</p>
<p>The following table lists the core primitives and what each one is responsible for:</p>
<table>
<thead>
<tr>
<th>Primitive</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td><code>Thread</code></td>
<td>Owns a reactor and runs its event loop, with a selectable scheduling priority</td>
</tr>
<tr>
<td><code>Reactor</code></td>
<td>An epoll-based event loop that dispatches on file descriptor readiness</td>
</tr>
<tr>
<td><code>Handler</code></td>
<td>Posts closures onto a specific thread for serial, lock-free execution</td>
</tr>
<tr>
<td><code>Alarm</code> / <code>RepeatingAlarm</code></td>
<td>Schedules closures to run after a delay or on an interval</td>
</tr>
<tr>
<td><code>Queue</code></td>
<td>A reactive, bounded, single-producer single-consumer channel</td>
</tr>
<tr>
<td><code>EnqueueBuffer</code></td>
<td>A helper that buffers items and feeds them into a <code>Queue</code> on demand</td>
</tr>
</tbody></table>
<p>This table maps each OS primitive to its one job. <code>Thread</code> and <code>Reactor</code> are the execution substrate: a thread is a reactor plus a real kernel thread wrapped around it. <code>Handler</code> is how other code schedules work onto a thread without touching its internals. <code>Alarm</code> and <code>RepeatingAlarm</code> add time to the model. <code>Queue</code> and <code>EnqueueBuffer</code> are the data-movement primitives that connect producers to consumers.</p>
<p>Everything above this layer is built out of exactly these pieces, which is why understanding them pays off across the entire codebase.</p>
<p>A <code>Thread</code> in GD isn't just a raw kernel thread. It owns a <code>Reactor</code>, which is an epoll-based event loop. Instead of blocking on a socket read, you register a file descriptor with the reactor and hand it a callback to run when the descriptor becomes readable.</p>
<p>The thread spins the reactor loop, and all work on that thread happens as reactions to events. Threads can be created at normal or real-time priority, and the stack uses a higher priority for the threads on the data path so that audio doesn't stutter when the system is busy. This is the same idea as libevent or the Node.js event loop, just wearing a Bluetooth badge.</p>
<p>The <code>Reactor</code> is worth understanding because it's the beating heart of every thread. It holds an epoll file descriptor and a set of registered reactables, each pairing a file descriptor with an on-read and an optional on-write callback.</p>
<p>The loop calls <code>epoll_wait</code>, and for each ready descriptor it invokes the registered callback. One subtle detail is unregistration safety: a callback might ask to unregister its own reactable while the reactor is mid-dispatch, so the reactor tracks the currently executing reactable and defers its destruction, which prevents a use-after-free that would otherwise be trivial to trigger. The reactor also uses an internal control file descriptor so another thread can wake it up to stop cleanly.</p>
<p>A <code>Handler</code> is the mechanism for getting work onto a specific thread. You post a closure to a handler, and the closure runs on that handler's thread. Internally, a handler owns a queue of closures plus an eventfd registered with the reactor, so posting work writes to the eventfd, which wakes the reactor, which drains the closure queue.</p>
<p>This is how the entire stack avoids locks: instead of two threads grabbing a mutex to touch shared state, one thread posts a message to the other thread's handler, and the state is only ever touched by its owning thread.</p>
<pre><code class="language-cpp">// Get the module's handler and post work onto its thread.
os::Handler* handler = GetHandler();

handler-&gt;Post(common::BindOnce(
    [](int connection_handle) {
      // This lambda runs on the handler's thread, not the caller's.
      LOG_INFO("Tearing down connection %d", connection_handle);
    },
    connection_handle));
</code></pre>
<p>This snippet shows the central concurrency pattern of the stack. <code>GetHandler()</code> returns the handler associated with the current module, which is bound to a specific thread. <code>common::BindOnce</code> packages a callable together with its arguments into a one-shot closure, similar to <code>std::bind</code> but move-aware and single-use, which matters because many Bluetooth payloads are move-only buffers. <code>handler-&gt;Post</code> writes to the handler's eventfd and enqueues that closure to run on the handler's thread. The consequence is that the lambda body executes serially on one thread, so it can read and write that module's state without any mutex.</p>
<p>If you internalize one thing about Gabeldorsche, make it this: work travels to the data's thread, the data does not travel to the work.</p>
<p>For time-based work, there's <code>Alarm</code> and <code>RepeatingAlarm</code>. An alarm schedules a closure to run on a handler after a delay, and it's built on top of the same reactor using a timerfd, so timers are just another readable file descriptor in the loop rather than a separate timer thread.</p>
<pre><code class="language-cpp">// Fire a one-shot timeout on this module's handler thread.
alarm_ = std::make_unique&lt;os::Alarm&gt;(GetHandler());

alarm_-&gt;Schedule(
    common::BindOnce(&amp;MyModule::OnConnectionTimeout, common::Unretained(this)),
    std::chrono::milliseconds(5000));
</code></pre>
<p>The alarm here is constructed against the module's handler, which ties its callback to the correct thread. <code>Schedule</code> takes a closure and a duration, arms a timerfd for that duration, and when it fires the reactor runs <code>OnConnectionTimeout</code> on the handler thread.</p>
<p><code>common::Unretained(this)</code> tells the binder to keep a raw pointer to the object without extending its lifetime, which is safe here precisely because the alarm and the object live on the same thread and are torn down in a known order.</p>
<p>That last point matters. <code>Unretained</code> is a promise you're making to the compiler, and if you break it, the crash will be memorable and probably remote.</p>
<h2 id="heading-the-module-system">The Module System</h2>
<p>Every functional layer in Gabeldorsche is a <code>Module</code>. A module has a lifecycle, a set of dependencies, and its own handler thread affinity. The <code>ModuleRegistry</code> is responsible for starting modules in dependency order and stopping them in reverse. This turns the old tangle of initialization ordering into something a computer can figure out on its own, which is a healthier arrangement than a comment that says "do not reorder these calls."</p>
<p>A module subclasses <code>Module</code>, declares a static <code>ModuleFactory</code>, and implements a small set of methods. The most important ones are <code>ListDependencies</code>, <code>Start</code>, and <code>Stop</code>.</p>
<pre><code class="language-cpp">class ExampleModule : public bluetooth::Module {
 public:
  static const ModuleFactory Factory;

 protected:
  void ListDependencies(ModuleList* list) const override {
    list-&gt;add&lt;hci::HciLayer&gt;();
    list-&gt;add&lt;hci::Controller&gt;();
  }

  void Start() override {
    hci_layer_ = GetDependency&lt;hci::HciLayer&gt;();
    controller_ = GetDependency&lt;hci::Controller&gt;();
    // Module is now ready to do work on GetHandler().
  }

  void Stop() override {
    // Release references; the registry stops dependencies after this.
    hci_layer_ = nullptr;
    controller_ = nullptr;
  }

  std::string ToString() const override { return "ExampleModule"; }

 private:
  hci::HciLayer* hci_layer_ = nullptr;
  hci::Controller* controller_ = nullptr;
};

const ModuleFactory ExampleModule::Factory =
    ModuleFactory([]() { return new ExampleModule(); });
</code></pre>
<p>This class demonstrates the full contract of a module.</p>
<p><code>ListDependencies</code> declares, at construction time, which other modules this one needs, by adding their types to the <code>ModuleList</code>. The registry reads these declarations across all modules and computes a start order so that <code>HciLayer</code> and <code>Controller</code> are running before <code>ExampleModule::Start</code> is ever called.</p>
<p>Inside <code>Start</code>, <code>GetDependency&lt;T&gt;()</code> looks up the already-running instance of each dependency by its factory and returns it as a raw pointer, which the module caches because the registry guarantees the dependency outlives it. <code>Stop</code> runs during shutdown before the dependencies are stopped, giving the module a chance to release references and cancel outstanding work.</p>
<p><code>ToString</code> gives the module a name for logging and dumpsys. The static <code>Factory</code> is a small object holding a lambda that constructs the module, and it is the handle the registry uses both to instantiate the module and to identify it in the dependency graph. There's no global initialization order to get wrong, because you never write the order at all.</p>
<p>The registry itself is what glues this together at boot, and it also assigns each module its handler.</p>
<pre><code class="language-cpp">ModuleList modules;
modules.add&lt;ExampleModule&gt;();

// The registry topologically sorts and starts everything.
ModuleRegistry registry;
registry.Start(&amp;modules, thread);

// ... stack runs ...

registry.StopAll();
</code></pre>
<p>Here the application declares the top-level modules it wants and hands them to the registry along with the thread the modules will run on.</p>
<p><code>Start</code> walks the dependency graph, starts each module exactly once in topological order, injects dependencies, and gives each module a <code>Handler</code> bound to the supplied thread.</p>
<p><code>StopAll</code> reverses the process, calling <code>Stop</code> in the exact reverse of the start order so no module is ever torn down before something that depends on it.</p>
<p>Because dependencies are explicit data rather than imperative code, the same machinery powers a <code>TestModuleRegistry</code> that lets a test start a single real module on top of fake dependencies.</p>
<p>That test variant is the quiet superpower of this design. Because a module only ever reaches its dependencies through <code>GetDependency&lt;T&gt;()</code>, a test can register a fake <code>HciLayer</code> before starting the real module under test, and the module can't tell the difference.</p>
<pre><code class="language-cpp">TestModuleRegistry test_registry;
test_registry.InjectTestModule(&amp;HciLayer::Factory, fake_hci_layer_);
test_registry.Start&lt;ExampleModule&gt;(&amp;test_registry.GetTestModuleList());

// Drive the fake HCI layer, assert on what ExampleModule does in response.
</code></pre>
<p>In this test setup, <code>InjectTestModule</code> pre-registers a fake implementation against the real <code>HciLayer</code> factory key, so any module that depends on <code>HciLayer</code> transparently receives the fake. <code>Start&lt;ExampleModule&gt;</code> then brings up the real module under test on top of it. The test can now feed events into the fake HCI layer and assert on the commands <code>ExampleModule</code> sends, all on a controlled thread, with no hardware and no other layers involved.</p>
<p>This is unit testing in the honest sense of the word, where the unit is genuinely isolated, which was close to impossible in the previous stack.</p>
<h2 id="heading-the-stack-bootstrap">The Stack Bootstrap</h2>
<p>Something has to construct the registry, pick the thread, add the top-level modules, and bring the whole thing to life. That something is the <code>Stack</code> object. It's the single entry point that owns the <code>ModuleRegistry</code> and the main thread, and it's what the shim layer talks to when Android decides Bluetooth should turn on.</p>
<p>The bootstrap does three things in order. It creates a <code>Thread</code> at the appropriate priority for the stack to run on. It builds a <code>ModuleList</code> containing the top-level modules for the current configuration, which pulls in all of their transitive dependencies automatically. Then it starts the registry against that thread and blocks until every module has finished starting, so that by the time the call returns the stack is fully operational.</p>
<p>Shutdown is the mirror image: stop the registry, which stops every module in reverse dependency order, then join the thread.</p>
<p>Concentrating this in one object means there's exactly one place that knows how the stack comes up and goes down, instead of the historical situation where startup was an emergent property of many files being included in the right order and hoping.</p>
<h2 id="heading-the-queue-abstraction">The Queue Abstraction</h2>
<p>Modules that produce and consume a stream of packets don't call each other directly. They connect through a <code>Queue</code>, which is a reactive, bounded, single-producer single-consumer channel built on the reactor. This is how ACL data, for example, flows between L2CAP and the HCI layer without either side blocking or sharing a lock.</p>
<p>The queue exposes two half-interfaces, one for each end. The producer side implements enqueue by registering a callback that the queue invokes when there's room. The consumer side registers a callback that the queue invokes when data is available. Nobody ever busy-waits, and nobody blocks on a full or empty queue.</p>
<pre><code class="language-cpp">// Producer side: register to be asked for the next packet.
queue_end_-&gt;RegisterEnqueue(
    handler_,
    common::Bind(&amp;MyModule::OnQueueReadyToSend, common::Unretained(this)));

std::unique_ptr&lt;packet::BasePacketBuilder&gt; MyModule::OnQueueReadyToSend() {
  if (pending_packets_.empty()) {
    queue_end_-&gt;UnregisterEnqueue();  // Nothing to send; stop being asked.
    return nullptr;
  }
  auto packet = std::move(pending_packets_.front());
  pending_packets_.pop();
  return packet;
}
</code></pre>
<p>This is the enqueue half of the pattern and it's inverted from what most people expect. You don't push data into the queue. Instead you call <code>RegisterEnqueue</code> with a callback, and when the queue has capacity it calls your callback asking for the next item.</p>
<p>Your callback returns one packet builder, or returns <code>nullptr</code> after calling <code>UnregisterEnqueue</code> when you have nothing left to send. This inversion is deliberate: it means backpressure is automatic. If the downstream consumer is slow and the queue fills up, your callback simply stops being called, and packets pile up in your own buffer where you can see them and account for them, rather than in some hidden kernel buffer where they turn into latency you can't explain.</p>
<p>The dequeue half mirrors this exactly.</p>
<pre><code class="language-cpp">// Consumer side: register to be told when a packet arrives.
queue_end_-&gt;RegisterDequeue(
    handler_,
    common::Bind(&amp;MyModule::OnPacketReceived, common::Unretained(this)));

void MyModule::OnPacketReceived() {
  auto packet = queue_end_-&gt;TryDequeue();
  if (packet == nullptr) {
    return;
  }
  // Process the received packet on handler_'s thread.
}
</code></pre>
<p>On the consuming side, <code>RegisterDequeue</code> hands the queue a callback bound to a handler. When a packet becomes available, the queue posts that callback to the handler thread, and inside it you call <code>TryDequeue</code> to retrieve the packet. The <code>TryDequeue</code> can still return <code>nullptr</code> if the item was already taken, so you check. The same thread-affinity rule applies: the callback runs on <code>handler_</code>'s thread, so packet processing is serial and lock-free.</p>
<p>Under the hood the two ends coordinate through a small reactive semaphore built on an eventfd, which is what lets one thread's producer safely signal another thread's consumer. When you have many items to push, <code>EnqueueBuffer</code> wraps this whole dance so you can add items and let the buffer drive the registration for you, which is what most call sites actually use.</p>
<h2 id="heading-the-hci-layer">The HCI Layer</h2>
<p>The Host Controller Interface is the protocol between the host stack and the controller chip. In Gabeldorsche the <code>HciLayer</code> module owns the command channel and enforces the one rule that trips up every naïve implementation: the controller tells you how many commands it can accept at once through a credit count. If you send more than it has credits for, things break in ways that are miserable to trace.</p>
<p>You don't write HCI bytes directly. You build a typed command packet, hand it to the HCI layer, and provide a callback for the eventual response. The layer handles flow control, matching responses to the commands that triggered them, and routing unsolicited events to subscribers.</p>
<pre><code class="language-cpp">hci_layer_-&gt;EnqueueCommand(
    hci::ResetBuilder::Create(),
    GetHandler()-&gt;BindOnceOn(this, &amp;MyModule::OnResetComplete));

void MyModule::OnResetComplete(hci::CommandCompleteView view) {
  auto reset_view = hci::ResetCompleteView::Create(view);
  ASSERT(reset_view.IsValid());
  if (reset_view.GetStatus() != hci::ErrorCode::SUCCESS) {
    LOG_ERROR("Reset failed");
  }
}
</code></pre>
<p>This sends the HCI Reset command and handles its completion. <code>hci::ResetBuilder::Create()</code> builds a typed, validated command packet, so you physically can't send a malformed Reset.</p>
<p><code>EnqueueCommand</code> places the command in the layer's internal command queue, which only releases a command to the controller when a credit is available and holds the rest until the controller returns credits in its responses. The second argument is a callback bound to your handler with <code>BindOnceOn</code>, so the completion runs on your thread.</p>
<p>Commands come in two flavors, those answered by a Command Complete event and those answered by a Command Status event, and the layer has <code>EnqueueCommand</code> overloads that route each to the right callback type.</p>
<p>When the response arrives you receive a generic view, narrow it to the specific <code>ResetCompleteView</code>, and check <code>IsValid()</code> before reading fields. That validity check isn't ceremony. It's the parser telling you whether the bytes actually match the structure you expect.</p>
<p>The HCI layer also splits its outputs into distinct streams instead of one overloaded callback. Command responses go to the callback you supplied with the command. Unsolicited events like a remote device connecting are delivered to whichever module registered a handler for that specific event code. LE meta-events, subevents of the single LE Meta Event opcode, are demultiplexed to their own subscribers. And the layer exposes narrower sub-interfaces, such as a security interface and an LE advertising interface, so that a module only sees the slice of HCI it actually cares about rather than the entire firehose.</p>
<p>This separation keeps request-response logic away from spontaneous-event logic, which in the old stack were frequently the same function trying to do three jobs at once.</p>
<p>Sitting beside <code>HciLayer</code> is the <code>Controller</code> module, whose job is to interrogate the chip once at startup and cache the answers. It issues the read-local-version, read-local-supported-commands, read-buffer-size, and LE feature commands, then exposes the results through simple getters.</p>
<p>This matters because the rest of the stack constantly needs to know things like the ACL buffer size and how many packets the controller can hold, and asking once and caching is far better than asking the chip repeatedly. When a higher layer wants to know whether a feature is supported, it asks the <code>Controller</code>, not the hardware.</p>
<h2 id="heading-the-packet-definition-language">The Packet Definition Language</h2>
<p>Here's the feature that quietly does the most good. In the old stack, parsing a packet meant reading bytes at hand-computed offsets, shifting and masking by hand, and hoping every author got the endianness and bounds checking right. They didn't always get it right, and malformed Bluetooth packets are a classic remote attack surface, the kind that ends up with a catchy name and a logo.</p>
<p>Gabeldorsche replaces all of that with a Packet Definition Language, or PDL. You describe the wire format once in a <code>.pdl</code> file, and a generator called <code>bluetooth_packetgen</code> emits C++ parser and builder classes from it.</p>
<p>A PDL file starts by declaring endianness and then defines enums, structs, and packets. The following table summarizes the field constructs you'll see most often:</p>
<table>
<thead>
<tr>
<th>Construct</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>field : N</code></td>
<td>A scalar field that is N bits wide</td>
</tr>
<tr>
<td><code>field : Enum</code></td>
<td>A field whose values are drawn from a named enum</td>
</tr>
<tr>
<td><code>field : N[]</code></td>
<td>A variable-length array of N-bit elements</td>
</tr>
<tr>
<td><code>field : N[k]</code></td>
<td>A fixed array of k elements, each N bits</td>
</tr>
<tr>
<td><code>_size_(field)</code></td>
<td>A field that holds the byte size of another field</td>
</tr>
<tr>
<td><code>_count_(field)</code></td>
<td>A field that holds the element count of an array</td>
</tr>
<tr>
<td><code>_payload_</code></td>
<td>The variable body carried by a parent packet</td>
</tr>
<tr>
<td><code>_fixed_ = value</code></td>
<td>A constant field the builder always emits</td>
</tr>
<tr>
<td><code>_reserved_</code></td>
<td>Bits that are always zero on the wire</td>
</tr>
</tbody></table>
<p>This table covers the vocabulary of a PDL file. Scalar and enum fields describe individual values with exact bit widths, so a 3-bit field is genuinely 3 bits and the generator packs it accordingly. The array forms describe repeated data, either variable or fixed length.</p>
<p>The <code>_size_</code> and <code>_count_</code> fields are the clever part: they let the format describe a length prefix once, and the generator wires up the arithmetic so that the builder computes the length on serialize and the view validates it on parse.</p>
<p>The <code>_payload_</code> marker is how packet inheritance carries a child's body inside a parent. And <code>_fixed_</code> and <code>_reserved_</code> encode constants and mandatory zeroes so no human has to remember them.</p>
<p>Everything here is declarative, and the generator turns it into code that can't forget a bounds check.</p>
<p>A concrete definition looks like a struct description with explicit widths and constraints, where child packets constrain fields of their parents.</p>
<pre><code class="language-plaintext">little_endian_packets

enum OpCode : 16 {
  RESET = 0x0C03,
  READ_LOCAL_NAME = 0x0C14,
}

packet Command {
  op_code : OpCode,
  _size_(payload) : 8,
  _payload_,
}

packet Reset : Command (op_code = RESET) {
}

packet ReadLocalNameComplete : CommandComplete (command_op_code = READ_LOCAL_NAME) {
  status : ErrorCode,
  local_name : 8[248],
}
</code></pre>
<p>This defines an opcode enum, a generic <code>Command</code> parent, and two concrete packets. The <code>Command</code> packet carries an <code>op_code</code>, a one-byte size of its payload, and the payload itself, which is the generic shape every command shares. <code>Reset</code> inherits from <code>Command</code> and fixes <code>op_code</code> to <code>RESET</code>, so the generated builder always emits the correct opcode and the generated view can recognize a Reset by matching that constraint. <code>ReadLocalNameComplete</code> inherits from <code>CommandComplete</code> and adds a <code>status</code> enum field plus <code>local_name</code> written as <code>8[248]</code>, meaning 248 elements of 8 bits each, which is exactly how the Bluetooth specification defines the local name field.</p>
<p>The constraints in parentheses are what let generated code route a raw buffer to the correct view type based on the opcode it carries, without a hand-written switch statement anywhere.</p>
<p>From that definition, the generator produces two kinds of classes per packet. A Builder serializes structured data into bytes, and a View parses bytes into structured, bounds-checked accessors.</p>
<pre><code class="language-cpp">// Building: structured data becomes validated bytes.
auto builder = hci::ResetBuilder::Create();
std::vector&lt;uint8_t&gt; bytes;
BitInserter it(bytes);
builder-&gt;Serialize(it);   // writes op_code, computed size, payload

// Parsing: bytes become a validated, typed view.
auto command_view = hci::CommandView::Create(
    PacketView&lt;kLittleEndian&gt;(std::make_shared&lt;std::vector&lt;uint8_t&gt;&gt;(bytes)));
auto name_view = hci::ReadLocalNameCompleteView::Create(command_view);

if (name_view.IsValid()) {
  std::array&lt;uint8_t, 248&gt; name = name_view.GetLocalName();
}
</code></pre>
<p>The building side shows that a builder knows the exact layout: <code>Serialize</code> walks the fields in order through a <code>BitInserter</code>, writing the fixed opcode, computing and writing the payload size, and emitting the payload, so you never touch an offset.</p>
<p>The parsing side is lazy and layered. A <code>PacketView&lt;kLittleEndian&gt;</code> wraps a shared byte buffer without copying it, a generic <code>CommandView</code> interprets the common command header, and the specific <code>ReadLocalNameCompleteView</code> narrows it further.</p>
<p>The critical method is <code>IsValid()</code>, which the generated code implements to verify that the buffer is long enough for every field and that all constraints hold before you read anything. Only after that check do you call <code>GetLocalName()</code> to pull out the parsed field as a typed array.</p>
<p>Because the parser is generated from the same specification for every packet, a whole category of off-by-one and out-of-bounds bugs simply can't be written by hand anymore. There's even a Python binding, generated from the same PDL, so the test suite parses and builds packets with byte-for-byte identical logic to the production stack.</p>
<h2 id="heading-the-acl-manager-and-connection-management">The ACL Manager and Connection Management</h2>
<p>Above the raw HCI layer sits the <code>AclManager</code>, the module responsible for asynchronous connection-oriented links, which is what carries actual user data once devices are connected. It manages both Classic and Low Energy connections, and it presents connections as objects with their own callback interfaces rather than as integer handles floating around global tables waiting to be misused. Internally it splits into a classic implementation and an LE implementation that share the round-robin data scheduler described in the next section.</p>
<p>When you request a connection, you register a callback that fires when the connection succeeds or fails, and on success you receive a connection object that owns the queue for that link.</p>
<pre><code class="language-cpp">// Register interest in Classic connection events, then connect.
acl_manager_-&gt;RegisterCallbacks(this, GetHandler());
acl_manager_-&gt;CreateConnection(remote_address);

void MyModule::OnConnectSuccess(
    std::unique_ptr&lt;hci::acl_manager::ClassicAclConnection&gt; connection) {
  uint16_t handle = connection-&gt;GetHandle();
  // The connection object owns the data queue for this link.
  connection-&gt;GetAclQueueEnd()-&gt;RegisterDequeue(
      GetHandler(),
      common::Bind(&amp;MyModule::OnAclData, common::Unretained(this)));
  connections_[handle] = std::move(connection);
}
</code></pre>
<p>This shows the connection lifecycle from the consumer's point of view. <code>RegisterCallbacks</code> subscribes your module to connection events on your handler thread, and <code>CreateConnection</code> kicks off the HCI dance to page and connect to the remote address.</p>
<p>When it succeeds, <code>OnConnectSuccess</code> receives a <code>ClassicAclConnection</code> owned by a <code>unique_ptr</code>, which means ownership is explicit and the link is torn down deterministically when the object is destroyed. The connection carries its own data queue, accessed through <code>GetAclQueueEnd()</code>, and you register a dequeue callback on it using the exact same queue pattern from earlier.</p>
<p>Finally you move the connection into your own map keyed by handle. There's no ambiguity about who owns the link, which was a genuine source of use-after-free bugs in the previous design.</p>
<p>The LE side adds one wrinkle that deserves a mention: privacy. LE devices rotate their advertising address using a Resolvable Private Address so that trackers can't follow a device by its MAC.</p>
<p>Gabeldorsche has an <code>LeAddressManager</code> that owns the local address rotation and the controller's resolving list, coordinating when the address may change so that it doesn't rotate in the middle of an operation that depends on address stability. This is fiddly, timing-sensitive work, and concentrating it in one component rather than spreading address logic across the LE code is exactly the kind of decision the whole architecture is built to make easy.</p>
<h2 id="heading-the-round-robin-scheduler-and-the-acl-data-path">The Round Robin Scheduler and the ACL Data Path</h2>
<p>The controller has a finite number of ACL buffers, and every connection competes for them. If one busy connection is allowed to consume every buffer, the other connections starve, which on a phone means your file transfer strangles your audio.</p>
<p>Gabeldorsche solves this with a <code>RoundRobinScheduler</code> that sits between the per-connection queues and the single shared link to the controller.</p>
<p>The scheduler tracks how many buffer credits the controller has, dequeues one packet at a time from each connection in rotation, fragments it to the controller's ACL packet size, and sends it downward, decrementing the credit count.</p>
<p>When the controller reports that it has transmitted packets and freed buffers, through the number-of-completed-packets event, the scheduler adds those credits back and resumes sending.</p>
<p>Because it visits connections in round-robin order rather than draining one before touching the next, bandwidth is shared fairly across links without any connection needing to know the others exist. The fragmentation happens here too, so upper layers get to think in whole L2CAP frames while the scheduler quietly chops them into controller-sized bites and reassembles credits on the way back. This one component is the difference between "my earbuds and my file sync coexist" and "pick one."</p>
<h2 id="heading-l2cap-and-the-data-pipeline">L2CAP and the Data Pipeline</h2>
<p>L2CAP, the Logical Link Control and Adaptation Protocol, multiplexes the single ACL link into many logical channels and handles segmentation and reassembly of larger service data units.</p>
<p>Gabeldorsche implements Classic and LE variants as separate modules, <code>L2capClassicModule</code> and <code>L2capLeModule</code>, sharing common machinery underneath. It distinguishes fixed channels, which are always present for duties like signalling, from dynamic channels, which are opened on demand for a specific service.</p>
<p>A service registers itself by protocol or service multiplexer, and when a remote device opens a channel to it, the service receives a channel object that, unsurprisingly by now, owns a queue.</p>
<pre><code class="language-cpp">// Register a dynamic L2CAP service on a PSM.
dynamic_channel_manager_-&gt;RegisterService(
    kMyPsm,
    security_policy,
    GetHandler()-&gt;BindOnceOn(this, &amp;MyModule::OnServiceRegistered),
    GetHandler()-&gt;BindOn(this, &amp;MyModule::OnConnectionOpen));

void MyModule::OnConnectionOpen(
    std::unique_ptr&lt;l2cap::classic::DynamicChannel&gt; channel) {
  channel-&gt;RegisterOnCloseCallback(/* ... */);
  channel-&gt;GetQueueUpEnd()-&gt;RegisterDequeue(/* ... */);
}
</code></pre>
<p>Here a service is registered against a Protocol Service Multiplexer value, the L2CAP equivalent of a port number. <code>RegisterService</code> takes the PSM, a security policy describing what level of pairing and encryption the channel requires, a one-shot callback confirming registration, and a repeating callback that fires each time a remote peer opens a channel. When a channel opens, <code>OnConnectionOpen</code> receives a <code>DynamicChannel</code> owned by a <code>unique_ptr</code>, and you wire up a close callback and a dequeue on its queue.</p>
<p>The security policy being a parameter of registration, rather than something checked ad hoc later, means a channel physically can't be opened at a lower security level than the service demanded. Security as a property of the type, not a runtime afterthought, is a recurring design choice in this stack.</p>
<p>Underneath that friendly channel object is a genuine data pipeline, and it's worth picturing how a byte travels through it.</p>
<pre><code class="language-plaintext">outgoing SDU
    -&gt; per-channel Segmenter (splits SDU into PDUs, applies mode)
    -&gt; channel Scheduler (picks which channel sends next, by priority)
    -&gt; Fragmenter (splits PDUs to ACL buffer size)
    -&gt; AclManager queue -&gt; RoundRobinScheduler -&gt; controller

incoming from controller
    -&gt; Reassembler (rebuilds PDUs from ACL fragments)
    -&gt; per-channel Recombiner (rebuilds SDUs from PDUs)
    -&gt; channel queue up-end -&gt; upper layer
</code></pre>
<p>This diagram traces the two directions of L2CAP data flow. On the way out, a service data unit from an upper layer enters the channel's Segmenter, which breaks it into protocol data units and applies the channel's transmission mode, such as Basic Mode or Enhanced Retransmission Mode with its acknowledgements and retransmits.</p>
<p>A per-link Scheduler then decides which channel gets to transmit next based on channel priority, so a latency-sensitive channel can be favored. The Fragmenter cuts those PDUs down to the controller's ACL buffer size, and they hand off to the <code>AclManager</code> queue and the round-robin scheduler from the previous section.</p>
<p>On the way in, the process runs in reverse: a Reassembler stitches ACL fragments back into PDUs, a per-channel Recombiner rebuilds the original SDUs, and the finished SDU is delivered to the channel's queue up-end where the upper layer reads it.</p>
<p>Each stage is a small, testable component with a single responsibility, which is why L2CAP, historically one of the buggiest layers, becomes tractable.</p>
<h2 id="heading-security-and-pairing">Security and Pairing</h2>
<p>The <code>SecurityModule</code> centralizes pairing, bonding, and encryption for both transports. Classic pairing runs its procedures over the link, while the LE Security Manager Protocol runs over a fixed L2CAP channel dedicated to SMP.</p>
<p>The module drives the state machines for the various association models, including numeric comparison, passkey entry, out-of-band, and just works. It surfaces user interaction through a callback interface so the framework can display the pairing dialog and report back what the user chose.</p>
<p>The design goal is that no other module implements its own crypto or pairing logic. When L2CAP needs a channel encrypted before it will carry data, it doesn't reach into key storage or start an encryption procedure itself. It asks the security module through an enforcement interface, waits for the result on its handler, and only then opens the channel to upper layers.</p>
<p>This is the same principle as the rest of the stack, applied to the most sensitive state: one module owns the pairing state machines and the keys, and everyone else sends it messages. Concentrating cryptographic decisions in one audited place is considerably safer than the historical situation, where security-relevant checks were sprinkled across many files and occasionally forgotten in exactly one of them, which is all it takes.</p>
<h2 id="heading-gatt-and-att">GATT and ATT</h2>
<p>At the top of the LE protocol stack sit the Attribute Protocol, ATT, and the Generic Attribute Profile, GATT, which is the request-response database that nearly every LE product actually uses.</p>
<p>ATT defines the wire operations, reads, writes, notifications, and indications against numbered attribute handles, and it runs over its own fixed L2CAP channel. GATT organizes those attributes into services and characteristics, the abstraction that a heart-rate monitor or a pair of earbuds presents to the phone.</p>
<p>In Gabeldorsche this is layered cleanly on everything below it. ATT is a client of the fixed-channel L2CAP interface, so it receives its channel through the same mechanism as any other fixed channel and moves packets through the same queue pattern. Its packets are defined in PDL like everything else, so an ATT read request and its response are generated builders and views with the same validity guarantees as an HCI command.</p>
<p>GATT builds its service and characteristic database on top of ATT and exposes registration and notification interfaces to the profiles above.</p>
<p>The payoff of the architecture shows up here in a subtle way: because GATT is just another set of modules speaking through queues and typed packets, it can be tested against a virtual peer over RootCanal without any of the layers below it being real hardware.</p>
<h2 id="heading-the-neighbor-and-storage-modules">The Neighbor and Storage Modules</h2>
<p>Two supporting module groups round out the picture. The neighbor modules handle discovery and identification of other devices, and the storage module handles persistence.</p>
<p>Discovery isn't one operation but several, and Gabeldorsche models each as a focused component under the neighbor umbrella. Inquiry handles Classic device discovery and its scan modes. Page and page-scan handle becoming connectable and connecting. Name resolution fetches a remote device's human-readable name.</p>
<p>Keeping these as distinct components with narrow interfaces, rather than one large discovery blob, means each can be reasoned about and tested on its own. It also it means a bug in name resolution can't accidentally corrupt inquiry state because they don't share mutable state.</p>
<p>The storage module is responsible for remembering things across reboots, chiefly bonded devices and their keys, along with adapter and device properties. It presents an in-memory model of devices and their properties and persists that model to a configuration file, batching writes so that a burst of property changes does not thrash the disk.</p>
<p>Because persistence lives behind a module interface, the rest of the stack reads and writes device properties through method calls rather than parsing a config file directly. The on-disk format can change without every layer needing to know. Losing your paired devices on every reboot would be a memorable kind of bad, so this module earns its keep quietly.</p>
<h2 id="heading-the-shim-and-facade-layers">The Shim and Facade Layers</h2>
<p>You can't rewrite an entire Bluetooth stack in one commit and ship it. Gabeldorsche was rolled out incrementally, one layer at a time, which means for a long stretch the new GD modules had to coexist with the old Fluoride profile code.</p>
<p>The <code>shim/</code> layer is the diplomatic bridge that makes this possible. It exposes the old C-style interfaces that BTIF and BTA expect, and translates those calls into the new module method calls underneath, hopping onto the GD stack thread as it crosses the boundary. Individual layers were gated behind flags so that a given layer could run on GD in one build and on legacy code in another, which made it possible to bisect regressions to a specific layer by toggling flags rather than by archaeology.</p>
<p>The <code>facade/</code> layer serves a different purpose entirely. Each GD module can expose a gRPC service, called a facade, that lets an external process drive the module directly. This is the seam that the test framework plugs into. Instead of testing through the entire Android framework, a test can start a single module, connect to its facade over gRPC, and issue commands straight at the layer under test while observing its event stream.</p>
<pre><code class="language-proto">service HciLayerFacade {
  rpc SendCommand(Command) returns (google.protobuf.Empty) {}
  rpc StreamEvents(google.protobuf.Empty) returns (stream Event) {}
}
</code></pre>
<p>This is a sketch of what a module facade looks like in protobuf. It defines a gRPC service with a unary method to send a command into the module and a server-streaming method that pushes every event the module emits back to the caller. Because it's gRPC, the client driving the test can be written in any language, and the project chose Python for readability and speed of writing tests.</p>
<p>The facade turns each internal layer into something you can poke at from outside the process without building the entire operating system around it. This is the difference between a testable design and a design that merely has the word testable in its design doc.</p>
<h2 id="heading-build-system-integration">Build System Integration</h2>
<p>None of this would be pleasant without build tooling that treats the packet generator as a first-class citizen. Gabeldorsche builds with Soong, the AOSP build system whose files are named <code>Android.bp</code>, and the PDL generator is wired in as a custom rule so that <code>.pdl</code> files are compiled to C++ headers automatically as part of the build graph.</p>
<p>The mechanism is a generated-sources rule. A build rule names the <code>bluetooth_packetgen</code> tool, points it at the <code>.pdl</code> inputs, and declares the <code>.h</code> outputs. Any C++ library that lists those generated headers as sources gets them built on demand and rebuilt whenever a <code>.pdl</code> file changes.</p>
<p>The practical effect is that a developer edits a packet definition, rebuilds, and the new typed builders and views simply exist, both in the C++ target and, through a parallel rule, in the Python bindings used by tests. There's no checked-in generated code to fall out of sync and no manual codegen step to forget before sending a change for review. The same source of truth feeds production C++ and test Python, and the build system guarantees they never drift.</p>
<h2 id="heading-logging-metrics-and-dumpsys">Logging, Metrics, and dumpsys</h2>
<p>A stack you can't see into is a stack you can't debug, and Bluetooth debugging often happens after the fact from a bug report.</p>
<p>Gabeldorsche invests accordingly. It logs through the standard Android logging macros with per-module tags, so a log line tells you which layer produced it. It also maintains structured metrics that feed the platform's statistics pipeline for aggregate health monitoring across the fleet.</p>
<p>The most useful debugging affordance is dumpsys integration. Modules can implement a dump method, and the registry can walk the running modules and ask each to serialize its current state, which lands in the Bluetooth section of a bug report.</p>
<p>Because every module knows how to describe itself, a bug report captures a coherent snapshot of the whole stack, connection tables, channel states, controller capabilities, without a human needing to instrument anything at the moment of failure.</p>
<p>This is the difference between "please reproduce it while I watch" and "send me the bug report you already have." For a component as timing-dependent as Bluetooth, that difference is often the entire investigation.</p>
<h2 id="heading-testing-with-cert-tests-and-rootcanal">Testing with Cert Tests and RootCanal</h2>
<p>The payoff for all this modularity is the test infrastructure, and it has two standout pieces. The first is the certification test suite, usually called Cert tests, written in Python and driving modules through their gRPC facades. The second is RootCanal, a virtual Bluetooth controller.</p>
<p>RootCanal deserves special attention. It's a software implementation of a Bluetooth controller that speaks HCI. But instead of a radio, it has a simulated physical layer.</p>
<p>You can connect multiple stacks to a single RootCanal instance, and it models them being in radio range of each other, delivering one stack's advertisements and connection requests to another. This means an entire pairing and data-transfer scenario between two devices can run in continuous integration on a Linux machine with no Bluetooth hardware present at all. The flakiness of real radios, radio interference, timing jitter, a colleague walking past with a microwave, which made the old test story hopeless, is removed by construction.</p>
<p>A Cert test wires these together: it starts the stack under test and a second reference stack, connects both to RootCanal, and asserts on the packets that cross between them.</p>
<pre><code class="language-python">class HciTest(GdBaseTestClass):

    def test_local_hci_cmd_and_event(self):
        # Send an HCI command through the DUT's facade.
        self.dut.hci.SendCommand(
            hci_facade.Command(payload=bytes(ResetBuilder().Serialize())))

        # Assert the expected completion event comes back.
        assertThat(self.dut.hci.get_event_stream()).emits(
            HciMatchers.CommandComplete(OpCode.RESET))
</code></pre>
<p>This test exercises the HCI layer end to end without any hardware. <code>self.dut</code> is the device under test, a real GD stack instance running behind its gRPC facade. <code>SendCommand</code> serializes a <code>ResetBuilder</code>, built by the Python bindings generated from the same PDL as the C++ code, and pushes it into the stack's HCI facade. The test then takes the stack's event stream and asserts that it emits a command-complete event for the Reset opcode, using a matcher that waits for the event to arrive rather than checking once and giving up on a race.</p>
<p>The whole scenario runs deterministically in CI. When this test passes, you know the HCI command path works. When it fails, it fails the same way every time, which is the single most valuable property a test can have.</p>
<h2 id="heading-floss-gabeldorsche-beyond-android">Floss: Gabeldorsche Beyond Android</h2>
<p>Because the GD stack was built on its own OS abstraction rather than baked into Android internals, it turned out to be portable. Floss, which stands for Fluoride Linux OS Stack, is the project that runs the Gabeldorsche core as the Bluetooth stack for desktop Linux and ChromeOS. It reuses the same modules, the same packet library, and the same testing infrastructure, and exposes them to the Linux world through a D-Bus interface instead of Android AIDL.</p>
<p>This is a real vindication of the architecture. The same connection management, HCI flow control, round-robin scheduling, and PDL-generated parsers that keep your phone's earbuds connected also run on a laptop, because none of that logic was ever coupled to Android in the first place.</p>
<p>The parts that differ between platforms are pushed to the edges, the transport that talks to the chip at the bottom and the interface that talks to the OS at the top, while the large protocol middle is shared.</p>
<p>An architecture that ports cleanly to an entirely different operating system is one that had genuinely clean seams, not just aspirational comments claiming it did.</p>
<h2 id="heading-summary">Summary</h2>
<p>Gabeldorsche is what happens when a team decides that the way to fix a decade of flaky Bluetooth is to fix the architecture rather than the symptoms.</p>
<p>The whole design rests on a few consistent ideas repeated at every layer. Work is organized into modules with explicit, declared dependencies that a registry starts in the right order and tears down in reverse. Concurrency is message passing to a thread that owns its data, built on a reactor and handlers, so the stack barely uses locks and the ones it does use are rare and deliberate.</p>
<p>Data moves between layers through reactive queues that provide backpressure for free, a round-robin scheduler shares controller buffers fairly across connections so audio and bulk transfer coexist, and L2CAP is a pipeline of small single-purpose stages rather than one monolith. Packets are parsed by code generated from a formal specification, which erases an entire family of memory-safety bugs and keeps the C++ and Python paths byte-for-byte identical. Connections and channels are owned objects with clear lifetimes rather than integer handles in global tables, and security is a property attached to types and registrations rather than a runtime check someone might forget.</p>
<p>The testing story is the part that makes the rest believable. Every layer exposes a gRPC facade, the build system regenerates packet code on every change, dumpsys captures a full state snapshot into every bug report, and RootCanal provides a virtual controller so full multi-device scenarios run deterministically in continuous integration without any radios. Tests that fail the same way every time are worth more than a stack that occasionally works, and that principle drove the whole rewrite.</p>
<p>If you want to explore further, the code lives in AOSP under the Bluetooth Mainline module, and the same core now runs on Linux desktops through Floss. Read a <code>.pdl</code> file, then read its generated header, then follow one ACL packet from L2CAP down through the round-robin scheduler to the HCI layer, and the philosophy of the whole project becomes clear in an afternoon. It's a stack that was designed to be understood, which, for Bluetooth, still feels like a small miracle.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The React Native Live Activities Handbook: How to Build iOS Live Activities and Android 16 Live Updates ]]>
                </title>
                <description>
                    <![CDATA[ A Live Activity is the card that sits on your lock screen while a delivery rider approaches, updating itself without you opening the app. Apple shipped the API in iOS 16.1. Google shipped its own vers ]]>
                </description>
                <link>https://www.freecodecamp.org/news/react-native-live-activities-handbook/</link>
                <guid isPermaLink="false">6a565e929f58169e255198d4</guid>
                
                    <category>
                        <![CDATA[ React Native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Expo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ push notifications ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Farouq Seriki ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 16:06:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/542a6607-0c73-4dbf-822e-d72d2325ef7a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A Live Activity is the card that sits on your lock screen while a delivery rider approaches, updating itself without you opening the app. Apple shipped the API in iOS 16.1. Google shipped its own version, called Live Updates, in Android 16.</p>
<p>The product requirement is identical on both platforms. The contracts underneath are opposites.</p>
<p>On iOS, <a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns">Apple Push Notification service</a> (APNs) updates a system-owned SwiftUI widget for you, and your app code never runs. On Android there's no system-managed remote update at all. A data-only <a href="https://firebase.google.com/docs/cloud-messaging">Firebase Cloud Messaging</a> (FCM) message wakes a background service, and your own code re-posts the notification every single time.</p>
<p>In this handbook you'll build both. You'll write one TypeScript API backed by two native implementations: a Swift one that talks to <a href="https://developer.apple.com/documentation/activitykit">ActivityKit</a> and a Kotlin one that talks to <code>NotificationManager</code>. You'll also write an APNs client and an FCM client from scratch, in about sixty lines each, with no libraries. And you'll learn the silent failure modes that make this API hard, because almost every mistake here produces no error at all.</p>
<p>I built a delivery-tracking demo called DropTrack to work through this. Everything below comes from that build, including a three-device Samsung investigation that ends with a screenshot of a hardcoded allowlist.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-live-activities-and-live-updates-actually-are">What Live Activities and Live Updates Actually Are</a></p>
</li>
<li><p><a href="#heading-where-you-have-already-seen-this-feature">Where You Have Already Seen This Feature</a></p>
</li>
<li><p><a href="#heading-why-you-need-a-custom-native-module">Why You Need a Custom Native Module</a></p>
</li>
<li><p><a href="#heading-how-the-ios-contract-works">How the iOS Contract Works</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-widget-in-swiftui">How to Build the Widget in SwiftUI</a></p>
</li>
<li><p><a href="#heading-how-to-bridge-activitykit-to-javascript">How to Bridge ActivityKit to JavaScript</a></p>
</li>
<li><p><a href="#heading-four-ios-gotchas-that-cost-me-an-evening-each">Four iOS Gotchas That Cost Me an Evening Each</a></p>
</li>
<li><p><a href="#heading-how-to-drive-ios-from-a-server-with-apns">How to Drive iOS From a Server With APNs</a></p>
</li>
<li><p><a href="#heading-how-to-write-an-apns-client-from-scratch">How to Write an APNs Client From Scratch</a></p>
</li>
<li><p><a href="#heading-how-to-test-on-real-hardware">How to Test on Real Hardware</a></p>
</li>
<li><p><a href="#heading-why-android-has-no-activitykit">Why Android Has No ActivityKit</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-kotlin-side">How to Build the Kotlin Side</a></p>
</li>
<li><p><a href="#heading-how-to-drive-android-from-a-server-with-fcm">How to Drive Android From a Server With FCM</a></p>
</li>
<li><p><a href="#heading-three-ux-gaps-the-naive-implementation-leaves">Three UX Gaps the Naive Implementation Leaves</a></p>
</li>
<li><p><a href="#heading-how-to-script-the-simulators-and-devices">How to Script the Simulators and Devices</a></p>
</li>
<li><p><a href="#heading-how-ios-and-android-compare">How iOS and Android Compare</a></p>
</li>
<li><p><a href="#heading-the-samsung-reality-check">The Samsung Reality Check</a></p>
</li>
<li><p><a href="#heading-the-demo-repository">The Demo Repository</a></p>
</li>
<li><p><a href="#heading-what-to-know-before-you-start">What to Know Before You Start</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-sources-and-further-reading">Sources and Further Reading</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p>Node.js 20 or later.</p>
</li>
<li><p>Xcode 26 or later, plus a <strong>paid Apple Developer account</strong>. APNs requires a real signing key and a device build. The simulator can't receive Live Activity pushes.</p>
</li>
<li><p>Android Studio with an API 36 emulator built from a <code>google_apis</code> system image. Google Play services are required for FCM, and a bare Android Open Source Project image doesn't include them.</p>
</li>
<li><p>A <a href="https://firebase.google.com/docs/cloud-messaging">Firebase</a> project, for the Android half.</p>
</li>
<li><p>Working knowledge of Swift, Kotlin, and TypeScript. This is a native modules article, not a JavaScript-only one.</p>
</li>
</ul>
<p>The versions I used throughout:</p>
<table>
<thead>
<tr>
<th>Package or tool</th>
<th>Version</th>
</tr>
</thead>
<tbody><tr>
<td>Expo SDK</td>
<td>57.0.4</td>
</tr>
<tr>
<td>React Native</td>
<td>0.86.0</td>
</tr>
<tr>
<td>React</td>
<td>19.2.3</td>
</tr>
<tr>
<td>TypeScript</td>
<td>6.0.3</td>
</tr>
<tr>
<td><a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a></td>
<td>4.0.7</td>
</tr>
<tr>
<td><a href="https://developer.android.com/jetpack/androidx/releases/core"><code>androidx.core</code></a></td>
<td>1.17.0</td>
</tr>
<tr>
<td><code>firebase-bom</code></td>
<td>33.7.0</td>
</tr>
<tr>
<td>Xcode</td>
<td>26.3</td>
</tr>
</tbody></table>
<p>The <code>androidx.core</code> version isn't optional. Version 1.17.0 backports the Android 16 promotion APIs so they compile against base SDK 36. I'll explain why in a moment.</p>
<h2 id="heading-what-live-activities-and-live-updates-actually-are">What Live Activities and Live Updates Actually Are</h2>
<p>A Live Activity isn't a push notification. A notification is a fire-and-forget event. A Live Activity is a persistent, glanceable card with its own state that changes in place, a bounded lifetime, and dedicated system surfaces.</p>
<p>Here's where each platform landed:</p>
<ul>
<li><p>iOS 16.1 introduced lock-screen Live Activities. 16.2 added the Dynamic Island. 17.2 added push-to-start. 18 added broadcast channels. See Apple's <a href="https://developer.apple.com/documentation/activitykit">ActivityKit documentation</a> and the <a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Live Activities Human Interface Guidelines</a>.</p>
</li>
<li><p>Android 16 (API 36) introduced <a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>Notification.ProgressStyle</code></a>, the segmented progress bar.</p>
</li>
<li><p>Android 16 QPR1 (API 36.1), the first Quarterly Platform Release, introduced the <em>promotion</em> pipeline: the status-bar chip and the elevated lock-screen slot. Google calls the whole feature <a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">progress-centric notifications</a>.</p>
</li>
</ul>
<p>I built DropTrack to exercise all of it. An order moves through seven steps, from "Order placed" to "Arriving now". A courier can be reassigned mid-run. The card is driven either locally from an in-app console or remotely from a push.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ea8d99d3-162d-4104-8402-c38e4ebc39fb.png" alt="DropTrack expanded Dynamic Island on a real iPhone 14 Pro" style="display:block;margin:0 auto" width="640" height="1387" loading="lazy">

<h2 id="heading-where-you-have-already-seen-this-feature">Where You Have Already Seen This Feature</h2>
<p>Before any code, it helps to know why this API exists, because "a card on the lock screen" undersells it.</p>
<p>Think about the last time you ordered food. You place the order, lock your phone, and then what? Without a Live Activity, the app's only way to reach you is a push notification per state change. Order confirmed. Restaurant is preparing your food. Rider assigned. Rider is two stops away. Rider has arrived.</p>
<p>That's five notifications for one order. Multiply that by every order, and you've trained the user to mute you.</p>
<p>A Live Activity replaces all five with one card that mutates in place. Same information with one surface and no notification fatigue. That's the entire product argument.</p>
<p>Apple's own <a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Human Interface Guidelines</a> name the recurring use cases: sports scores, workouts, rides, and deliveries. Google's <a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">progress-centric notifications</a> documentation converges on almost the same list, stating that "key use cases include rideshare, delivery, and navigation." That page even describes the delivery example in terms of progress <em>points</em> for food preparation and delivery milestones, and <em>segments</em> colored by traffic conditions, which is precisely the widget you're about to build.</p>
<h3 id="heading-apps-that-have-shipped-it">Apps That Have Shipped it</h3>
<p>Rather than repeat the roundup posts, I checked each app's own App Store listing, because a company describing its own feature is the strongest evidence available. Every app below states that it uses Live Activities in its own words:</p>
<table>
<thead>
<tr>
<th>App</th>
<th>What its Live Activity shows</th>
<th>First-party source</th>
</tr>
</thead>
<tbody><tr>
<td>Chowdeck</td>
<td>Delivery progress, "right on your Lock Screen and in the Dynamic Island"</td>
<td><a href="https://apps.apple.com/us/app/chowdeck-food-delivery/id1530676376">App Store listing</a></td>
</tr>
<tr>
<td>Flighty</td>
<td>Departure countdown, gate changes, taxi time, arrival progress</td>
<td><a href="https://flighty.com/help/live-activities-widgets">Flighty's own help docs</a></td>
</tr>
<tr>
<td>ESPN</td>
<td>Key plays and game stats for major soccer leagues, the NHL, and the NBA</td>
<td><a href="https://apps.apple.com/us/app/espn-live-sports-scores/id317469184">App Store listing</a></td>
</tr>
<tr>
<td>MLB</td>
<td>Game updates on the lock screen</td>
<td><a href="https://apps.apple.com/us/app/mlb/id493619333">App Store listing</a></td>
</tr>
<tr>
<td>FotMob</td>
<td>Soccer scores on the lock screen</td>
<td><a href="https://apps.apple.com/us/app/fotmob-soccer-live-scores/id488575683">App Store listing</a></td>
</tr>
<tr>
<td>CARROT Weather</td>
<td>Incoming precipitation and storm intensity</td>
<td><a href="https://apps.apple.com/us/app/carrot-weather-alerts-radar/id961390574">App Store listing</a></td>
</tr>
<tr>
<td>Structured</td>
<td>Pomodoro focus timers</td>
<td><a href="https://apps.apple.com/us/app/structured-daily-planner-todo/id1499198946">App Store listing</a></td>
</tr>
</tbody></table>
<p>Apple ships them in its own software, too. The <a href="https://support.apple.com/guide/apple-sports-app/follow-games-in-real-time-apdc0cb7ad64/web">Apple Sports support page</a> says that with Live Activities turned on "you can get real-time information on your iPhone Lock Screen or your Apple Watch so that you can follow every moment of the game."</p>
<p>Two more are well documented by the technology press rather than by the companies themselves. Uber Eats <a href="https://www.macrumors.com/2023/05/02/uber-eats-live-activities/">rolled out Live Activities in May 2023</a>, showing order status, estimated time of arrival, and the driver's name and photo. DoorDash <a href="https://www.macrumors.com/2023/12/04/doordash-rolling-out-live-activities/">followed in December 2023</a> with a real-time estimated time of arrival in the Dynamic Island.</p>
<p>Two honest caveats about that table. First, an app not mentioning Live Activities in its listing doesn't mean it lacks the feature, only that I couldn't confirm it first-hand. Uber Eats is exactly that case. Second, this reflects the listings as I read them, and any app can add or drop the feature in a release.</p>
<p>Food delivery is the canonical case, and it's what DropTrack models. Ride-hailing is the highest-stakes version: "driver arriving in 3 minutes", the plate number, the trip in progress, glanced at while you stand on a curb with a locked phone. The Dynamic Island's compact presentation exists almost for this: a glyph and a percentage read in half a second.</p>
<p>Finance is the case people forget, and it's where a segmented bar would earn its keep. A crypto deposit isn't "pending, then done". It's "3 of 12 network confirmations", which is a segmented progress bar with discrete steps, mapping cleanly onto <a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>ProgressStyle</code>'s</a> segments and points and onto the SwiftUI capsules you will write below. The same shape fits a bank transfer clearing or a card payment settling.</p>
<p>I want to be careful here, though: I checked the App Store listings for the major exchanges and neobanks and found none that documents a Live Activity. Treat this one as an obvious fit that the category hasn't yet taken up, not as prior art.</p>
<p>Here's the table I wish someone had shown me before I started:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>What updates</th>
<th>Who drives the update</th>
<th>Why a card beats notifications</th>
</tr>
</thead>
<tbody><tr>
<td>Food delivery</td>
<td>Courier position, ETA, rider swap</td>
<td>Backend</td>
<td>Five pushes collapse into one mutating card</td>
</tr>
<tr>
<td>Ride-hailing</td>
<td>Driver arriving, trip state, fare</td>
<td>Backend</td>
<td>Glanceable while the phone is locked</td>
</tr>
<tr>
<td>Crypto deposit</td>
<td>Confirmations, 3 of 12</td>
<td>Backend</td>
<td>A progress bar, not a binary "done" ping</td>
</tr>
<tr>
<td>Bank transfer</td>
<td>Settlement stage</td>
<td>Backend</td>
<td>Fewer "has it landed?" support tickets</td>
</tr>
<tr>
<td>Parcel</td>
<td>Out for delivery, then delivered</td>
<td>Backend</td>
<td>Persistent, not buried in the tray</td>
</tr>
<tr>
<td>Timer or workout</td>
<td>Elapsed time</td>
<td>The device</td>
<td>No server needed. This is the easy case</td>
</tr>
</tbody></table>
<p>Look at the third column. In every commercially interesting case the update is driven by your server, not by the device. That means the entire value of the feature lives in the push path. And the push path is exactly where the two platforms diverge, where the failures are silent, and where most of this handbook's pain is concentrated.</p>
<p>The pretty SwiftUI card is the easy half. Getting a backend to reliably mutate that card while the app is dead is the hard half, and it's different on each platform.</p>
<h2 id="heading-why-you-need-a-custom-native-module">Why You Need a Custom Native Module</h2>
<p>Before writing a line of Swift, I evaluated the shortcuts. Both fail, for instructive reasons.</p>
<p><a href="https://github.com/software-mansion-labs/expo-live-activity"><code>expo-live-activity</code></a> ships a predefined widget layout and a fixed content-state shape. DropTrack needs a custom segmented progress bar, a rider-reassignment treatment, and its own Dynamic Island layouts. All of those require owning <code>ActivityAttributes</code> and the SwiftUI that renders it. You reach first render fastest, and then you eject.</p>
<p><a href="https://github.com/invertase/notifee">Notifee</a>, the notification library most React Native developers reach for, is archived. As of this writing its GitHub repository is marked archived, its last published release was <a href="https://www.npmjs.com/package/@notifee/react-native"><code>@notifee/react-native@9.1.8</code></a> in December 2024, and its Android module still targets <code>compileSdk 34</code>. It has no Live Updates support. A custom module is currently the only React Native route to Android 16 Live Updates.</p>
<p>So: one local <a href="https://docs.expo.dev/modules/overview/">Expo module</a>, one TypeScript API, two native backends.</p>
<pre><code class="language-text">modules/droptrack-live/
├── expo-module.config.json   ← autolinking (apple + android)
├── index.ts                  ← the public JS API
├── src/                      ← TS types, native binding, web no-op
├── ios/
│   ├── DeliveryAttributes.swift    ← the ActivityKit contract
│   └── DroptrackLiveModule.swift   ← JS to ActivityKit
└── android/
    ├── DroptrackLiveModule.kt      ← JS to NotificationManager
    ├── DeliveryNotifier.kt         ← the shared notification builder
    └── DroptrackFcmService.kt      ← push to NotificationManager
</code></pre>
<p>The JavaScript surface is deliberately small, and identical across platforms:</p>
<pre><code class="language-typescript">// The dynamic half of the card: every update replaces this object wholesale.
// Keep it small. iOS rejects an ActivityKit push payload over 4 KB.
export type DeliveryState = {
  status: string;           // free text shown as the headline: "Picked up"
  progress: number;         // 0.0 to 1.0, drives the bar on both platforms
  etaEpochMillis: number;   // Unix ms, the shape JS speaks. Swift converts it.
  stopsRemaining: number;   // renders as "2 stops away" / "you're next"
  courierName: string;      // dynamic, because riders get reassigned mid-run
  riderReassigned: boolean; // flips the courier row to the "new rider" style
};

// Three verbs, mirroring the ActivityKit lifecycle. Android fakes the same
// shape with notify / notify / cancel, so callers never branch on platform.

// Starts the activity and returns the system-assigned id. Hold on to it:
// every later call needs it, and it does NOT survive an app restart.
startDelivery(info: DeliveryInfo, state: DeliveryState): Promise&lt;string&gt;;

// Replaces the ContentState in place. The card mutates, it does not re-appear.
updateDelivery(id: string, state: DeliveryState): Promise&lt;void&gt;;

// Final state, then dismissal. Omit dismissAfterSeconds to let the card linger.
endDelivery(id: string, state: DeliveryState, dismissAfterSeconds?: number): Promise&lt;void&gt;;

isSupported(): boolean;          // false on iOS &lt; 16.2, Android &lt; 16, and web
areActivitiesEnabled(): boolean; // false if the USER switched them off in Settings
</code></pre>
<p>Those last two are separate on purpose. A device can support Live Activities while the user has switched them off in Settings, and only <code>areActivitiesEnabled()</code> catches that. <code>Activity.request</code> throws in that case, so <code>startDelivery</code> throws too.</p>
<p>The <code>dismissAfterSeconds</code> argument maps onto ActivityKit's <a href="https://developer.apple.com/documentation/activitykit/activityuidismissalpolicy">dismissal policy</a>. Under the default policy, Apple's documentation says "the system keeps a Live Activity that ended on the Lock Screen for up to four hours after it ends." Passing a value shortens that window.</p>
<p>This project uses Expo's <a href="https://docs.expo.dev/workflow/continuous-native-generation/">Continuous Native Generation</a> (CNG), which means the <code>ios</code> and <code>android</code> folders are generated by <code>prebuild</code> and aren't committed. Every line of native code therefore lives in the module or in a <a href="https://docs.expo.dev/config-plugins/introduction/">config plugin</a>, so it survives <code>prebuild --clean</code>. The widget extension itself is generated by <a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a> for the same reason.</p>
<p>One structural detail matters later: the module compiles as its own CocoaPod, not into the app target.</p>
<h2 id="heading-how-the-ios-contract-works">How the iOS Contract Works</h2>
<p>An ActivityKit activity is split in two. <a href="https://developer.apple.com/documentation/activitykit/activityattributes"><code>ActivityAttributes</code></a> holds static data, set once and never changed. Its nested <code>ContentState</code> holds the dynamic half, which every update replaces wholesale.</p>
<p>Most tutorials state that rule and move on. It's worth understanding <em>why</em> the platform forces it, because the reason tells you exactly which half any given field belongs in.</p>
<h3 id="heading-why-the-split-exists">Why the Split Exists</h3>
<p>The split isn't a style convention. It's about what can physically cross the wire.</p>
<p>When you call <code>Activity.request(...)</code>, you hand ActivityKit two things at once: the attributes and the first content state. The system files the attributes away and never accepts a new copy of them. From that moment on, the only thing you can ever send is a new <code>ContentState</code>.</p>
<p>You can see this in the shape of an <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">ActivityKit push payload</a>. Here's what my APNs client actually transmits:</p>
<pre><code class="language-javascript">aps: {
  // Older-than-last timestamps are discarded by APNs (ordering guard).
  timestamp: Math.floor(Date.now() / 1000),
  event,                        // "update" or "end"
  'content-state': contentState, // the only field that carries data
}
</code></pre>
<p>There is no <code>attributes</code> key. There can't be one. Nothing in the payload can reach the static half.</p>
<p>So "should this field be static or dynamic?" is really the question <strong>"will my server ever need to change this value?"</strong> If the answer is yes, the field must live in <code>ContentState</code>, because that's the only thing a push can move.</p>
<p>That's the whole design. <code>ActivityAttributes</code> is the activity's identity, fixed at birth. <code>ContentState</code> is its current reading, replaced wholesale on every update rather than patched. Send a content state that omits a field and you haven't left that field alone, you've failed to decode. The <code>Codable</code> conformance is what lets the system serialize the state across a process boundary, and the <code>Hashable</code> conformance lets it tell whether an incoming state actually differs from the one already on screen.</p>
<h3 id="heading-the-bet-i-lost">The Bet I Lost</h3>
<p>Here's what I wrote first. It looks reasonable:</p>
<pre><code class="language-swift">// BEFORE: courierName is static. This compiles, ships, and works fine,
// right up until a dispatcher reassigns the rider mid-delivery.
struct DeliveryAttributes: ActivityAttributes {
  public struct ContentState: Codable, Hashable {
    var status: String
    var progress: Double
    var eta: Date
    var stopsRemaining: Int
  }
  var orderId: String
  var courierName: String   // the bet: a delivery has one courier
}
</code></pre>
<p>Putting a field in the static half is a bet that it can never change for the activity's lifetime. I bet that a delivery has one courier. Then I built rider reassignment, where a dispatcher swaps the courier mid-run, and the bet came due.</p>
<p>Because <code>courierName</code> was static, there was no way to express the change. Not through <code>activity.update()</code>, not through a push, not at all. The only escape would have been to end the activity and start a new one, which means the card disappears from the lock screen and returns as a different card, mid-delivery. That's not a fix, it's a regression.</p>
<p>So the field moved:</p>
<pre><code class="language-swift">// AFTER: only orderId is genuinely immutable. Everything a dispatcher
// can change lives in ContentState, because ContentState is the only
// thing an APNs push can carry.
struct DeliveryAttributes: ActivityAttributes {
  public struct ContentState: Codable, Hashable {
    /// Human-readable status, e.g. "Picked up", "2 stops away"
    var status: String
    /// Overall delivery progress, 0.0 ... 1.0
    var progress: Double
    /// Estimated arrival time
    var eta: Date
    /// Stops before the courier reaches the user
    var stopsRemaining: Int
    /// Courier display name, dynamic since riders can be reassigned mid-run
    var courierName: String
    /// True right after a reassignment, drives the "new rider" treatment
    var riderReassigned: Bool
  }

  /// Order identifier shown on the card, the only truly immutable fact
  var orderId: String
}
</code></pre>
<p>Moving one field touched twelve files, because the contract is duplicated at every layer that has to encode or decode it:</p>
<pre><code class="language-text">9  DeliveryNotifier.kt          Android notification builder
3  DroptrackLive.types.ts       the TypeScript contract
3  DroptrackLiveModule.swift    the Swift bridge record
3  App.tsx                      React state and the push handler
2  DeliveryLiveActivity.swift   the widget's SwiftUI
2  DeliveryAttributes.swift     the module's copy
2  DeliveryAttributes.swift     the widget's copy (yes, twice)
2  DroptrackLiveModule.kt       the Kotlin bridge record
1  DroptrackFcmService.kt, dispatch-server.mjs, push-update.mjs,
   apns.test.mjs, delivery.ts
</code></pre>
<p>The asymmetry here is what matters. A dynamic field you never change costs you a few bytes in every push. A static field you need to change costs you the entire stack. <strong>When in doubt, put the field in</strong> <code>ContentState</code><strong>.</strong> In the end, only <code>orderId</code> earned its place in the static half.</p>
<h3 id="heading-the-identical-copy-trap">The Identical-Copy Trap</h3>
<p>The widget extension is a separate binary. It's a different process, launched by the system, that doesn't link your app's code. Your app target compiles the module's <code>DeliveryAttributes.swift</code>. The widget target compiles its own. Two compilations produce two independent types that merely share a name.</p>
<p>At runtime, ActivityKit has to connect them. Your app says "start an activity of type <code>DeliveryAttributes</code>", and the system asks the widget whether it has a <a href="https://developer.apple.com/documentation/widgetkit"><code>WidgetConfiguration</code></a> for a type by that name whose <code>ContentState</code> decodes this data. The match is structural, on the type's name and its <code>Codable</code> shape, performed by a system daemon, at runtime, across a process boundary.</p>
<p>Now rename <code>courierName</code> to <code>riderName</code> in one copy and forget the other. The app compiles. The widget compiles. Both are internally consistent. <code>Activity.request()</code> succeeds and returns an id. But when the system hands the encoded state to the widget, the widget's decoder looks for <code>riderName</code>, doesn't find it, and throws inside a daemon in another process, where your breakpoints and your log statements don't exist.</p>
<p>The card never appears. No crash, no warning, no console line, and <code>Activity.activities</code> still lists the activity as running. You'll spend an hour on your SwiftUI layout, and the layout was never the problem.</p>
<p>Two things follow. A shared source file doesn't save you, because the module compiles as its own CocoaPod rather than into the app target, so no build phase naturally covers both. And since the compiler will never catch this, the check has to be external:</p>
<pre><code class="language-shell">diff modules/droptrack-live/ios/DeliveryAttributes.swift \
     targets/widgets/DeliveryAttributes.swift
</code></pre>
<p>If that command ever prints anything, your Live Activity is already broken. It costs nothing to wire into a pre-commit hook, and it's the single highest-value guardrail in this project.</p>
<h2 id="heading-how-to-build-the-widget-in-swiftui">How to Build the Widget in SwiftUI</h2>
<p>The widget declares one <a href="https://developer.apple.com/documentation/widgetkit"><code>Widget</code></a> with two presentations: the lock-screen card and the Dynamic Island.</p>
<p>Before reading the code, hold one idea in your head: <strong>these closures are a pure function from state to view.</strong> The widget extension isn't a running program. The system launches the process, calls your closure with the current <code>ContentState</code>, keeps the rendered result, and kills the process.</p>
<p>When a push delivers a new state, it runs the closure again. There's no <code>@State</code>, no timer, no network call, and no <code>onAppear</code> doing work. That's also why widget extensions have no network access: there's nobody home to make a request.</p>
<p>Two things about the type signature before the code. <code>Widget</code> is a WidgetKit protocol, not a SwiftUI <code>View</code>, and <code>body</code> returns <code>some WidgetConfiguration</code>, not <code>some View</code>. You're not describing pixels, you're declaring what kind of widget this is.</p>
<p>And the <code>for:</code> argument on line 3 is the binding point from the previous section: this is the exact spot where the app's <code>DeliveryAttributes</code> and the widget's copy are matched by name and <code>Codable</code> shape, so it's where the identical-copy trap either works or silently fails.</p>
<pre><code class="language-swift">struct DeliveryLiveActivity: Widget {
  var body: some WidgetConfiguration {
    ActivityConfiguration(for: DeliveryAttributes.self) { context in
      // Closure 1 of 2: the lock screen and banner.
      DeliveryCardView(context: context)
        .activityBackgroundTint(Color(red: 0.07, green: 0.07, blue: 0.12))
        .activitySystemActionForegroundColor(.white)

    } dynamicIsland: { context in          // closure 2 of 2, labelled
      DynamicIsland {
        // Expanded (long-press): four named slots, arranged around the cutout.
        DynamicIslandExpandedRegion(.leading) {
          Image(systemName: "bicycle").foregroundStyle(brandOrange)
        }
        DynamicIslandExpandedRegion(.trailing) {
          Text(context.state.eta, style: .time).font(.callout.bold())
        }
        DynamicIslandExpandedRegion(.center) {
          Text(context.state.status).font(.callout.weight(.semibold)).lineLimit(1)
        }
        DynamicIslandExpandedRegion(.bottom) {
          VStack(spacing: 4) {
            SegmentedProgressBar(progress: context.state.progress)
            HStack {
              CourierLabel(state: context.state, compact: true)
              Spacer()
              Text(stopsLabel(context.state.stopsRemaining))
            }
            .font(.caption2).foregroundStyle(.secondary)
          }
        }
      } compactLeading: {                   // the default pill, left of the cutout
        Image(systemName: "bicycle").foregroundStyle(brandOrange)
      } compactTrailing: {                  // ...and right of it
        Text("\(Int(context.state.progress * 100))%").font(.caption2.bold())
      } minimal: {                          // when another app shares the island
        Image(systemName: "bicycle").foregroundStyle(brandOrange)
      }
      .keylineTint(brandOrange)
    }
  }
}
</code></pre>
<p>That's one state object rendered four ways, and a few of the choices are load-bearing:</p>
<ul>
<li><p><strong>The two closures:</strong> <code>ActivityConfiguration</code> takes a content closure and a <code>dynamicIsland:</code> closure. The bare-then-labelled form is Swift's multiple-trailing-closure syntax, not two separate statements. The first is the lock-screen and banner card, and the second is every Dynamic Island form.</p>
</li>
<li><p><code>context</code><strong>:</strong> Each closure receives <code>context.state</code>, the dynamic <code>ContentState</code> replaced on every update, and <code>context.attributes</code>, the static half, read exactly once, for the order id on the card.</p>
</li>
<li><p><strong>The two</strong> <code>.activity…</code> <strong>modifiers:</strong> These are ActivityKit-specific, not general SwiftUI. You don't draw the system's swipe-to-end chrome, so all you can do is tint it.</p>
</li>
<li><p><code>eta, style: .time</code> renders a localized clock time such as "5:42 PM", honouring the user's 12- or 24-hour setting. It's not a countdown. A ticking countdown would use <code>style: .timer</code>, which the system animates with no push at all, which is how a timer-style activity stays live with zero server traffic.</p>
</li>
<li><p><code>lineLimit(1)</code> isn't decoration. The centre slot is a few dozen points wide, and an unbounded status string wraps and wrecks the layout. The bar lives in <code>.bottom</code> for the opposite reason: it's the only region wide enough.</p>
</li>
<li><p><code>.font</code> <strong>and</strong> <code>.foregroundStyle</code> <strong>on the</strong> <code>HStack</code> apply to both children through SwiftUI's environment, and a child's explicit modifier still wins over the inherited one, which is how <code>CourierLabel</code> overrides the colour on a reassignment.</p>
</li>
<li><p><code>Int(progress * 100)</code> truncates rather than rounds, so 0.999 would render as "99%". The scripted steps end on exactly 1.0 so it lands on 100, but server-computed progress would want <code>.rounded()</code>.</p>
</li>
<li><p><code>minimal</code> is the presentation people forget. When another app's activity is also running, the island shrinks yours to a single circle. Skip it and your activity looks broken whenever a timer is going. <code>keylineTint</code> sets the glow the system draws around the whole island on update.</p>
</li>
</ul>
<h3 id="heading-the-segmented-progress-bar">The Segmented Progress Bar</h3>
<p>The capsule bar is an <code>HStack</code> of rounded rectangles. <code>ContentState</code> carries <code>progress</code> as a double rather than a step index, so the widget derives how many segments to fill.</p>
<pre><code class="language-swift">private struct SegmentedProgressBar: View {
  let progress: Double
  var segments: Int = 7   // one capsule per delivery step

  // ContentState carries a 0...1 fraction, never a step index, so the bar has
  // to work backwards to a segment count. Keeping the wire format numeric means
  // the widget never needs to know what the seven steps are called.
  private var filled: Int {
    // Clamp first: a server that sends 1.4 must not paint 10 of 7 segments.
    let clamped = min(max(progress, 0), 1)
    // .rounded(.up) so a step that has merely BEGUN already lights its capsule.
    // progress 0.05 * 7 = 0.35 -&gt; ceil -&gt; 1 segment lit, not 0.
    // The outer min() guards the exact 1.0 case against a 8th segment.
    return min(segments, Int((clamped * Double(segments)).rounded(.up)))
  }

  var body: some View {
    HStack(spacing: 5) {
      // ForEach over a constant range, so `id: \.self` on the index is safe.
      ForEach(0..&lt;segments, id: \.self) { i in
        RoundedRectangle(cornerRadius: 3, style: .continuous)
          // The only stateful decision in the whole view: lit, or track colour.
          .fill(i &lt; filled ? brandOrange : trackGray)
          // Fixed height, no width: the HStack divides the width evenly, so the
          // same view fits the wide lock-screen card and the narrow island.
          .frame(height: 7)
      }
    }
  }
}
</code></pre>
<p>With progress values of 0.05, 0.15, 0.35, 0.55, 0.7, 0.85, 0.95, and 1.0, rounding <code>progress * 7</code> upward fills one through seven segments in lockstep with the steps. The same view renders on the lock-screen card and inside the expanded island, so the component reads as one thing across surfaces.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/539a1b65-6fdf-440a-8e9b-0ea3a02c918a.png" alt="DropTrack lock-screen card with the segmented progress bar" style="display:block;margin:0 auto" width="560" height="1213" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ef33b5c6-bab4-4df5-9753-ed032256ece2.png" alt="DropTrack compact Dynamic Island pill" style="display:block;margin:0 auto" width="640" height="110" loading="lazy">

<h3 id="heading-the-reassignment-treatment">The Reassignment Treatment</h3>
<p>Reassignment is an in-place update: same activity id, same step, new <code>courierName</code>, and <code>riderReassigned</code> set to true. The courier row swaps its icon and copy.</p>
<pre><code class="language-swift">// Shared by the lock-screen card and the expanded island, so a reassignment
// reads identically wherever the user happens to be looking.
private struct CourierLabel: View {
  let state: DeliveryAttributes.ContentState
  let compact: Bool   // true inside the island, where horizontal space is scarce

  var body: some View {
    // Both branches are driven purely by ContentState. No local state, because
    // the widget process does not live long enough to hold any.
    if state.riderReassigned {
      Label(
        // The island cannot fit "New rider · Tunde", so it drops the prefix and
        // leans on the swap icon plus the orange tint to carry the meaning.
        compact ? state.courierName : "New rider · \(state.courierName)",
        systemImage: "arrow.triangle.2.circlepath"
      )
      // An explicit style on the child overrides the .secondary the parent
      // HStack pushed down through the environment. This is what makes the
      // swapped rider the one thing on the card that draws the eye.
      .foregroundStyle(brandOrange).bold()
    } else {
      // Steady state: inherits the parent's caption font and secondary colour.
      Label(state.courierName, systemImage: "bicycle")
    }
  }
}
</code></pre>
<p>Advancing a step retires the badge. That's the whole feature, and the reason <code>courierName</code> had to leave the static half.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/38944dd0-0875-4b92-b1cb-16783123848c.png" alt="DropTrack rider-reassignment treatment on the lock screen" style="display:block;margin:0 auto" width="640" height="289" loading="lazy">

<h2 id="heading-how-to-bridge-activitykit-to-javascript">How to Bridge ActivityKit to JavaScript</h2>
<p>Expo's <a href="https://docs.expo.dev/modules/module-api/"><code>Record</code></a> type gives you type-safe bridging. Expo validates the JavaScript object against these fields before your function body runs.</p>
<pre><code class="language-swift">// A Record is Expo's typed bridge struct. Every @Field is decoded out of the
// JS object BEFORE your function body runs, so a missing or wrong-typed key
// fails at the boundary with a clear error rather than deep inside Swift.
// The defaults are what a field falls back to when JS omits it.
struct DeliveryStateRecord: Record {
  @Field var status: String = ""
  @Field var progress: Double = 0
  @Field var etaEpochMillis: Double = 0   // Double, not Int: JS numbers are f64,
                                          // and epoch ms overflows Int32.
  @Field var stopsRemaining: Int = 0
  @Field var courierName: String = ""
  @Field var riderReassigned: Bool = false

  // The bridge type and the ActivityKit type are deliberately NOT the same
  // struct. This function is the single place the two vocabularies meet.
  @available(iOS 16.2, *)
  func toContentState() -&gt; DeliveryAttributes.ContentState {
    DeliveryAttributes.ContentState(
      status: status,
      // Clamp at the boundary, so no downstream view has to defend itself.
      progress: min(max(progress, 0), 1),
      // JS speaks Unix milliseconds; Foundation.Date wants seconds. Divide once,
      // here, rather than scattering /1000 through the codebase.
      eta: Date(timeIntervalSince1970: etaEpochMillis / 1000),
      stopsRemaining: stopsRemaining,
      courierName: courierName,
      riderReassigned: riderReassigned
    )
  }
}
</code></pre>
<p>And the module itself:</p>
<pre><code class="language-swift">AsyncFunction("startDelivery") { (info: DeliveryInfoRecord, state: DeliveryStateRecord) -&gt; String in
  // Runtime OS guard: the module compiles against older targets, so this is
  // what stops it calling into a framework that isn't there.
  guard #available(iOS 16.2, *) else { throw LiveActivityUnsupportedException() }

  let activity = try Activity.request(
    attributes: DeliveryAttributes(orderId: info.orderId),                  // static half, set once
    content: ActivityContent(state: state.toContentState(), staleDate: nil), // dynamic half
    pushType: .token                                                        // ask APNs for a token
  )
  return activity.id   // the only handle JS gets, and the one it forgets on reload
}

AsyncFunction("updateDelivery") { (activityId: String, state: DeliveryStateRecord) in
  let activity = try Self.findActivity(id: activityId)
  // .update() replaces the whole ContentState. There is no partial-patch API.
  await activity.update(ActivityContent(state: state.toContentState(), staleDate: nil))
}
</code></pre>
<p>A few notes on the arguments, so they stay out of the code. <code>AsyncFunction</code> (rather than <code>Function</code>) is what makes the JS side a <code>Promise</code>, and Expo infers the rest of the signature from the parameter types.</p>
<p><code>staleDate: nil</code> means the card is never marked stale. Pass a date and, once it passes, the system flips the activity's state so your widget can read <code>context.isStale</code> and render a degraded view, but it doesn't grey the card out for you.</p>
<p>And <code>pushType: .token</code> is what asks APNs for a per-activity token, where passing <code>nil</code> instead gives you a purely local activity that no server can update.</p>
<p>Notice <code>findActivity</code>. Activities are looked up fresh by id on every call rather than cached in a property, because activity handles live inside <a href="https://developer.apple.com/documentation/activitykit/activity">ActivityKit</a>, not in your process. That distinction matters enormously, as the next section shows.</p>
<pre><code class="language-swift">@available(iOS 16.2, *)
private static func findActivity(id: String) throws -&gt; Activity&lt;DeliveryAttributes&gt; {
  // `Activity.activities` is a live, system-owned list. It survives an app
  // relaunch, a JS reload, and a process kill. Caching a handle in a Swift
  // property would not, which is precisely why this lookup runs every time.
  guard let activity = Activity&lt;DeliveryAttributes&gt;.activities.first(where: { $0.id == id }) else {
    // Reached when the user swiped the card away, or the activity aged out.
    throw ActivityNotFoundException(id)
  }
  return activity
}
</code></pre>
<h2 id="heading-four-ios-gotchas-that-cost-me-an-evening-each">Four iOS Gotchas That Cost Me an Evening Each</h2>
<p>Each of these wasted an evening because the failure looked like a bug in my code when it was really a quirk of the platform. They share a shape, so I've written each one the same way: the symptom you see, why it happens, and what to do about it.</p>
<h3 id="heading-gotcha-1-the-compact-island-looks-broken">Gotcha 1: The Compact Island Looks Broken</h3>
<p>You build the compact Dynamic Island, run the app, and it never appears.</p>
<p>This happens because the compact presentation is hidden while your own app is in the foreground. This is by design, but nothing tells you so, and I spent twenty minutes certain my layout was wrong.</p>
<p>To fix this, lock the device, or switch to another app, before you judge whether the island works. There's nothing to fix in the code.</p>
<h3 id="heading-gotcha-2-an-old-activity-renders-nothing-after-a-rebuild">Gotcha 2: An Old Activity Renders Nothing After a Rebuild</h3>
<p>You change the widget, rebuild, and an activity that was already running goes blank. No error appears anywhere.</p>
<p>This happens when an activity is tied to the exact build that started it. Once you rebuild the widget extension, the running activity no longer matches the code on the device.</p>
<p>To fix this, after any change to the widget extension, end the stale activity and start a fresh one. Don't expect a warning to remind you.</p>
<h3 id="heading-gotcha-3-reinstalling-can-jam-the-system-daemon">Gotcha 3: Reinstalling Can Jam the System Daemon</h3>
<p>Right after <code>simctl install</code>, your first activity refuses to render. <code>Activity.activities</code> says it's active, yet the screen shows nothing.</p>
<p>This happens because Live Activities are drawn by a background system process called <code>chronod</code>. A reinstall can leave it wedged: in my case it threw <code>widgetDescriptorNotFound</code> and logged internal errors, and no amount of ending and restarting the activity cleared it.</p>
<p>This is a fault in the daemon, not in your app, which is why the usual checks mislead you. Listing the extension with <code>pluginkit -m</code> shows it present and healthy while the daemon behind it is stuck.</p>
<p>To fix this, reboot the simulator with <code>simctl shutdown &amp;&amp; boot</code>, then start a fresh activity.</p>
<h3 id="heading-gotcha-4-tapping-the-card-opens-an-app-that-forgot-everything">Gotcha 4: Tapping the Card Opens an App That Forgot Everything</h3>
<p>This is the subtle one, the most visible to users, and the reason for the next two code samples.</p>
<p>The user taps the live card to open your app. The app launches into an empty screen that insists nothing is being tracked, every control disabled, while the card sits right there on the lock screen still updating.</p>
<p>This happens because tapping the card cold-starts the app, which means your JavaScript begins from scratch. The activity id lived in React state, and that state died with the previous process.</p>
<p>The activity itself is completely fine: <code>Activity.activities</code> still lists it, and an APNs push to it still returns <code>200</code>. (A dead activity returns <code>410</code>, which makes a handy liveness probe.) The app simply forgot the id it needs to reconnect.</p>
<p>To fix this, stop treating your in-memory id as the source of truth, and ask the system what is running on launch. Expose the live activities to JavaScript.</p>
<pre><code class="language-swift">AsyncFunction("getRunningActivities") { () -&gt; [[String: Any]] in
  guard #available(iOS 16.2, *) else { return [] }

  // The system's list, populated even on a brand-new process. That is the point.
  return Activity&lt;DeliveryAttributes&gt;.activities.map { activity in
    self.observePushToken(of: activity)   // resubscribe: see note below
    let state = activity.content.state    // the live state, kept current by push
    return [
      "activityId": activity.id,                // the handle JS lost
      "orderId": activity.attributes.orderId,   // the static half
      "status": state.status,
      "progress": state.progress,
      "courierName": state.courierName,
      "riderReassigned": state.riderReassigned,
    ]
  }
}
</code></pre>
<p>That <code>observePushToken</code> call is the line everyone misses. The <code>for await</code> loop watching this activity's push token died with the previous process, so without resubscribing here the card recovers. But the server never learns the token again after a rotation, and pushes quietly stop landing.</p>
<p>Then rehydrate on mount, and again on every foreground, because a push that lands while you're backgrounded updates the widget without running your JavaScript at all. React Native's <a href="https://reactnative.dev/docs/appstate"><code>AppState</code></a> gives you the hook.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const sync = () =&gt; {
    void DroptrackLive.getRunningActivities().then((running) =&gt; {
      const activity = running[0];
      if (!activity) return;   // nothing running: leave the empty state alone

      setActivityId(activity.activityId);   // this line re-enables the whole UI
      setRider({ name: activity.courierName, justReassigned: activity.riderReassigned });

      // The card carries a status string, not a step index. Guard the -1.
      const index = STEPS.findIndex((s) =&gt; s.status === activity.status);
      if (index &gt;= 0) setStepIndex(index);
    });
  };

  sync();   // case 1: cold start (e.g. the user tapped the card and we launched)
  const sub = AppState.addEventListener("change", (s) =&gt; {
    if (s === "active") sync();   // case 2: resume from background
  });
  return () =&gt; sub.remove();
}, []);
</code></pre>
<p>The whole tracking interface is gated on <code>activityId</code>, so restoring it is what turns the app back on. Miss that one line and the app looks dead while the card is live on the lock screen.</p>
<p>The two call sites cover the two ways state goes stale. <code>sync()</code> on mount handles a cold start, and the <code>AppState</code> listener handles a resume, because the mount effect won't re-run and a push may have advanced the card while you were backgrounded without executing any JavaScript. The empty dependency array means this wiring is set up once, for the app's lifetime.</p>
<p>That last gotcha generalises into the sentence I wish I'd read first: <strong>Live Activities are owned by the system, not by your process.</strong> The API hands you an activity id and it's easy to assume the id is yours to keep. It's not. The activity outlives the variable, and the moment a user is most likely to open your app, by tapping the live card, is precisely the moment your in-memory copy of that id doesn't exist.</p>
<h2 id="heading-how-to-drive-ios-from-a-server-with-apns">How to Drive iOS From a Server With APNs</h2>
<p>Here's the payoff: updating the card while the app is fully force-quit. The API surface is almost insultingly small. You change one argument. Apple documents the flow in <a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">Starting and updating Live Activities with ActivityKit push notifications</a>.</p>
<pre><code class="language-swift">let activity = try Activity.request(
  attributes: attributes,
  content: ActivityContent(state: state.toContentState(), staleDate: nil),
  pushType: .token   // this was `nil`. That is the entire feature.
)

Task {
  for await tokenData in activity.pushTokenUpdates {
    let token = tokenData.map { String(format: "%02x", $0) }.joined()   // raw Data -&gt; hex
    NSLog("[DropTrack] push token for %@: %@", activity.id, token)       // NSLog, so devicectl sees it
    self.sendEvent("onPushTokenReceived", ["activityId": activity.id, "token": token])
  }
}
</code></pre>
<p><code>pushTokenUpdates</code> is an <code>AsyncSequence</code>, not a one-shot getter, which is why this is a <code>for await</code> loop and not a single read. Reading <code>activity.pushToken</code> right after <code>request()</code> reliably returns nil, because the token hasn't arrived from APNs yet, and it can also rotate mid-flight.</p>
<p>The token arrives as raw <code>Data</code>, so it's hexed before use. It's logged with <code>NSLog</code> rather than <code>print</code> because a standalone Release build has no Metro to receive <code>console.log</code>, and <code>NSLog</code> is what <code>devicectl --console</code> surfaces. And the token is per-activity, so it's keyed by activity id when handed to JS rather than stored globally.</p>
<p>Everything after that is plumbing, and two pieces of it fail without a single error message.</p>
<p><code>pushType: .token</code> <strong>does nothing without the</strong> <a href="https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment"><code>aps-environment</code></a> <strong>entitlement.</strong> <code>Activity.request</code> still succeeds. Local updates still work. The token stream simply never yields. Nothing logs. Add the entitlement in <code>app.json</code>:</p>
<pre><code class="language-json">"ios": {
  "entitlements": {
    "aps-environment": "development",
    "com.apple.security.application-groups": ["group.com.fasarticle.droptrack"]
  }
}
</code></pre>
<p>Two notes on those values, since <code>app.json</code> can't carry comments. <code>aps-environment</code> set to <code>development</code> means your tokens are only valid against <code>api.sandbox.push.apple.com</code>. A shipping build needs <code>production</code>, and mixing the two produces an afternoon of <code>BadDeviceToken</code>. The application group is the shared container between the app and the widget extension, and both targets must carry the identical group id or shared reads silently return nothing.</p>
<p>Then verify that the entitlement survived signing, because an entitlement in your config isn't the same as an entitlement in your binary:</p>
<pre><code class="language-shell"># Reads the entitlements actually baked into the signed .app.
# If aps-environment is absent here, the token stream will never yield,
# no matter what app.json says.
codesign -d --entitlements - --xml Build/Products/Release-iphoneos/DropTrack.app
</code></pre>
<p><strong>The push token is per-activity, not per-device.</strong> It arrives asynchronously after <code>request()</code> returns, and the system can rotate it mid-flight. Consume the <code>pushTokenUpdates</code> async sequence. A single read of <code>activity.pushToken</code> immediately after <code>request()</code> is reliably nil. Every <code>startDelivery</code> mints a brand-new token. I verified that by starting two activities and comparing the hex.</p>
<h2 id="heading-how-to-write-an-apns-client-from-scratch">How to Write an APNs Client From Scratch</h2>
<p>You don't need a library. Two Node.js built-ins and about sixty lines will do it.</p>
<p>Sending one push comes down to three steps. First, sign a token that proves the request is really from you. Second, open a connection to Apple and attach a precise set of headers. Third, send the state you want the card to show. The rest of this section walks each step, then covers the two ways it fails silently.</p>
<p>One thing to get out of the way first: APNs speaks HTTP/2 only, so <code>fetch()</code> can't reach it. You need Node's <a href="https://nodejs.org/api/http2.html"><code>node:http2</code></a> module, which is why this is written by hand rather than as a <code>fetch</code> call.</p>
<h3 id="heading-step-1-sign-a-token-that-proves-who-you-are">Step 1: Sign a Token That Proves Who You Are</h3>
<p>APNs won't accept a push until you prove you own the app. You do that with an ES256 <a href="https://datatracker.ietf.org/doc/html/rfc7519">JSON Web Token</a>, signed by the <code>.p8</code> key you download from Apple, following <a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns">Establishing a token-based connection to APNs</a>. A JWT is just three base64url chunks joined by dots: a header, a claims object, and a signature over the first two.</p>
<pre><code class="language-javascript">import { createPrivateKey, sign } from "node:crypto";

const b64url = (buf) =&gt; Buffer.from(buf).toString("base64url");   // JWT uses base64url, not base64

export function mintJWT({ keyPath, keyId, teamId }) {
  const header = b64url(JSON.stringify({ alg: "ES256", kid: keyId }));   // kid: the 10-char key id
  const claims = b64url(JSON.stringify({ iss: teamId, iat: Math.floor(Date.now() / 1000) }));
  const signingInput = `${header}.${claims}`;   // a JWT signs header AND claims joined

  const signature = sign("sha256", Buffer.from(signingInput), {
    key: createPrivateKey(readFileSync(keyPath, "utf8")),
    dsaEncoding: "ieee-p1363",   // THE line. The default (DER) fails silently. See below.
  });

  return `${signingInput}.${b64url(signature)}`;
}
</code></pre>
<p>That one <code>dsaEncoding</code> line is the whole reason to write this by hand rather than trust a snippet off the internet.</p>
<p>Node signs in DER format by default, which wraps the signature's two halves in an envelope, but the JWT standard (<a href="https://datatracker.ietf.org/doc/html/rfc7518">RFC 7518</a>) wants those two halves raw and joined. Use the wrong one and APNs answers <code>403 InvalidProviderToken</code>, which reads like your key is bad and sends you re-downloading the <code>.p8</code> for an hour, when the key was fine all along.</p>
<p>Two smaller details. <code>kid</code> is the ten-character key id (it is in the <code>.p8</code> filename), and it tells APNs which of your registered keys should check the signature. And the <code>iat</code> timestamp can't be more than an hour old, per Apple, which also asks you to refresh it no more than once every 20 minutes, so cache the token instead of minting a fresh one for every push.</p>
<h3 id="heading-step-2-open-the-connection-and-send-the-push">Step 2: Open the Connection and Send the Push</h3>
<p>With the token in hand, open an HTTP/2 session to the APNs host and make one <code>POST</code>. The headers have to be exact, and the one people get wrong is the topic: it's your bundle id with <code>.push-type.liveactivity</code> appended, not the bundle id on its own.</p>
<pre><code class="language-javascript">// Pseudo-headers (":method", ":path") are HTTP/2's way of encoding the request
// line. `client` here is an http2 session opened against the APNs host.
const req = client.request({
  ":method": "POST",
  // The device token in the path is the PER-ACTIVITY token, not a device token
  // from UNUserNotificationCenter. Those are different values entirely.
  ":path": `/3/device/${token}`,
  authorization: `bearer ${jwt}`,   // lowercase "bearer" is what APNs expects

  // Live Activity pushes get a SUFFIXED topic. Send the bare bundle id and
  // APNs rejects the request outright.
  "apns-topic": `${bundleId}.push-type.liveactivity`,
  "apns-push-type": "liveactivity", // must match the topic suffix
  "apns-priority": "10",            // 10 = deliver immediately, 5 = opportunistic
  "apns-expiration": "0",           // 0 = do not retry a failed push
});

req.end(JSON.stringify({
  aps: {
    // Ordering guard, in SECONDS. APNs discards a push whose timestamp is older
    // than the last one it applied, so out-of-order retries cannot rewind the card.
    timestamp: Math.floor(Date.now() / 1000),
    event: "update",              // "update" mutates the card, "end" finishes it
    // Decoded straight into the widget's Codable ContentState. Field names and
    // types must match that struct EXACTLY. See the eta trap below.
    "content-state": contentState,
  },
}));
</code></pre>
<p>The body is small on purpose. <code>timestamp</code> is an ordering guard in seconds: APNs drops any push older than the last one it applied, so a delayed retry can't rewind the card. <code>event</code> is <code>"update"</code> to change the card or <code>"end"</code> to finish the activity, and there's no <code>"start"</code> here because push-to-start is a separate token that needs iOS 17.2. Everything else lives in <code>content-state</code>, which the widget decodes directly, and that's where the second silent failure hides, below.</p>
<h3 id="heading-how-to-test-your-auth-without-a-phone">How to Test Your Auth Without a Phone</h3>
<p>This one trick saved me the most time, so do it before you involve a device at all. Send a push to the sandbox using a deliberately fake device token, and read the <a href="https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns">response code</a> APNs gives back.</p>
<ul>
<li><p><code>400 BadDeviceToken</code> is the good outcome. It means your key, key id, team id, and JWT encoding are all correct, and APNs got far enough to look up the token and simply not find it. Your auth works.</p>
</li>
<li><p><code>403 InvalidProviderToken</code> means the token itself is wrong, so the problem is your key or your signing, not the device.</p>
</li>
</ul>
<p>That single request separates "my auth is broken" from "my token is broken" with no phone in the room. On this project it returned <code>400</code> on the first try, which told me the auth was solid and I should look elsewhere for the real bug. That was exactly the reassurance I needed.</p>
<h3 id="heading-the-eta-trap-a-200-that-renders-nothing">The <code>eta</code> Trap: a 200 That Renders Nothing</h3>
<p>This is the most instructive bug in the project, and it hit the moment a second code path reused a shared helper.</p>
<p>The same delivery data takes two different routes to the widget, and the routes expect two different shapes. The native module path carries <code>etaEpochMillis</code> (Unix milliseconds), and Swift converts it into a <code>Date</code> on the way through. The push path skips Swift entirely: the JSON <code>content-state</code> is decoded directly by the widget's <code>Codable</code> struct, which expects a field literally named <code>eta</code>, holding seconds since 2001 (Apple's <a href="https://developer.apple.com/documentation/foundation/date">reference date</a>), and knows nothing about <code>etaEpochMillis</code>.</p>
<p>So if you reuse the native-bridge object as a push payload, APNs returns <code>200</code>, iOS quietly throws the update away because it won't decode, and nothing is logged anywhere. The fix is to translate at the boundary, converting to the widget's shape only when building a push.</p>
<pre><code class="language-javascript">const APPLE_EPOCH_OFFSET = 978_307_200;   // seconds between 1970 and 2001

// The shape the WIDGET decodes, not the shape the native bridge takes.
function toContentState({ etaEpochMillis, ...rest }) {
  return {
    ...rest,
    eta: Math.floor(etaEpochMillis / 1000) - APPLE_EPOCH_OFFSET,   // ms-&gt;s, then rebase to 2001
  };
}
</code></pre>
<p>Two details make this work. <code>Foundation.Date</code>'s reference point is 2001-01-01, not the Unix epoch, and Swift's <code>Codable</code> encodes a <code>Date</code> as seconds since that date, which is why the offset is subtracted. And destructuring <code>etaEpochMillis</code> out of <code>rest</code> is what drops the wrong key: leave it in and the widget's <code>Codable</code> init sees an unexpected field next to a missing <code>eta</code>, fails to decode, and the update vanishes after a 200.</p>
<p>The lesson generalises past the <code>eta</code> field. <strong>A</strong> <code>200</code> <strong>from APNs means only that Apple accepted your bytes, not that the card changed.</strong> Any content-state that fails to decode is discarded with no error on any surface, so confirm on the lock screen every time rather than trusting the status code.</p>
<p>Two last environment traps to note. Development-signed builds must talk to <code>api.sandbox.push.apple.com</code>, because the production host returns <code>400 BadDeviceToken</code> for a sandbox token, which looks exactly like a malformed token and sends you debugging the wrong layer. And if you push frequently, add <code>NSSupportsLiveActivitiesFrequentUpdates</code> to your <code>Info.plist</code>, or the system budgets and drops your rapid pushes.</p>
<h2 id="heading-how-to-test-on-real-hardware">How to Test on Real Hardware</h2>
<p>The simulator will lie to you about push, so at some point you need a real phone. Four things can trip you up there that the documentation never mentions. Here is each one with the fix.</p>
<h3 id="heading-build-in-release-not-debug">Build in Release, not Debug</h3>
<p>A Debug build expects to download its JavaScript from Metro over your local network. The build script bakes your Mac's network address into the app, so the moment the phone is on a different network, or simply not tethered to your Mac, it shows a red screen reading <code>No script URL provided</code>. There's no <code>adb reverse</code> equivalent on iOS to paper over this.</p>
<p>Build with <code>--configuration Release</code> instead, which embeds the JavaScript bundle inside the app. That's the more honest test anyway, because a Release build is the only way to fully force-quit the app and prove the push, not a live Metro connection, is doing the work.</p>
<h3 id="heading-read-logs-with-devicectl-not-consolelog">Read Logs with <code>devicectl</code>, Not <code>console.log</code></h3>
<p>Once you drop Metro, <code>console.log</code> has nowhere to go. To read something like a push token off the device, you need the app's real standard output, and <code>NSLog</code> in the Swift code plus <code>devicectl</code> on the Mac is the reliable way to get it.</p>
<pre><code class="language-shell"># --console            stream the app's stdout/stderr back to this terminal
# --terminate-existing kill a running copy first, so we catch launch-time logs
# --device             the COREDEVICE id (see the identifier trap below)
xcrun devicectl device process launch --console --terminate-existing \
  --device &lt;coredevice-id&gt; com.fasarticle.droptrack
# → [DropTrack] push token for 3095ACA0-...: 80875cb137590013a4c9...
#   ^ the hex string the dispatch server scrapes and pushes to
</code></pre>
<h3 id="heading-know-which-of-the-two-device-identifiers-you-need">Know Which of the Two Device Identifiers You Need</h3>
<p>The same phone has two identifiers, and the tools disagree about which they want. <code>expo run:ios --device</code> wants the hardware UDID, listed by <code>xcrun xctrace list devices</code>. The <code>devicectl</code> command above wants the CoreDevice identifier, listed by <code>xcrun devicectl list devices</code>.</p>
<p>Pass one where the other belongs and you get <code>No device UDID or name matching</code>, which reads as though the phone is unplugged. It's not, so check that you copied the right kind of id before you check the cable.</p>
<h3 id="heading-handle-cold-start-deep-links-not-just-live-ones">Handle Cold-start Deep Links, Not Just Live Ones</h3>
<p>If your test driver opens the app with a deep link, note that <code>devicectl ... --payload-url</code> cold-starts the app, so the link arrives through <a href="https://reactnative.dev/docs/linking"><code>Linking.getInitialURL()</code></a> rather than the <code>url</code> event that the simulator's <code>simctl openurl</code> fires. A driver that listens only for the <code>url</code> event will appear to do nothing on a device. Handle both entry points.</p>
<p>With all four handled and the app confirmed fully quit, I drove three pushes through it (a status change, a rider reassignment, and an "arriving now"). Each one rendered on the lock screen and in the Dynamic Island.</p>
<p>One bonus comes free. Sign the Mac into the same Apple ID, and Continuity mirrors the same Live Activity onto the macOS menu bar, segmented bar, and all. One APNs push, three surfaces.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/9aaf2fd9-07b5-49b8-a6cb-6befe06b9e15.png" alt="DropTrack Live Activity on the macOS menu bar via Continuity" style="display:block;margin:0 auto" width="1200" height="305" loading="lazy">

<h2 id="heading-why-android-has-no-activitykit">Why Android Has No ActivityKit</h2>
<p>Android has no equivalent of ActivityKit, so there's no system-owned card and no framework keeping it up to date. What you have instead is an ordinary notification that you keep re-posting.</p>
<p>The trick is the notification id. Re-post under the same id and Android replaces the existing notification in place, which reads as an update. Post under a new id and you get a second notification instead. So the three lifecycle verbs map onto plain notification calls:</p>
<ul>
<li><p><strong>start</strong> is <code>notify()</code> with a fresh id.</p>
</li>
<li><p><strong>update</strong> is <code>notify()</code> again under that same id.</p>
</li>
<li><p><strong>end</strong> is a final <code>notify()</code> with <code>ongoing = false</code>, followed by a delayed <code>cancel()</code>.</p>
</li>
</ul>
<p>That is the entire model. The catch is that everything iOS did for you (keeping the card current, recovering it after a restart, and updating it from a push) is now code you have to write.</p>
<h3 id="heading-the-trap-that-shapes-everything-android-16-is-two-releases-in-one">The Trap That Shapes Everything: Android 16 is Two Releases in One</h3>
<p>One versioning quirk shapes the whole Android side, so it's worth thirty seconds up front. "Android 16" ships as two releases that share a single API number:</p>
<table>
<thead>
<tr>
<th>Release</th>
<th>Reports</th>
<th>What it adds</th>
</tr>
</thead>
<tbody><tr>
<td>Android 16 (base)</td>
<td><code>SDK_INT == 36</code></td>
<td><a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>ProgressStyle</code></a>, the segmented progress bar</td>
</tr>
<tr>
<td>Android 16 QPR1</td>
<td><code>SDK_INT == 36</code></td>
<td>the <em>promotion</em> pipeline: the status-bar chip and the elevated lock-screen slot</td>
</tr>
</tbody></table>
<p>Both report <code>36</code> because <code>Build.VERSION.SDK_INT</code>, the value your code reads to check the OS version, is a plain integer with no room for a <code>.1</code>. Two different releases, one number.</p>
<p>That collision is the entire source of the friction, and it leaves you three things to handle. None is hard once you know it is coming.</p>
<h4 id="heading-1-compile-through-the-backport-not-the-platform-classes">1. Compile through the backport, not the platform classes</h4>
<p>The promotion methods (<code>setRequestPromotedOngoing</code>, <code>setShortCriticalText</code>, and the <code>POST_PROMOTED_NOTIFICATIONS</code> permission) live only in the 36.1 SDK, and React Native 0.86 compiles against base 36. Call them on the platform classes directly and the build fails with "no such method" before the app ever runs. One dependency fixes that:</p>
<pre><code class="language-gradle">dependencies {
  // NotificationCompat backports the 36.1 promotion APIs so they compile
  // against base SDK 36. Without this, ProgressStyle, setRequestPromotedOngoing
  // and setShortCriticalText are simply not on the classpath.
  implementation 'androidx.core:core-ktx:1.17.0'
}
</code></pre>
<p>Call the <a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder"><code>NotificationCompat</code></a> versions rather than the platform ones. They compile against 36, take effect on a real 36.1 device, and are ignored on older releases, so one build is safe everywhere.</p>
<h4 id="heading-2-read-sdkintfull-only-when-you-truly-need-the-minor-version">2. Read <code>SDK_INT_FULL</code> only when you truly need the minor version</h4>
<p>A normal <code>SDK_INT &gt;= 36</code> check can't tell base 36 from 36.1, since both say 36. When you genuinely need to know which release you are on, <a href="https://developer.android.com/reference/android/os/Build.VERSION#SDK_INT_FULL"><code>Build.VERSION.SDK_INT_FULL</code></a> carries the minor version, with named values in <a href="https://developer.android.com/reference/android/os/Build.VERSION_CODES_FULL"><code>Build.VERSION_CODES_FULL</code></a>. Because of rule 1 you rarely need it for the promotion request itself. You reach for it to decide what to show the user, or to guard the call in rule 3.</p>
<h4 id="heading-3-wrap-the-capability-check-so-it-cant-crash-you">3. Wrap the capability check so it can't crash you</h4>
<p><a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()"><code>NotificationManager.canPostPromotedNotifications()</code></a> reports whether the device will actually honour a promotion. Its reference lists it as <a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()">added in API 36</a>, so in theory it belongs to base Android 16, while the <a href="https://developer.android.com/reference/android/Manifest.permission#POST_PROMOTED_NOTIFICATIONS"><code>POST_PROMOTED_NOTIFICATIONS</code></a> permission it depends on only arrived in 36.1. On the pre-QPR build I tested, though, the call was absent at runtime and threw. A capability probe should never be the thing that crashes your app, so wrap it and default to false:</p>
<pre><code class="language-kotlin">Function("canPostPromotedNotifications") {
  // Function, not AsyncFunction: this is a cheap synchronous read, and the UI
  // wants it during the first render to decide what to disable.
  val manager = notificationManager ?: return@Function false

  // Cheapest gate first: below API 36 there is nothing to promote to.
  if (Build.VERSION.SDK_INT &lt; 36) return@Function false

  // Docs list this as added in API 36, but on the pre-QPR build I tested it was
  // absent at runtime and threw. runCatching stops a capability probe from
  // crashing the app, defaulting to false when the call is unavailable.
  return@Function runCatching { manager.canPostPromotedNotifications() }.getOrDefault(false)
}
</code></pre>
<h2 id="heading-how-to-build-the-kotlin-side">How to Build the Kotlin Side</h2>
<p>Here's the notification builder. Notice how many of these lines are promotion requirements rather than cosmetics.</p>
<pre><code class="language-kotlin">// Whole percent, clamped: a server that sends 1.4 must not become 140.
val progressPercent = (progress.coerceIn(0.0, 1.0) * 100).toInt()

val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
  .setSmallIcon(R.drawable.ic_delivery)
  .setContentTitle(status)                     // promotion REQUIRES a title
  .setContentText(courierLine(courierName, riderReassigned, stopsRemaining))
  .setSubText("Order $orderId")
  .setOngoing(ongoing)                         // promotion REQUIRES ongoing
  .setOnlyAlertOnce(true)                      // buzz once, then update silently
  .setColor(BRAND_ORANGE)                      // NOT setColorized: that disqualifies it
  .setShortCriticalText("$progressPercent%")   // text inside the status-bar chip
  .setRequestPromotedOngoing(ongoing)          // the promotion request itself

// `when` is the ETA in the header. It must be in the future (see note below).
if (etaEpochMillis &gt; System.currentTimeMillis()) {
  builder.setWhen(etaEpochMillis.toLong()).setShowWhen(true)
}

if (Build.VERSION.SDK_INT &gt;= 36) {
  val style = NotificationCompat.ProgressStyle()
    .setStyledByProgress(true)                 // colour follows the value
    .setProgress(progressPercent)
    .setProgressTrackerIcon(IconCompat.createWithResource(ctx, R.drawable.ic_delivery))
    .setProgressSegments(listOf(               // segments: spans of the bar
      NotificationCompat.ProgressStyle.Segment(100).setColor(BRAND_ORANGE)
    ))
    .setProgressPoints(listOf(                 // points: milestone dots on top
      NotificationCompat.ProgressStyle.Point(35).setColor(Color.WHITE)
    ))
  builder.setStyle(style)
} else {
  builder.setProgress(100, progressPercent, false)  // pre-16 fallback bar
}

val notification = builder.build()
if (Build.VERSION.SDK_INT &gt;= 36) {
  Log.d(TAG, "hasPromotableCharacteristics=${notification.hasPromotableCharacteristics()}")
}
manager.notify(notificationIdFor(activityId), notification)   // stable id = update in place
</code></pre>
<p>Several of those lines carry non-obvious weight:</p>
<ul>
<li><p><code>setOnlyAlertOnce(true)</code> stops each of the seven re-posts per delivery from buzzing the phone. Only the first <code>notify()</code> alerts, and the rest land silently.</p>
</li>
<li><p><code>setColor</code> <strong>versus</strong> <code>setColorized</code><strong>:</strong> <code>setColor</code> tints the notification and is fine, but <code>setColorized(true)</code> would <em>disqualify</em> it from promotion. They're not interchangeable.</p>
</li>
<li><p><code>setWhen</code> must be a future timestamp, or the update can be skipped entirely. One UI renders it as an absolute clock time, Pixel as a relative countdown.</p>
</li>
<li><p><code>ProgressStyle</code> <strong>segments versus points:</strong> <em>Segments</em> are spans of the bar (one <code>Segment(100)</code> fills the whole thing, several draw the divided look). <em>Points</em> are milestone dots painted on top at a given percent.</p>
</li>
<li><p><strong>The</strong> <code>else</code> <strong>branch:</strong> Below API 36 the compat layer has no <code>ProgressStyle</code> at all, so it falls back to the classic determinate bar.</p>
</li>
</ul>
<p>Two runtime checks are your only debugging signal, and they answer different questions:</p>
<ul>
<li><p><code>hasPromotableCharacteristics()</code>, logged after <code>build()</code>, tells you whether <em>you</em> satisfied every promotion precondition. It says nothing about the device.</p>
</li>
<li><p><code>canPostPromotedNotifications()</code> tells you whether the <em>device</em> will honour the request. The two genuinely disagree on Samsung, which is why you need both.</p>
</li>
</ul>
<p>And <code>notificationIdFor()</code> derives a stable int from the activity id, so a re-post under the same id updates the card in place. A different id would post a second notification, the classic "why do I have seven delivery cards" bug.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/b815c40a-9c1f-42c6-a8da-1dc6647eb7d1.png" alt="DropTrack notification with a ProgressStyle segmented bar in the shade" style="display:block;margin:0 auto" width="640" height="1386" loading="lazy">

<p><strong>Promotion eligibility is all-or-nothing, and silent.</strong> It flips true only when every condition holds: notification permission, <code>setRequestPromotedOngoing(true)</code>, ongoing, a content title, an allowed style, importance above <code>MIN</code>, and not colorized. Miss one and you get an ordinary notification with no explanation. Log <a href="https://developer.android.com/reference/android/app/Notification#hasPromotableCharacteristics()"><code>hasPromotableCharacteristics()</code></a> after every <code>build()</code>, and surface <code>canPostPromotedNotifications()</code> in your development interface. Those two booleans are the only debugging signal the platform gives you.</p>
<p>Note also that custom <code>RemoteViews</code> aren't allowed for promoted notifications. You get the <code>ProgressStyle</code> template, or you get no promotion. That's the biggest design constraint compared with iOS, where you write arbitrary SwiftUI.</p>
<p>When it does promote on a real API 36.1 build, you get the chip and the elevated lock-screen slot.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/d0c42fc5-3c8a-42c5-8398-cc4fc358a174.png" alt="DropTrack status-bar chip on Android 16 QPR" style="display:block;margin:0 auto" width="636" height="59" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/1583af2a-bd36-40c1-9b6b-9fca3179669f.png" alt="DropTrack promoted lock-screen placement on Android 16 QPR" style="display:block;margin:0 auto" width="640" height="1386" loading="lazy">

<p>The same APK behaves three different ways:</p>
<table>
<thead>
<tr>
<th>Runtime</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>API 36.1 (QPR)</td>
<td>Chip, promoted lock-screen slot, and segmented bar</td>
</tr>
<tr>
<td>API 36 (base)</td>
<td>Segmented bar only, no promotion</td>
</tr>
<tr>
<td>Below API 36</td>
<td>No bar at all, unless you keep the manual <code>setProgress</code> branch</td>
</tr>
</tbody></table>
<h2 id="heading-how-to-drive-android-from-a-server-with-fcm">How to Drive Android From a Server With FCM</h2>
<p>This is the deepest difference between the two platforms.</p>
<p>On iOS, APNs updates the widget through the system, and your app code never runs. On Android, there's no system-managed remote update. A data-only FCM message wakes a <a href="https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService"><code>FirebaseMessagingService</code></a>, and your code re-posts the notification. The app is the updater, even from the background.</p>
<pre><code class="language-kotlin">// Registered in the manifest, so Android can instantiate it WITHOUT your
// Activity, your React context, or your module ever existing.
class DroptrackFcmService : FirebaseMessagingService() {

  // Called on install, and again whenever FCM rotates the token. Never assume
  // the token you saw at startup is still valid.
  override fun onNewToken(token: String) {
    Log.i(DeliveryNotifier.TAG, "[DropTrack] fcm token: $token")
  }

  override fun onMessageReceived(message: RemoteMessage) {
    // FCM `data` is always Map&lt;String, String&gt;. Every number and boolean was
    // stringified on the way out and has to be parsed back here.
    val d = message.data

    // No activityId means we cannot address a notification. Bail rather than
    // guess: posting under the wrong id creates a duplicate card.
    val activityId = d["activityId"] ?: return
    val event = d["event"] ?: "update"

    // This service is a SEPARATE entry point. It cannot see the module's
    // in-memory state, so every field must come from the payload, and every
    // parse must be defensive, because a crash here kills the update.
    DeliveryNotifier.ensureChannel(this)   // the process may be brand new
    DeliveryNotifier.post(
      ctx = this,
      activityId = activityId,
      // Every `?:` below is load-bearing. toDoubleOrNull returns null rather
      // than throwing on malformed input, so a bad payload degrades the card
      // instead of killing the service.
      orderId = d["orderId"] ?: "",
      status = d["status"] ?: "",
      progress = d["progress"]?.toDoubleOrNull() ?: 0.0,
      etaEpochMillis = d["etaEpochMillis"]?.toDoubleOrNull() ?: 0.0,
      stopsRemaining = d["stopsRemaining"]?.toIntOrNull() ?: 0,
      courierName = d["courierName"] ?: "",
      riderReassigned = d["riderReassigned"]?.toBoolean() ?: false,
      // "end" clears `ongoing`, which lets the user finally swipe the card away.
      ongoing = event != "end",
    )
  }
}
</code></pre>
<p>That "separate entry point" comment is the crux, so it's worth slowing down on. Android can start this service on its own to deliver a push, at a moment when the rest of your app isn't running. There's no React, no module instance, and none of the objects the app was holding in memory. So the push handler can't look up "what delivery is in progress" from app state, because there's no app state to look at. Everything it needs has to come from the push payload itself.</p>
<p>That forces a specific design. The code that builds a notification can't live inside the app and read app state. It has to be standalone code that takes plain values and nothing else.</p>
<p>So I pulled all of it into a shared <code>DeliveryNotifier</code> object, and both paths call it: the in-app path when JavaScript drives an update, and the push path when the service does. Because they run the same builder from the same inputs, they produce an identical notification, and that shared builder is the single most important structural change on the Android side.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/652317c7-291c-4480-a402-fe44c60fcb7f.png" alt="DropTrack notification updated by a remote FCM push while the app was backgrounded" style="display:block;margin:0 auto" width="640" height="269" loading="lazy">

<h3 id="heading-the-four-rules-of-android-push">The Four Rules of Android Push</h3>
<p>Four things have to be right, or the push half quietly fails. In short: send the right kind of message, authenticate the harder way, read the error codes correctly, and wire the plugin through prebuild. Each one in full below.</p>
<h4 id="heading-rule-1-send-a-data-only-message-never-one-with-a-notification-block">Rule 1: send a data-only message, never one with a <code>notification</code> block</h4>
<p>This is the rule that catches everyone. A message with a <code>notification</code> block, while the app is backgrounded or killed, is handled by the system tray, and your <code>onMessageReceived</code> code never runs, so the card never updates. Only a data-only message (no <code>notification</code> block) reaches your code in the background.</p>
<p>Firebase documents this in <a href="https://firebase.google.com/docs/cloud-messaging/android/receive">Receive messages in an Android app</a>. Send data-only, and set <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig"><code>android.priority</code></a> to <code>high</code> so the app wakes promptly.</p>
<h4 id="heading-rule-2-authenticate-with-a-service-account-token-not-a-static-key">Rule 2: authenticate with a service-account token, not a static key</h4>
<p>FCM v1 auth is heavier than APNs, with no one-shot <code>.p8</code>. You mint an RS256 JWT from a <a href="https://developers.google.com/identity/protocols/oauth2/service-account">service account</a>, exchange it for a short-lived OAuth access token, and send that access token as a bearer credential to <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send"><code>projects.messages.send</code></a>. The consolation is that FCM v1 is plain HTTPS, so <code>fetch()</code> works.</p>
<pre><code class="language-javascript">// `sa` is the parsed service-account JSON from Firebase. This JWT is not the
// credential you send to FCM; it is the one you trade for a short-lived token.
export function buildAuthJWT(sa) {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));  // RSA key, so no ieee-p1363 trap
  const claims = b64url(JSON.stringify({
    iss: sa.client_email,
    scope: "https://www.googleapis.com/auth/firebase.messaging",  // exact scope, or every send 403s
    aud: sa.token_uri,        // the JWT is FOR Google's token endpoint
    iat: now,
    exp: now + 3600,          // one hour, Google's maximum
  }));
  const signingInput = `${header}.${claims}`;
  const sig = createSign("RSA-SHA256").update(signingInput).sign(sa.private_key);
  return `${signingInput}.${b64url(sig)}`;
}

export async function sendDataMessage({ token, data, sa }) {
  const accessToken = await getAccessToken(sa);   // trades the JWT for a token; cache the result
  const res = await fetch(
    `https://fcm.googleapis.com/v1/projects/${sa.project_id}/messages:send`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        message: {
          token,                        // per-DEVICE token (APNs is per-activity)
          data,                         // note: NO `notification` key (see below)
          android: { priority: "high" },
        },
      }),
    }
  );
  const body = await res.json().catch(() =&gt; ({}));   // never throw on a bad error body
  return { status: res.status, reason: body?.error?.status ?? null };
}
</code></pre>
<p>The design turns on two absences and one indirection. The indirection is the token exchange. Unlike APNs, where the JWT <em>is</em> the credential, here you POST the JWT to Google's token endpoint, get back a short-lived access token, and send that, so cache it rather than re-minting per message.</p>
<p>The first absence is any <code>notification</code> key in the body, which is deliberate and covered next. The second is high priority over normal, because <code>normal</code> may be held until the device leaves Doze, by which time the delivery has finished.</p>
<p>On the response, <code>404 UNREGISTERED</code> means drop the token while <code>400 INVALID_ARGUMENT</code> means it was never a token. The caller needs to tell those apart, so the reason string is returned rather than swallowed.</p>
<p>FCM <code>data</code> values are a map of string to string, so everything is stringified on the way out and parsed on the way in. Unlike iOS, there's no silent shape mismatch, because you wrote the parser. But your parser must never throw.</p>
<h4 id="heading-rule-3-treat-400-and-404-as-different-failures">Rule 3: treat <code>400</code> and <code>404</code> as different failures</h4>
<p>FCM splits two cases that APNs collapses. A malformed token returns <code>400 INVALID_ARGUMENT</code>, while a well-formed but unregistered token returns <code>404 UNREGISTERED</code> (the full list is in the <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode">FCM ErrorCode reference</a>).</p>
<p>APNs reports both as <code>BadDeviceToken</code>. This matters for the fake-token auth probe from the iOS section: on FCM a fake token gives <code>INVALID_ARGUMENT</code>, not <code>UNREGISTERED</code>. Either one still proves your auth works, since a bad service account fails earlier with <code>401</code> or <code>403</code>. I wrote my probe expecting <code>UNREGISTERED</code>, and correcting the test is how I learned the distinction.</p>
<h4 id="heading-rule-4-wire-the-google-services-plugin-through-prebuild">Rule 4: wire the <code>google-services</code> plugin through prebuild</h4>
<p>The <a href="https://developers.google.com/android/guides/google-services-plugin"><code>google-services</code> plugin</a> is what reads your Firebase config into the build. Because Continuous Native Generation regenerates the <code>android</code> folder on every <code>prebuild</code>, a <a href="https://docs.expo.dev/config-plugins/introduction/">config plugin</a> has to re-place <code>google-services.json</code> and re-add the Gradle wiring each time, or the setting is lost on the next prebuild.</p>
<pre><code class="language-javascript">// plugins/withAndroidFcm.js
function withGoogleServicesJson(config) {
  // withDangerousMod runs arbitrary filesystem work during prebuild. It is
  // "dangerous" because nothing validates the result. It is also the only way
  // to place a file the Gradle plugin expects to already exist.
  return withDangerousMod(config, ["android", (cfg) =&gt; {
    // Kept at the repo root, gitignored, and copied in on every prebuild.
    const src = path.join(cfg.modRequest.projectRoot, "google-services.json");
    // Must land in android/app/, where the google-services plugin looks for it.
    const dest = path.join(cfg.modRequest.platformProjectRoot, "app", "google-services.json");

    // Fail loudly at prebuild. Without this the build succeeds, the app starts,
    // and FCM initialisation quietly no-ops at runtime.
    if (!fs.existsSync(src)) {
      throw new Error("[withAndroidFcm] google-services.json not found at project root");
    }

    fs.copyFileSync(src, dest);
    return cfg;
  }]);
}
// plus withProjectBuildGradle for the classpath,
// and withAppBuildGradle to apply the plugin.
</code></pre>
<p>Have it throw when the file is missing. Failing fast at prebuild beats a mystery at runtime.</p>
<h3 id="heading-the-honest-caveat">The Honest Caveat</h3>
<p>A data message can be dropped under <a href="https://developer.android.com/training/monitoring-device-state/doze-standby">Doze</a> or after a force-kill. An iOS Live Activity is system-owned and updates regardless. High priority helps. This is a platform limitation to document rather than engineer around. Test with the app backgrounded, not swiped away, and be honest with your product team about the difference.</p>
<h2 id="heading-three-ux-gaps-the-naive-implementation-leaves">Three UX Gaps the Naïve Implementation Leaves</h2>
<p>Because your code owns the Android side, you inherit three responsibilities that iOS handles quietly for you. I shipped all three only after hitting each one in testing.</p>
<h3 id="heading-tapping-the-notification-does-nothing">Tapping the Notification Does Nothing</h3>
<p><code>NotificationCompat</code> posts happily, but with no <code>setContentIntent(PendingIntent)</code> there is no tap target, so Android just expands and collapses the notification. Nothing in the documentation shouts this at you.</p>
<pre><code class="language-kotlin">private fun launchIntent(ctx: Context, activityId: String): PendingIntent? {
  // Ask the package manager for our own launcher intent, rather than naming
  // MainActivity. Under Expo's Continuous Native Generation that class name is
  // generated, so hardcoding it breaks on the next prebuild.
  val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
    // SINGLE_TOP: reuse the existing task instead of stacking a second copy.
    // CLEAR_TOP: drop anything above it, so the user lands on the tracking screen.
    ?.apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP }
    ?: return null

  return PendingIntent.getActivity(
    ctx,
    // The request code. Keyed per activity so two concurrent deliveries get
    // two distinct PendingIntents rather than silently sharing one.
    notificationIdFor(activityId),
    intent,
    // FLAG_IMMUTABLE is mandatory on Android 12+; omit it and this throws.
    // FLAG_UPDATE_CURRENT refreshes the extras of the existing PendingIntent
    // rather than handing back a stale one from an earlier delivery.
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
  )
}
</code></pre>
<p><a href="https://developer.android.com/reference/android/app/PendingIntent#FLAG_IMMUTABLE"><code>FLAG_IMMUTABLE</code></a> is required on Android 12 and later. Verify the result with <code>adb shell dumpsys notification</code>, which should show <code>contentIntent=PendingIntent{... startActivity}</code>.</p>
<h3 id="heading-a-push-updates-the-notification-but-not-your-interface">A Push Updates the Notification But Not Your Interface</h3>
<p>The service is a separate entry point from your JavaScript state, so a push arriving while the app is open leaves the app showing a stale step. The fix is a static bridge, set when a module instance exists and cleared when it does not.</p>
<pre><code class="language-kotlin">companion object {
  // Static, because the FCM service cannot reach a module INSTANCE. @Volatile
  // because the service runs on a different thread than the one that sets this.
  // Nullable because, most of the time, there is no live module to emit into.
  @Volatile private var pushEmitter: ((Map&lt;String, String&gt;) -&gt; Unit)? = null

  // Safe to call from anywhere. The `?.` is the entire "is the app alive?" check.
  fun emitPush(data: Map&lt;String, String&gt;) { pushEmitter?.invoke(data) }
}

override fun definition() = ModuleDefinition {
  // Declares the events JS may subscribe to. Emitting an undeclared name throws.
  Events("onFcmTokenReceived", "onDeliveryPush")

  // Register the bridge when a module instance exists...
  OnCreate  { pushEmitter = { data -&gt; sendEvent("onDeliveryPush", data) } }
  // ...and tear it down when it does not. Skip this and you leak a closure
  // holding a dead React context, then crash on the next push.
  OnDestroy { pushEmitter = null }
  // ...
}
</code></pre>
<p>The service calls <code>DroptrackLiveModule.emitPush(d)</code> after <code>notify()</code>. It does nothing when the app is dead, in which case the notification still updates and there's simply nothing to sync. This is the Android analogue of the iOS foreground resync.</p>
<h3 id="heading-a-cold-started-app-comes-up-empty">A Cold-started App Comes Up Empty</h3>
<p>iOS recovers from <code>Activity.activities</code>. Android has no such store, so a killed app reopened by tapping its own notification shows "Not tracking". Persist the delivery to <a href="https://developer.android.com/reference/android/content/SharedPreferences"><code>SharedPreferences</code></a> on every <code>notify()</code>, then read it back.</p>
<pre><code class="language-kotlin">// Same JS name as the iOS implementation, so App.tsx never branches on platform.
AsyncFunction("getRunningActivities") {
  val ctx = context ?: return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()

  // Our stand-in for iOS's Activity.activities: the record written on notify().
  val active = DeliveryNotifier.activeDelivery(ctx)
    ?: return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()
  val activityId = active["activityId"] as String

  // Phantom guard: trust the system's notification list over our own record.
  if (!DeliveryNotifier.isActive(ctx, activityId)) {
    DeliveryNotifier.clear(ctx)
    return@AsyncFunction emptyList&lt;Map&lt;String, Any?&gt;&gt;()
  }

  // Repopulate the per-process map, or a later update/end throws after cold start.
  deliveries[activityId] = DeliveryInfoRecord().apply { orderId = active["orderId"] as String }
  return@AsyncFunction listOf(active)
}
</code></pre>
<p>Two subtleties here I only found by breaking them. Guard the rehydration with <a href="https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications()"><code>getActiveNotifications()</code></a>. Without that check, a stale record produces an uncancellable phantom delivery whose Cancel button throws <code>ActivityNotFoundException</code>. And make <code>endDelivery</code> forgiving. If the activity isn't in the in-memory map because you cold-started, still cancel the notification and clear the state rather than throwing. Tearing something down should never fail because you've forgotten about it.</p>
<p>Because the resync effect in <code>App.tsx</code> is cross-platform, implementing Android's <code>getRunningActivities()</code> lights up the existing cold-start path with no JavaScript changes. That's the payoff of keeping one API across two backends.</p>
<h2 id="heading-how-to-script-the-simulators-and-devices">How to Script the Simulators and Devices</h2>
<p>Two small tools saved more time than any feature.</p>
<p>The iOS simulator has no scriptable tap. There's no <code>uiautomator</code> equivalent, and synthetic clicks need macOS accessibility grants. So I added a development-only deep-link driver, fifteen lines, stripped from release builds.</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  // Dead code in release builds: the bundler strips the whole effect body.
  if (!__DEV__) return;

  // "droptrack://drive/next" -&gt; "next" -&gt; call actions.next().
  // actionsRef, not actions: the listener is registered once, so a plain
  // closure would capture the first render's handlers forever.
  const run = (url: string) =&gt; actionsRef.current[url.split("/").pop() ?? ""]?.();

  // Case 1: app already running. simctl openurl fires this event.
  const sub = Linking.addEventListener("url", ({ url }) =&gt; run(url));

  // Case 2: devicectl --payload-url COLD-STARTS the app: the URL arrives as the
  // initial URL and never fires the 'url' event. Handle both or device
  // automation silently does nothing while simulator automation works.
  void Linking.getInitialURL().then((url) =&gt; url &amp;&amp; run(url));

  return () =&gt; sub.remove();
}, []);
</code></pre>
<pre><code class="language-shell"># Drives the running app one step forward, with no tap and no accessibility grant.
xcrun simctl openurl booted "com.fasarticle.droptrack://drive/next"
</code></pre>
<p>For the push half I built a small web dispatcher. A browser cannot reach APNs or FCM, because of HTTP/2, missing cross-origin headers, and the fact that signing keys must never leave the server. So it talks to a zero-dependency local Node.js signing server that scrapes push tokens off the device console, streams them to the browser with server-sent events, and sends to whichever platform you select.</p>
<pre><code class="language-text">iPhone  --NSLog--&gt; devicectl --console --+
Android --Log.i--&gt; adb logcat -----------+
                                         | scrape
browser &lt;--SSE /events-- dispatch-server (127.0.0.1:8787)
   \--POST /push--------&gt; apns.mjs / fcm.mjs --&gt; APNs / FCM --&gt; device
</code></pre>
<p>Pick a step, pick a courier, and press one button. It turns a two-minute test into a ten-second one, and it made the Samsung investigation below possible at all.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/ac41eefe-7672-4668-a26d-2e375879621b.png" alt="The DropTrack web dispatcher, one control plane for iOS and Android pushes" style="display:block;margin:0 auto" width="900" height="1546" loading="lazy">

<h2 id="heading-how-ios-and-android-compare">How iOS and Android Compare</h2>
<table>
<thead>
<tr>
<th></th>
<th>iOS Live Activity</th>
<th>Android Live Update</th>
</tr>
</thead>
<tbody><tr>
<td>Introduced</td>
<td>iOS 16.1 and 16.2</td>
<td>Android 16 (API 36), promotion in 36.1</td>
</tr>
<tr>
<td>Interface</td>
<td>Custom SwiftUI in a widget extension</td>
<td>System <code>ProgressStyle</code> template, custom <code>RemoteViews</code> not allowed when promoted</td>
</tr>
<tr>
<td>Surfaces</td>
<td>Lock screen and Dynamic Island, plus Mac via Continuity</td>
<td>Shade, status-bar chip, promoted lock-screen slot</td>
</tr>
<tr>
<td>Update while app closed</td>
<td>Per-activity APNs token. The system updates the widget</td>
<td>Re-post the same notification id. Remote means an FCM data message that wakes your service</td>
</tr>
<tr>
<td>Progress bar</td>
<td>Hand-built, an <code>HStack</code> of capsules</td>
<td><code>ProgressStyle</code> segments and points</td>
</tr>
<tr>
<td>Promotion rules</td>
<td>Automatic once started</td>
<td>Permission, request, ongoing, title, allowed style, importance above <code>MIN</code>, not colorized</td>
</tr>
<tr>
<td>Push auth</td>
<td>ES256 <code>.p8</code> key, HTTP/2</td>
<td>Service-account RS256 to OAuth token, plain HTTPS</td>
</tr>
<tr>
<td>Payload decoding</td>
<td>The system decodes into your <code>Codable</code>. A mismatch drops silently</td>
<td>You parse it yourself in your service</td>
</tr>
<tr>
<td>Cold-start recovery</td>
<td><code>Activity.activities</code>, owned by the system</td>
<td>You persist and read it back yourself</td>
</tr>
<tr>
<td>Tap to open</td>
<td>Free</td>
<td>You must attach a content <code>PendingIntent</code></td>
</tr>
<tr>
<td>Delivery guarantee</td>
<td>System-owned and reliable</td>
<td>Data message, droppable under Doze or force-kill</td>
</tr>
</tbody></table>
<h2 id="heading-the-samsung-reality-check">The Samsung Reality Check</h2>
<p>Version numbers lie, and Samsung is where they lie loudest. I tested by hand across three real Galaxy devices using <a href="https://developer.samsung.com/remote-test-lab">Samsung Remote Test Lab</a>, whose Remote Debug Bridge turns out to be a full local <code>adb</code> tunnel to a phone in Korea. The entire emulator automation playbook (granting permissions, tapping, taking screenshots, and reading <code>dumpsys</code>) works unchanged against remote hardware.</p>
<p>Here's what the same unmodified APK did on each:</p>
<table>
<thead>
<tr>
<th>Device</th>
<th>One UI / SDK</th>
<th><code>canPostPromotedNotifications()</code></th>
<th>What actually showed</th>
</tr>
</thead>
<tbody><tr>
<td>Galaxy S25 Ultra</td>
<td>8.0 / 36.0</td>
<td><code>false</code></td>
<td>Nothing. No chip, no promoted card</td>
</tr>
<tr>
<td>Galaxy S26 Ultra</td>
<td>8.5 / 36.1</td>
<td><code>true</code></td>
<td>Top-of-shade pinning and a status-bar icon</td>
</tr>
<tr>
<td>Galaxy A37 (mid-range)</td>
<td>8.5 / 36.1</td>
<td><code>true</code></td>
<td>Same as the S26</td>
</tr>
</tbody></table>
<p>Two findings come out of that table.</p>
<h4 id="heading-1-on-the-base-36-device-the-two-capability-checks-disagree">1. On the base-36 device, the two capability checks disagree</h4>
<p>The S25 Ultra runs base 36, which has no promotion pipeline, so nothing promotes. But <code>hasPromotableCharacteristics()</code> returns <code>true</code> there (Samsung backported some framework pieces) while <code>canPostPromotedNotifications()</code> returns <code>false</code>. A base-36 Pixel emulator returns <code>false</code> for both. So you can't infer one check from the other. Detect both at runtime, and trust <code>canPostPromotedNotifications()</code> for whether promotion will actually happen.</p>
<h4 id="heading-2-on-the-361-devices-promotion-works-but-only-partly">2. On the 36.1 devices, promotion works but only partly</h4>
<p>Both the S26 Ultra and the A37 genuinely grant <code>FLAG_PROMOTED_ONGOING</code> to the unmodified APK, which you can confirm in <code>dumpsys</code>. You get top-of-shade pinning and the status-bar icon. You do <em>not</em> get the chip pill, the lock-screen card, or a Now Bar entry, even though the promotion succeeded.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/71557971-c9d3-4936-ab7d-1ee9b2b779ef.png" alt="DropTrack promoted to the top of the shade on Samsung One UI 8.5" style="display:block;margin:0 auto" width="640" height="1386" loading="lazy">

<p>That "only partly" was the mystery, until I found where the missing surfaces went. In Settings, under Lock screen and Always On Display, there's a page called Live notifications (searchable as "live notification", notably not as "Now bar"). It promises exactly the surfaces I was missing: the lock screen, the status bar, and the top of the notification panel, illustrated with the Now Bar pill. And directly below that promise sits a fixed, six-app allowlist: Audio broadcast, Emergency sharing, Google Finance, Maps, Media player, and Sports from Google.</p>
<p>DropTrack isn't on that list. Yet its promoted delivery was live on that very device at that very second, and the page's own "Not seeing Live notifications?" checklist (three notification permissions) was fully satisfied. The feature was working. Samsung simply doesn't offer its best surfaces to apps outside the six.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c84fbe8c8c80534346db05/5b2ad0b1-7c3d-4b96-ab37-8d82c72b09fb.png" alt="Samsung's Live notifications settings page showing a fixed six-app allowlist" style="display:block;margin:0 auto" width="640" height="1386" loading="lazy">

<p>So the screenshot-backed conclusion for these units is this: One UI 8.5 accepts Google's promotion contract and ships the minor surfaces to any app, but reserves the headline ones for a hardcoded list. This reflects those specific devices at the time of testing, and Samsung may change it.</p>
<p>It leaves the three platforms in three different places. Apple offers a public API on every device. Google offers a public API on its own hardware. Samsung offers the framework to everyone but keeps the best stage by invitation.</p>
<h2 id="heading-the-demo-repository">The Demo Repository</h2>
<p>Everything in this handbook is one working project, <a href="https://github.com/FastheDeveloper/LiveActivity">DropTrack</a>, released under the MIT licence. It's not a snippet dump. It's a single React Native app whose one delivery-tracking feature reaches down through five layers. The point of reading it is to see how those layers connect rather than how any one of them looks in isolation.</p>
<p>Here's what lives in the repo, layer by layer:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Where</th>
<th>What is there</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Mobile app</strong></td>
<td><code>App.tsx</code>, <code>delivery.ts</code></td>
<td>The React Native UI and the one cross-platform TypeScript API (<code>startDelivery</code> / <code>updateDelivery</code> / <code>endDelivery</code>) that both native sides implement</td>
</tr>
<tr>
<td><strong>iOS native</strong></td>
<td><code>modules/droptrack-live/ios/</code>, <code>targets/widgets/</code></td>
<td>The Swift ActivityKit bridge and the SwiftUI widget with its four presentations, the segmented bar, and the reassignment treatment</td>
</tr>
<tr>
<td><strong>Android native</strong></td>
<td><code>modules/droptrack-live/android/</code></td>
<td>The Kotlin <code>NotificationCompat</code> builder, the promotion logic, and the <code>FirebaseMessagingService</code> that re-posts from a push</td>
</tr>
<tr>
<td><strong>Web</strong></td>
<td><code>DispatcherConsole.tsx</code>, <code>src/dispatchClient.ts</code></td>
<td>The Expo-web dispatcher console, a browser UI for composing and firing pushes at a chosen device</td>
</tr>
<tr>
<td><strong>Backend</strong></td>
<td><code>scripts/</code></td>
<td>The APNs client (about 100 lines) and the FCM client (about 80 lines) written from scratch with no push libraries, plus the local signing server that ties the console to real devices</td>
</tr>
</tbody></table>
<p>A few things make it worth cloning rather than skimming:</p>
<ul>
<li><p><strong>It genuinely runs on all three surfaces:</strong> The same <code>DeliveryState</code> object drives a SwiftUI Live Activity, an Android promoted notification, and a web console, so you can watch one API produce three very different results.</p>
</li>
<li><p><strong>The push clients have no dependencies:</strong> <code>scripts/apns.mjs</code> and <code>scripts/fcm.mjs</code> use only Node built-ins, so you can read the entire APNs and FCM path end to end without unpacking a library. Both have small test files next to them.</p>
</li>
<li><p><strong>Every gotcha in this article is written up in</strong> <a href="https://github.com/FastheDeveloper/LiveActivity/blob/main/GOTCHAS.md"><code>GOTCHAS.md</code></a><strong>:</strong> The long checklist that used to live in this section now lives there, next to the code it refers to, alongside <code>DEVLOG.md</code> (how the build unfolded) and <code>ARTICLE_NOTES.md</code>.</p>
</li>
<li><p><strong>It's safe to fork:</strong> The Firebase service account, the <code>google-services.json</code>, and the APNs <code>.p8</code> key are all gitignored, so nothing sensitive is in the history. The <code>README.md</code> lists exactly which of those you supply to run the push phase yourself.</p>
</li>
</ul>
<p>Clone it, run the app on a simulator, open the web console, and push an update to your own device. That loop is the fastest way to make everything above concrete.</p>
<h2 id="heading-what-to-know-before-you-start">What to Know Before You Start</h2>
<p><strong>The product feature is identical, and the platform contracts are opposites.</strong> iOS gives you a system-owned widget and updates it for you. Android gives you a notification and makes you the update engine, down to re-posting it from a background service on every message. Design your module's seam accordingly. The same three-function API can hide wildly different machinery, and that is exactly what a good native module is for.</p>
<p><strong>The silent failures are the tax.</strong> Almost every hard bug in this project, from the mismatched widget struct, to the missing entitlement, the wrong epoch, the un-promoted notification, and the <code>notification</code>-instead-of-<code>data</code> message, fails with zero errors. So instrument aggressively. Log promotable characteristics. Verify entitlements survived signing. Probe push auth with fake tokens before you involve a device. Always confirm on the real surface rather than trusting a <code>200</code>.</p>
<p><strong>Version numbers lie, so detect features instead.</strong> Android 16 is two releases. Samsung's Android 16 is a third. <code>canPostPromotedNotifications()</code> and <code>hasPromotableCharacteristics()</code> can disagree on the same build. Check both at runtime, and never infer one from the other.</p>
<p><strong>The system owns the activity, not your process.</strong> On iOS the activity outlives your variable, your React state, and your process. On Android the notification outlives your process too, but nothing recovers it for you. Both platforms punish you for assuming the id in your <code>useState</code> is the source of truth.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a complete picture of both platforms. You built one TypeScript API over two native backends, a SwiftUI widget with four presentations, a Kotlin notification that satisfies Android's promotion contract, and two push clients written from scratch with no libraries. You also know the failure modes that produce no error message, which is most of them.</p>
<p>Three things are worth exploring next:</p>
<ol>
<li><p><strong>Push-to-start, on iOS 17.2 and later.</strong> You can start a Live Activity from a push, with no app launch at all. The token is per-app rather than per-activity, which rewrites the token plumbing described above.</p>
</li>
<li><p><strong>Broadcast channels, on iOS 18.</strong> One push updates an activity for many users, which suits live scores. Every push in this handbook targets a single token.</p>
</li>
<li><p><strong>An Android foreground service.</strong> It narrows the Doze and force-kill delivery gap. It won't close it, but it makes long-running deliveries more durable than a bare data message.</p>
</li>
</ol>
<p>The interesting part was never <code>Activity.request()</code>. It was everything the two platforms decline to tell you when you get it wrong.</p>
<h2 id="heading-sources-and-further-reading">Sources and Further Reading</h2>
<p>Apple, ActivityKit and APNs:</p>
<ul>
<li><p><a href="https://developer.apple.com/documentation/activitykit">ActivityKit</a> and <a href="https://developer.apple.com/documentation/activitykit/activityattributes"><code>ActivityAttributes</code></a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities">Displaying live data with Live Activities</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications">Starting and updating Live Activities with ActivityKit push notifications</a></p>
</li>
<li><p><a href="https://developer.apple.com/design/human-interface-guidelines/live-activities">Live Activities, Human Interface Guidelines</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment">The <code>aps-environment</code> entitlement</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns">Sending notification requests to APNs</a>, <a href="https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns">Establishing a token-based connection to APNs</a>, and <a href="https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns">Handling notification responses from APNs</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/foundation/date"><code>Date</code>, and the 2001 reference date</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/widgetkit">WidgetKit</a></p>
</li>
</ul>
<p>Android and Firebase:</p>
<ul>
<li><p><a href="https://developer.android.com/about/versions/16/features/progress-centric-notifications">Progress-centric notifications in Android 16</a> and <a href="https://developer.android.com/about/versions/16">Android 16 overview</a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/Notification.ProgressStyle"><code>Notification.ProgressStyle</code></a> and <a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.ProgressStyle"><code>NotificationCompat.ProgressStyle</code></a></p>
</li>
<li><p><a href="https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder"><code>NotificationCompat.Builder</code></a>, where <code>setRequestPromotedOngoing</code> and <code>setShortCriticalText</code> are backported</p>
</li>
<li><p><a href="https://developer.android.com/reference/android/os/Build.VERSION#SDK_INT_FULL"><code>Build.VERSION.SDK_INT_FULL</code></a> and <a href="https://developer.android.com/reference/android/os/Build.VERSION_CODES_FULL"><code>Build.VERSION_CODES_FULL</code></a>, the 36 versus 36.1 distinction</p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/NotificationManager#canPostPromotedNotifications()"><code>NotificationManager.canPostPromotedNotifications()</code></a>, <a href="https://developer.android.com/reference/android/app/Notification#hasPromotableCharacteristics()"><code>Notification.hasPromotableCharacteristics()</code></a>, and <a href="https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications()"><code>getActiveNotifications()</code></a></p>
</li>
<li><p><a href="https://developer.android.com/reference/android/app/PendingIntent#FLAG_IMMUTABLE"><code>PendingIntent.FLAG_IMMUTABLE</code></a> and <a href="https://developer.android.com/reference/android/content/SharedPreferences"><code>SharedPreferences</code></a></p>
</li>
<li><p><a href="https://developer.android.com/jetpack/androidx/releases/core"><code>androidx.core</code> release notes</a></p>
</li>
<li><p><a href="https://developer.android.com/training/monitoring-device-state/doze-standby">Doze and App Standby</a></p>
</li>
<li><p><a href="https://firebase.google.com/docs/cloud-messaging">Firebase Cloud Messaging</a>, <a href="https://firebase.google.com/docs/cloud-messaging/android/receive">Receive messages in an Android app</a>, <a href="https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessagingService"><code>FirebaseMessagingService</code></a>, <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send"><code>projects.messages.send</code></a>, and the <a href="https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode">FCM <code>ErrorCode</code> reference</a></p>
</li>
<li><p><a href="https://developers.google.com/identity/protocols/oauth2/service-account">Using OAuth 2.0 for server to server applications</a> and the <a href="https://developers.google.com/android/guides/google-services-plugin"><code>google-services</code> Gradle plugin</a></p>
</li>
</ul>
<p>Apps that document their own Live Activity, cited in "Where You Have Already Seen This Feature":</p>
<ul>
<li><p><a href="https://apps.apple.com/us/app/chowdeck-food-delivery/id1530676376">Chowdeck</a>, <a href="https://apps.apple.com/us/app/espn-live-sports-scores/id317469184">ESPN</a>, <a href="https://apps.apple.com/us/app/mlb/id493619333">MLB</a>, <a href="https://apps.apple.com/us/app/fotmob-soccer-live-scores/id488575683">FotMob</a>, <a href="https://apps.apple.com/us/app/carrot-weather-alerts-radar/id961390574">CARROT Weather</a>, and <a href="https://apps.apple.com/us/app/structured-daily-planner-todo/id1499198946">Structured</a>, all via their App Store listings</p>
</li>
<li><p><a href="https://flighty.com/help/live-activities-widgets">Flighty's Live Activities help page</a></p>
</li>
<li><p><a href="https://support.apple.com/guide/apple-sports-app/follow-games-in-real-time-apdc0cb7ad64/web">Apple Sports, following games in real time</a></p>
</li>
<li><p>MacRumors on <a href="https://www.macrumors.com/2023/05/02/uber-eats-live-activities/">Uber Eats, May 2023</a> and <a href="https://www.macrumors.com/2023/12/04/doordash-rolling-out-live-activities/">DoorDash, December 2023</a></p>
</li>
<li><p>MacStories, <a href="https://www.macstories.net/reviews/ios-16-1-and-apps-with-live-activities-the-macstories-roundup-part-1/">the iOS 16.1 Live Activities roundup</a></p>
</li>
</ul>
<p>React Native, Expo, and tooling:</p>
<ul>
<li><p><a href="https://docs.expo.dev/modules/overview/">Expo Modules API overview</a> and the <a href="https://docs.expo.dev/modules/module-api/">module API reference</a></p>
</li>
<li><p><a href="https://docs.expo.dev/config-plugins/introduction/">Expo config plugins</a> and <a href="https://docs.expo.dev/workflow/continuous-native-generation/">Continuous Native Generation</a></p>
</li>
<li><p><a href="https://github.com/EvanBacon/expo-apple-targets"><code>@bacons/apple-targets</code></a>, which generates the widget extension</p>
</li>
<li><p><a href="https://github.com/software-mansion-labs/expo-live-activity"><code>expo-live-activity</code></a>, the packaged alternative</p>
</li>
<li><p><a href="https://github.com/invertase/notifee">Notifee</a>, archived, and its last release <a href="https://www.npmjs.com/package/@notifee/react-native"><code>@notifee/react-native@9.1.8</code></a></p>
</li>
<li><p>React Native <a href="https://reactnative.dev/docs/appstate"><code>AppState</code></a> and <a href="https://reactnative.dev/docs/linking"><code>Linking</code></a></p>
</li>
<li><p>Node.js <a href="https://nodejs.org/api/crypto.html"><code>crypto</code></a> and <a href="https://nodejs.org/api/http2.html"><code>http2</code></a></p>
</li>
<li><p><a href="https://datatracker.ietf.org/doc/html/rfc7519">RFC 7519, JSON Web Token</a> and <a href="https://datatracker.ietf.org/doc/html/rfc7518">RFC 7518, JSON Web Algorithms</a>, which define the ES256 and RS256 signature formats</p>
</li>
<li><p><a href="https://developer.samsung.com/remote-test-lab">Samsung Remote Test Lab</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What “Production-Ready” Actually Means in Flutter  ]]>
                </title>
                <description>
                    <![CDATA[ I've been building Flutter apps for a few years now, and I still remember the first time I shipped something I was genuinely proud of. It had a clean UI, smooth animations, and every flow worked exact ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-production-ready-actually-means-in-flutter/</link>
                <guid isPermaLink="false">6a206c1a2a223bf98b13f071</guid>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iOS ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gidudu Nicholas ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 18:02:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/82dd0caa-f57c-447b-9a20-4e49f40898f7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I've been building Flutter apps for a few years now, and I still remember the first time I shipped something I was genuinely proud of. It had a clean UI, smooth animations, and every flow worked exactly as I intended. I handed it to real users and felt good about it.</p>
<p>Within a week, the bug reports started coming in.</p>
<p>Screens freezing, API calls failing silently, Users losing form data they'd spent ten minutes filling out, one user reported the app just... stopped responding after they walked through a tunnel on the subway. I had never tested that. Why would I? It worked fine on my machine.</p>
<p>That experience taught me something I wish someone had told me earlier: there's a real gap between an app that works and an app that is production-ready.</p>
<p>I've now shipped multiple Flutter apps, and I've hit almost every wall this article covers — network failures, memory leaks, state management that made sense at first and became a nightmare at scale, and performance that felt fine in development and janked badly on a user's old device.</p>
<p>This article is everything I've learned from those experiences. Not theory, but actual patterns that came from actual problems.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-it-works-on-my-machine-is-dangerous-in-flutter">Why "It Works on My Machine" is Dangerous in Flutter</a></p>
</li>
<li><p><a href="#heading-development-vs-production-what-actually-changes">Development vs Production: What Actually Changes</a></p>
</li>
<li><p><a href="#heading-network-reliability-and-defensive-request-handling">Network Reliability and Defensive Request Handling</a></p>
</li>
<li><p><a href="#heading-retry-logic-and-the-production-request-lifecycle">Retry Logic and the Production Request Lifecycle</a></p>
</li>
<li><p><a href="#heading-offline-support-and-local-persistence">Offline Support and Local Persistence</a></p>
</li>
<li><p><a href="#heading-state-management-at-scale">State Management at Scale</a></p>
</li>
<li><p><a href="#heading-widget-rebuilds-and-rendering-performance">Widget Rebuilds and Rendering Performance</a></p>
</li>
<li><p><a href="#heading-async-pitfalls-and-the-disposed-widget-problem">Async Pitfalls and the Disposed Widget Problem</a></p>
</li>
<li><p><a href="#heading-memory-leaks-and-lifecycle-management">Memory Leaks and Lifecycle Management</a></p>
</li>
<li><p><a href="#heading-observability-and-crash-reporting">Observability and Crash Reporting</a></p>
</li>
<li><p><a href="#heading-testing-production-flutter-apps">Testing Production Flutter Apps</a></p>
</li>
<li><p><a href="#heading-architecture-and-long-term-maintainability">Architecture and Long-Term Maintainability</a></p>
</li>
<li><p><a href="#heading-end-to-end-example-a-production-grade-profile-feature">End-to-End Example: a Production-Grade Profile Feature</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-it-works-on-my-machine-is-dangerous-in-flutter">Why "It Works on My Machine" is Dangerous in Flutter</h2>
<p>Here's what your development environment looks like: fast internet, a powerful machine or emulator, a clean app state on every hot reload, APIs that respond in milliseconds, and you, a careful developer who deliberately follows the happy path.</p>
<p>Here's what your users look like: spotty mobile data, old mid-range devices, six other apps running in the background, and zero patience for a screen that stops loading without explanation.</p>
<p>That gap is where production bugs live.</p>
<p>The tricky part is that Flutter makes development feel so smooth that it's easy to mistake "works on my machine" for "ready for users."</p>
<p>I've made that mistake. Most Flutter developers I know have made it too. The app looks polished. The animations are butter. You demo it to a colleague, and everything goes perfectly. Then someone tries to use it while commuting on patchy mobile data, and the whole thing falls apart.</p>
<p>Production-ready Flutter engineering starts with accepting one uncomfortable truth: things will go wrong. Networks will fail. Devices will run low on memory. Users will background your app at the worst possible moment. The question isn't whether these things happen, but rather whether your app handles them gracefully when they do.</p>
<h2 id="heading-development-vs-production-what-actually-changes">Development vs Production: What Actually Changes</h2>
<p>I want to be specific here because "production is different" is easy to say and hard to internalize until you've been burned by it.</p>
<p>In development, a failed API call is something you notice immediately in your terminal, fix in a few minutes, and move on from. In production, that same failed API call happens to a user who sees a blank screen, has no idea why, waits a few seconds, and then either retries or uninstalls. You find out three days later when someone leaves a one-star review.</p>
<p>In development, a widget that rebuilds unnecessarily costs a few milliseconds you never feel. In production, on an older or lower-powered device with several apps running in the background, that same unnecessary rebuild is the thing that pushes a frame over the 16ms budget and creates a stutter the user notices.</p>
<p>In development, a memory leak that adds 5MB of usage over ten minutes is invisible. I once had a leak in a chat feature, an undisposed stream subscription that was completely undetectable during testing. In production, after an hour of use on a low-memory device, the OS started killing the app mid-session. Users thought it was crashing randomly. It took me an embarrassingly long time to track down.</p>
<p>The pattern is always the same: problems that are invisible at development scale become significant at production scale, and problems that are minor on development hardware become severe on the hardware your actual users own.</p>
<h2 id="heading-network-reliability-and-defensive-request-handling">Network Reliability and Defensive Request Handling</h2>
<p>If I had to pick one category of bug that has bitten me the most across multiple apps, it would be this one. Mobile networks are genuinely unreliable, and Flutter apps are often written as though they're not.</p>
<p>The most common networking pattern I see (and wrote myself for longer than I'd like to admit) looks like this:</p>
<pre><code class="language-dart">final response = await dio.get('/user');

setState(() {
  user = response.data;
});
</code></pre>
<p>This works perfectly in development. But it has four ways to fail in production:</p>
<ol>
<li><p>The request fails due to a network error, and the exception propagates unhandled</p>
</li>
<li><p>The user navigates away before the response arrives and <code>setState</code> is called on a disposed widget</p>
</li>
<li><p>The API returns unexpected data, and the cast throws at runtime</p>
</li>
<li><p>The request hangs indefinitely, and the user stares at a spinner forever</p>
</li>
</ol>
<p>I've hit all four. Here's a version that handles them:</p>
<pre><code class="language-dart">Future&lt;void&gt; loadUser(String userId) async {
  setState(() {
    isLoading = true;
    error = null;
  });

  try {
    final response = await dio.get('/user/$userId');

    // mounted checks whether this widget is still in the widget tree.
    // If the user navigated away while the request was running,
    // mounted is false. Calling setState on a disposed widget throws
    // an error — this one line prevents that entire class of crash.
    if (!mounted) return;

    setState(() {
      user = User.fromJson(response.data as Map&lt;String, dynamic&gt;);
      isLoading = false;
    });
  } on DioException catch (e) {
    if (!mounted) return;

    setState(() {
      // Give the user a message that is actually useful.
      // "Something went wrong" is not helpful. Knowing whether
      // they have no internet vs the server failed lets them
      // decide whether to move or wait.
      error = e.type == DioExceptionType.connectionError
          ? 'No internet connection. Please try again.'
          : 'Failed to load profile. Please try again.';
      isLoading = false;
    });
  }
}
</code></pre>
<h3 id="heading-the-three-states-every-screen-needs">The Three States Every Screen Needs</h3>
<p>I used to design screens for the success case and treat loading and error as afterthoughts. That was a mistake. Every screen that fetches remote data needs all three:</p>
<pre><code class="language-dart">@override
Widget build(BuildContext context) {
  // Loading: never leave users staring at a blank screen.
  // A spinner tells them something is happening.
  if (isLoading) {
    return const Center(child: CircularProgressIndicator());
  }

  // Error: show what went wrong and how to recover.
  // A dead end with no retry button is one of the most
  // frustrating things a user can experience.
  if (error != null) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(error!, style: const TextStyle(color: Colors.red)),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () =&gt; loadUser(widget.userId),
            child: const Text('Try again'),
          ),
        ],
      ),
    );
  }

  // Success: show the data.
  return UserProfileView(user: user!);
}
</code></pre>
<p>The error state with a retry button isn't a nice-to-have. It's the difference between a user who recovers from a network hiccup and a user who thinks your app is broken.</p>
<h2 id="heading-retry-logic-and-the-production-request-lifecycle">Retry Logic and the Production Request Lifecycle</h2>
<p>Mobile networks fail all the time temporarily. A user walks past a dead zone, enters an elevator, or switches from WiFi to mobile data mid-request. The request fails but if retried two seconds later, it would succeed.</p>
<p>Without retry logic, every temporary network failure is a permanent failure from the user's perspective. That's a bad trade.</p>
<pre><code class="language-dart">Future&lt;T&gt; withRetry&lt;T&gt;(
  Future&lt;T&gt; Function() request, {
  int maxAttempts = 3,
  Duration delay = const Duration(seconds: 1),
}) async {
  for (int i = 0; i &lt; maxAttempts; i++) {
    try {
      return await request();
    } catch (e) {
      // On the final attempt, stop retrying and let the
      // error propagate to the caller.
      if (i == maxAttempts - 1) rethrow;

      // Wait before trying again. This gives temporary network
      // issues time to resolve and avoids hammering a server
      // that might already be struggling.
      await Future.delayed(delay);
    }
  }

  throw Exception('Retry failed');
}
</code></pre>
<p>Usage is straightforward:</p>
<pre><code class="language-dart">final user = await withRetry(
  () =&gt; dio.get('/user/$userId'),
  maxAttempts: 3,
  delay: const Duration(seconds: 2),
);
</code></pre>
<p>For production apps with heavier traffic, look at <code>dio_smart_retry</code>. This implements exponential backoff, and the delay doubles between each retry, which is much more considerate of server load during actual outages.</p>
<h2 id="heading-offline-support-and-local-persistence">Offline Support and Local Persistence</h2>
<p>I learned to take offline support seriously after an embarrassing support ticket. A user had filled out a long onboarding form (15 fields), which took them several minutes, and hit submit on a spotty connection. The request failed. The form cleared. All their data was gone. They were furious, and honestly, they had every right to be.</p>
<p>The goal of offline support is not to replicate every feature without internet. It's to make sure users don't lose progress and don't hit dead ends.</p>
<h3 id="heading-caching-remote-data">Caching Remote Data</h3>
<p>The strategy here is simple: every time a network request succeeds, save the result locally. Then, if the next request fails, serve what you saved last time instead of showing an error screen.</p>
<pre><code class="language-dart">class UserRepository {
  final Dio _dio;
  final Box _cache; // Hive box

  UserRepository(this._dio, this._cache);

  Future&lt;User&gt; getUser(String userId) async {
    try {
      final response = await _dio.get('/user/$userId');
      final user = User.fromJson(response.data as Map&lt;String, dynamic&gt;);

      // Save fresh data to the cache every time a request succeeds.
      // This means the next request can fall back to this
      // if the network is unavailable.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } catch (e) {
      // Network failed. See if we have something cached.
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        // Stale data is better than an error screen.
        // The user sees something useful even without internet.
        return User.fromJson(Map&lt;String, dynamic&gt;.from(cached));
      }

      // Nothing cached. We have no choice but to surface the error.
      rethrow;
    }
  }
}
</code></pre>
<h3 id="heading-preserving-user-input">Preserving User Input</h3>
<p>This is the fix for the onboarding ticket I mentioned:</p>
<pre><code class="language-dart">// Save whatever the user has typed whenever the field changes.
_contentController.addListener(() async {
  await _cache.put('draft_post', _contentController.text);
});

// When the screen opens, restore any saved draft.
@override
void initState() {
  super.initState();
  final draft = _cache.get('draft_post') as String?;
  if (draft != null &amp;&amp; draft.isNotEmpty) {
    _contentController.text = draft;
  }
}

// Clear the draft once the user successfully submits.
Future&lt;void&gt; _submit() async {
  await _repository.createPost(_contentController.text);
  await _cache.delete('draft_post');
}
</code></pre>
<p>Three lines of code that save users from losing their work. This is worth doing in any form that takes more than a minute to fill out.</p>
<p>Packages I use for local persistence:</p>
<ol>
<li><p><strong>Hive</strong> for simple key-value storage</p>
</li>
<li><p><strong>Isar</strong> when I need more powerful queries</p>
</li>
<li><p><strong>sqflite</strong> for relational data</p>
</li>
<li><p><strong>shared_preferences</strong> strictly for user settings, not for anything substantial</p>
</li>
</ol>
<h2 id="heading-state-management-at-scale">State Management at Scale</h2>
<p><code>setState</code> is fine. I want to say that clearly because there's a tendency in the Flutter community to treat it like it's always wrong. For local, simple UI state – a button toggling, a form field showing validation — <code>setState</code> is exactly the right tool.</p>
<p>The problems start when you use it for state that multiple widgets depend on, or for async operations, or for anything that needs to survive navigation. I've done all of these. Here's what goes wrong:</p>
<pre><code class="language-dart">// This setState call lives high in the widget tree.
// Every widget below it rebuilds — including expensive ones
// that have nothing to do with this state change.
setState(() {
  currentUser = updatedUser;
});
</code></pre>
<p>As the app grows, this gets worse. Rebuilds spread. Side effects happen in unexpected order. You start spending more time debugging state than building features.</p>
<h3 id="heading-moving-to-riverpod">Moving to Riverpod</h3>
<p>After hitting these walls in my second app, I switched to Riverpod and haven't looked back. The core idea is simple: state lives outside widgets, and widgets subscribe to exactly the state they need.</p>
<pre><code class="language-dart">@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  AsyncValue&lt;User&gt; build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future&lt;void&gt; _load() async {
    state = const AsyncValue.loading();

    // AsyncValue.guard runs the future and wraps the result
    // in AsyncValue.data on success or AsyncValue.error on failure.
    // It saves you from writing try/catch every single time.
    state = await AsyncValue.guard(
      () =&gt; ref.read(userRepositoryProvider).getUser(userId),
    );
  }

  Future&lt;void&gt; refresh() =&gt; _load();
}
</code></pre>
<p>In the widget:</p>
<pre><code class="language-dart">@override
Widget build(BuildContext context) {
  // ref.watch subscribes this widget to the notifier.
  // It rebuilds only when userAsync changes — not when
  // unrelated state elsewhere in the app changes.
  final userAsync = ref.watch(userNotifierProvider(widget.userId));

  return userAsync.when(
    // when() forces you to handle loading, error, and data.
    // Miss one and it's a compile error, not a runtime surprise.
    loading: () =&gt; const CircularProgressIndicator(),
    error: (e, _) =&gt; Text('Error: $e'),
    data: (user) =&gt; UserProfileView(user: user),
  );
}
</code></pre>
<p>The part I appreciate most: <code>when()</code> makes it a compile error to forget the loading or error state. The compiler enforces what I used to forget.</p>
<h3 id="heading-immutable-state">Immutable State</h3>
<p>One thing that burned me hard in a real-time chat feature: a mutable list shared across multiple parts of the app.</p>
<pre><code class="language-dart">List&lt;Message&gt; messages = [];

// Later, in different places:
messages.add(newMessage);       // socket handler
messages.removeAt(0);          // pagination
messages.insert(0, pinned);    // push notification handler
</code></pre>
<p>When a message appeared twice, or disappeared at random, tracing which mutation caused it was genuinely painful. The fix is to never mutate and always create a new list:</p>
<pre><code class="language-dart">// The old list is unchanged. The new state is a new list.
// Every change is explicit and traceable.
state = [...state, newMessage];
</code></pre>
<p>It feels like a small thing until you spend two hours debugging a mutation bug. Then it feels very important.</p>
<h2 id="heading-widget-rebuilds-and-rendering-performance">Widget Rebuilds and Rendering Performance</h2>
<p>Flutter is fast. But unnecessary rebuilds accumulate, and on low-end devices the accumulation is noticeable.</p>
<h3 id="heading-const-widgets-skip-rebuilds-entirely">Const Widgets Skip Rebuilds Entirely</h3>
<p>The <code>const</code> keyword tells Dart this widget can be created at compile time and reused indefinitely. Any widget whose content will never change is a candidate.</p>
<pre><code class="language-dart">// Without const: a new Text instance is created on every
// rebuild of the parent, even though the content never changes.
Text('Welcome to the app')

// With const: Flutter reuses the same instance.
// No rebuild work, no allocation.
const Text('Welcome to the app')
</code></pre>
<p>This sounds like a small thing. In a large widget tree with many static elements, the cumulative effect is real. Make it a habit.</p>
<h3 id="heading-keep-the-rebuild-scope-small">Keep the Rebuild Scope Small</h3>
<p>When <code>setState</code> lives high in the widget tree, every widget below it rebuilds — even ones that have nothing to do with the state that changed. The fix is to push state as far down the tree as possible, ideally into its own extracted widget.</p>
<pre><code class="language-dart">// The problem: counter lives in the parent, so every
// setState call rebuilds the entire subtree — including
// ExpensiveListWidget, which has nothing to do with the counter.
class _BadExampleState extends State&lt;BadExample&gt; {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_counter'),
        ElevatedButton(
          onPressed: () =&gt; setState(() =&gt; _counter++),
          child: const Text('Increment'),
        ),
        const ExpensiveListWidget(), // rebuilds for no reason
      ],
    );
  }
}
</code></pre>
<p>Now, only that widget rebuilds when the count changes. <code>ExpensiveListWidget</code> is untouched.</p>
<h3 id="heading-listviewbuilder-for-anything-of-unknown-length">ListView.builder for Anything of Unknown Length</h3>
<p>A <code>Column</code> with a mapped list builds every item upfront regardless of whether it is visible. On a list of 200 items, that is 200 widgets created before the user has scrolled at all.</p>
<pre><code class="language-dart">// This builds every single item widget upfront.
// With 200 items, 200 widgets are created on first render,
// most of which are immediately off-screen.
Column(
  children: items.map((item) =&gt; ItemCard(item: item)).toList(),
)

// This builds only what is visible, plus a small buffer.
// Scrolling through 10,000 items uses the same memory as 10.
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ItemCard(items[index]);
  },
)
</code></pre>
<p><code>ListView.builder</code> isn't an optimization for large lists. It's the correct default for any list of unknown or variable size. I use <code>Column</code> with a mapped list only when I know for certain the list will always be tiny.</p>
<h2 id="heading-async-pitfalls-and-the-disposed-widget-problem">Async Pitfalls and the Disposed Widget Problem</h2>
<p>This is one of those bugs that's completely invisible during development and shows up constantly in production.</p>
<p>The scenario: an async operation starts, the user navigates away before it finishes, and the operation completes and tries to call <code>setState</code> on a widget that no longer exists.</p>
<pre><code class="language-dart">Future&lt;void&gt; _loadData() async {
  final data = await repository.fetchData();

  // If the user navigated away during the await above,
  // this widget is gone. setState throws:
  // "setState() called after dispose()"
  setState(() =&gt; this.data = data );
}
</code></pre>
<p>The fix is one line:</p>
<pre><code class="language-dart">Future&lt;void&gt; _loadData() async {
  final data = await repository.fetchData();

  // mounted is true while the widget is in the tree,
  // false after dispose() has been called.
  if (!mounted) return;

  setState(() =&gt; this.data = data);
}
</code></pre>
<p>I now write this check automatically after every <code>await</code> that leads to a <code>setState</code>. It becomes muscle memory quickly.</p>
<h3 id="heading-never-create-futures-inside-build">Never Create Futures Inside Build</h3>
<p>This is an easy-to-overlook issue. When you create a Future directly inside the <code>build</code> method, a new Future is created on every rebuild — meaning <code>FutureBuilder</code> treats it as a brand new operation each time and resets to the loading state unnecessarily.</p>
<pre><code class="language-dart">// Bad: a new Future is created on every rebuild.
// FutureBuilder sees a different Future each time
// and resets to loading state unnecessarily.
@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: repository.fetchUser(userId), // new Future every build
    builder: (context, snapshot) { ... },
  );
}
</code></pre>
<pre><code class="language-dart">// Good: create the Future once in initState.
// FutureBuilder holds the same reference across rebuilds.
late final Future&lt;User&gt; _userFuture;

@override
void initState() {
  super.initState();
  _userFuture = repository.fetchUser(widget.userId);
}

@override
Widget build(BuildContext context) {
  return FutureBuilder(
    future: _userFuture,
    builder: (context, snapshot) { ... },
  );
}
</code></pre>
<h3 id="heading-move-heavy-work-off-the-ui-thread">Move Heavy Work Off the UI Thread</h3>
<p>Dart renders UI on the main isolate. Anything CPU-intensive that blocks it causes dropped frames.</p>
<pre><code class="language-dart">// Parsing a large API response synchronously on the main isolate
// can block rendering for 50-200ms on slower devices.
final users = (response.data as List)
    .map((json) =&gt; User.fromJson(json))
    .toList();
</code></pre>
<pre><code class="language-dart">// compute() runs the function in a separate isolate.
// The main isolate stays free to render frames.
// Note: the function must be top-level or static —
// closures that capture local state cannot be sent to another isolate.
final users = await compute(parseUsers, response.data);

List&lt;User&gt; parseUsers(dynamic data) {
  return (data as List)
      .map((json) =&gt; User.fromJson(json as Map&lt;String, dynamic&gt;))
      .toList();
}
</code></pre>
<p>I reach for <code>compute</code> whenever I am parsing a large JSON response, doing image processing, or running anything that feels slow in a quick profile. The threshold in my head is roughly 16ms — if an operation might take longer than that, it shouldn't be on the main isolate.</p>
<h2 id="heading-memory-leaks-and-lifecycle-management">Memory Leaks and Lifecycle Management</h2>
<p>This one cost me the most debugging time across all the apps I've shipped. Memory leaks in Flutter don't crash immediately. They build slowly — a few megabytes per session, every session — until the app starts feeling heavy, the OS starts killing it in the background, and users file bug reports about "random crashes."</p>
<p>The root cause is almost always the same: something created inside a widget keeps running after the widget is gone.</p>
<h3 id="heading-controllers-that-are-never-disposed">Controllers That Are Never Disposed</h3>
<p>The most common source of memory leaks I've seen, including in my own code, is controllers that are created in <code>initState</code> and never released. Flutter doesn't clean these up automatically.</p>
<pre><code class="language-dart">class _ProfileScreenState extends State&lt;ProfileScreen&gt; {
  late final TextEditingController _nameController;
  late final AnimationController _fadeController;
  late final ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController();
    _fadeController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
    _scrollController = ScrollController();
  }

  @override
  void dispose() {
    // Every controller created in initState needs to be
    // disposed here. This is not optional — it releases
    // native resources and removes listeners that would
    // otherwise keep this widget's memory alive indefinitely.
    _nameController.dispose();
    _fadeController.dispose();
    _scrollController.dispose();
    super.dispose(); // always last
  }
}
</code></pre>
<p>An undisposed <code>AnimationController</code> is particularly bad. It holds a ticker that fires on every frame — so it keeps consuming CPU even after the screen it belonged to is gone. I've seen this cause noticeable battery drain in addition to memory issues.</p>
<h3 id="heading-stream-subscriptions">Stream Subscriptions</h3>
<pre><code class="language-dart">class _ChatScreenState extends State&lt;ChatScreen&gt; {
  StreamSubscription&lt;Message&gt;? _messageSubscription;

  @override
  void initState() {
    super.initState();
    _messageSubscription = messageStream.listen((message) {
      // Without cancellation, this callback keeps firing
      // even after the screen is removed from the tree.
      // It will call setState on a disposed widget and
      // hold message objects in memory that should be freed.
      if (mounted) setState(() =&gt; messages.add(message));
    });
  }

  @override
  void dispose() {
    _messageSubscription?.cancel();
    super.dispose();
  }
}
</code></pre>
<h3 id="heading-timers">Timers</h3>
<pre><code class="language-dart">@override
void dispose() {
  // A timer that fires after dispose will try to run
  // a callback on a widget that no longer exists.
  _dismissTimer?.cancel();
  super.dispose();
}
</code></pre>
<p>A rule I follow without exception: anything created in <code>initState</code> that has a <code>dispose</code>, <code>cancel</code>, or <code>close</code> method gets a corresponding call in <code>dispose</code>. No exceptions, no "I'll add it later."</p>
<h2 id="heading-observability-and-crash-reporting">Observability and Crash Reporting</h2>
<p>Before I integrated crash reporting into my first production app, debugging was genuinely painful. A user would report a crash. I would ask what they were doing. They would say "I just opened it." I would stare at the code looking for anything that could cause that. Half the time I never figured it out.</p>
<p>With crash reporting, that changes completely.</p>
<h3 id="heading-set-it-up-before-launch">Set it Up Before Launch</h3>
<pre><code class="language-dart">void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // Catch Flutter framework errors — widget build errors,
  // rendering errors, etc.
  FlutterError.onError =
      FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Catch errors in async code that Flutter does not catch —
  // errors in event handlers, timers, isolates.
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}
</code></pre>
<h3 id="heading-never-let-failures-be-silent">Never Let Failures Be Silent</h3>
<pre><code class="language-dart">// This is how I used to write it. If submitOrder throws,
// nothing happens. The user has no idea. I have no idea.
await api.submitOrder(order);
</code></pre>
<pre><code class="language-dart">// This is how I write it now.
try {
  await api.submitOrder(order);
  setState(() =&gt; orderStatus = OrderStatus.confirmed);
} catch (e, stackTrace) {
  // recordError sends the full exception and stack trace
  // to Crashlytics, with device info and the user's
  // recent session activity attached automatically.
  FirebaseCrashlytics.instance.recordError(e, stackTrace);
  setState(() =&gt; orderStatus = OrderStatus.failed);
}
</code></pre>
<h3 id="heading-breadcrumbs">Breadcrumbs</h3>
<p>Raw crash logs tell you what broke. Breadcrumbs tell you what the user was doing when it broke. These aren't the same thing.</p>
<pre><code class="language-dart">FirebaseCrashlytics.instance.log('User opened checkout');
FirebaseCrashlytics.instance.log('Payment sheet presented');
FirebaseCrashlytics.instance.log('User submitted payment');
// crash here — now I know the exact sequence
</code></pre>
<h2 id="heading-testing-production-flutter-apps">Testing Production Flutter Apps</h2>
<p>I'll be honest: I under-tested my first app. I was moving fast, the features worked, and writing tests felt slow. Then I refactored a pricing calculation, introduced a bug that wasn't immediately obvious, and shipped it. A user caught it before I did.</p>
<p>I test more carefully now. Not everything — but the things that matter.</p>
<h3 id="heading-unit-test-business-logic">Unit Test Business Logic</h3>
<pre><code class="language-dart">test('discount applies percentage correctly', () {
  final result = calculateDiscountedPrice(
    price: 100.0,
    discountPercent: 10,
  );

  // 10% off 100.00 should be 90.00
  expect(result, equals(90.0));
});

test('discount throws for negative percentage', () {
  expect(
    () =&gt; calculateDiscountedPrice(price: 100, discountPercent: -5),
    throwsA(isA&lt;ArgumentError&gt;()),
  );
});
</code></pre>
<p>Business logic – pricing, validation, authorization – should be in plain Dart functions with no Flutter dependencies, so they can be tested in milliseconds without any test infrastructure.</p>
<h3 id="heading-widget-test-ui-states">Widget Test UI States</h3>
<p>Flutter's widget testing is genuinely one of its best features. You can test loading states, error states, and user interactions without a device or emulator.</p>
<pre><code class="language-dart">testWidgets('shows error state with retry button on load failure',
    (tester) async {
  final mockRepo = MockUserRepository();
  when(mockRepo.getUser(any)).thenThrow(Exception('Network error'));

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(mockRepo),
      ],
      child: const MaterialApp(home: ProfileScreen(userId: 'test')),
    ),
  );

  // pumpAndSettle waits for all animations and async
  // operations to complete before asserting.
  await tester.pumpAndSettle();

  expect(find.text('Failed to load profile. Please try again.'), findsOneWidget);
  expect(find.text('Try again'), findsOneWidget);
});
</code></pre>
<p>What I prioritize testing: core business logic, error and loading states, any flow that involves money or data the user can't recover, and the integration points between my app and the backend. Static UI widgets that contain no logic I generally leave uncovered.</p>
<h2 id="heading-architecture-and-long-term-maintainability">Architecture and Long-Term Maintainability</h2>
<p>The first app I shipped had no real architecture. Everything was in widgets. Business logic sat next to UI code. State was scattered.</p>
<p>It worked fine for six months. Then I needed to add a feature that touched several existing screens, and what should have taken a day took a week because I couldn't change anything without breaking something else.</p>
<p>The second app I was more deliberate about. Features in their own folders. Repositories separate from widgets. State managed outside the UI layer. When requirements changed — and they always change — the changes were contained.</p>
<h3 id="heading-separate-concerns-at-the-layer-boundary">Separate Concerns at the Layer Boundary</h3>
<pre><code class="language-plaintext">lib/
  features/
    profile/
      data/
        profile_repository.dart     # network + cache logic
      domain/
        user.dart                   # clean domain model
      presentation/
        profile_screen.dart         # widget
        profile_notifier.dart       # state
</code></pre>
<p>Widgets shouldn't make network calls. Repositories shouldn't import Flutter. Neither should know anything about the other's internals.</p>
<p>When you need to swap the data source, or test the notifier with a mock, or change the UI without touching the business logic — this separation is what makes that possible.</p>
<h3 id="heading-technical-debt-accumulates-faster-than-you-expect">Technical Debt Accumulates Faster Than You Expect</h3>
<p>A shortcut that saves thirty minutes today tends to cost several hours a month from now. The shortcuts that compound fastest in Flutter:</p>
<ul>
<li><p>Business logic inside widgets (impossible to test, impossible to reuse)</p>
</li>
<li><p><code>dynamic</code> instead of typed models (runtime errors instead of compile-time errors)</p>
</li>
<li><p>Copy-pasted validation logic (change it in one place and forget the others)</p>
</li>
<li><p>Mutable global state without clear ownership</p>
</li>
</ul>
<p>None of these are catastrophic on day one. All of them make the next change harder than it should be, and the change after that harder still.</p>
<h2 id="heading-end-to-end-example-a-production-grade-profile-feature">End-to-End Example: a Production-Grade Profile Feature</h2>
<p>Here's everything from this article assembled into one feature. A repository with caching and retry, a Riverpod notifier with optimistic updates, a widget that handles all three states, and proper lifecycle management throughout.</p>
<h3 id="heading-the-repository">The Repository</h3>
<pre><code class="language-dart">class ProfileRepository {
  final Dio _dio;
  final Box _cache;

  ProfileRepository(this._dio, this._cache);

  Future&lt;User&gt; getUser(String userId) async {
    try {
      final response = await withRetry(
        () =&gt; _dio.get('/users/$userId'),
      );

      final user = User.fromJson(
        response.data as Map&lt;String, dynamic&gt;,
      );

      // Cache successful responses for offline fallback.
      await _cache.put('user_$userId', user.toJson());

      return user;
    } on DioException catch (e) {
      final cached = _cache.get('user_$userId');

      if (cached != null) {
        return User.fromJson(Map&lt;String, dynamic&gt;.from(cached));
      }

      if (e.type == DioExceptionType.connectionError) {
        throw NoInternetException();
      }

      throw ServerException(e.response?.statusCode ?? 0);
    }
  }

  Future&lt;void&gt; updateDisplayName(String userId, String name) async {
    await withRetry(
      () =&gt; _dio.patch('/users/$userId', data: {'displayName': name}),
    );

    // Invalidate cache so the next read fetches fresh data.
    await _cache.delete('user_$userId');
  }
}
</code></pre>
<h3 id="heading-the-notifier">The Notifier</h3>
<pre><code class="language-dart">@riverpod
class ProfileNotifier extends _$ProfileNotifier {
  @override
  AsyncValue&lt;User&gt; build(String userId) {
    _load();
    return const AsyncValue.loading();
  }

  Future&lt;void&gt; _load() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () =&gt; ref.read(profileRepositoryProvider).getUser(userId),
    );
  }

  Future&lt;void&gt; refresh() =&gt; _load();

  Future&lt;void&gt; updateName(String newName) async {
    final current = state.valueOrNull;
    if (current == null) return;

    try {
      await ref
          .read(profileRepositoryProvider)
          .updateDisplayName(userId, newName);

      // Update the UI immediately without waiting for a reload.
      state = AsyncValue.data(current.copyWith(displayName: newName));
    } catch (e, st) {
      FirebaseCrashlytics.instance.recordError(e, st);
      // Restore the previous state if the update fails.
      state = AsyncValue.data(current);
      rethrow;
    }
  }
}
</code></pre>
<h3 id="heading-the-widget">The Widget</h3>
<pre><code class="language-dart">class ProfileScreen extends ConsumerWidget {
  final String userId;
  const ProfileScreen({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final profileAsync = ref.watch(profileNotifierProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: profileAsync.when(
        loading: () =&gt; const Center(child: CircularProgressIndicator()),
        error: (e, _) =&gt; _ErrorView(
          message: e is NoInternetException
              ? 'No internet connection.'
              : 'Failed to load profile.',
          onRetry: () =&gt; ref
              .read(profileNotifierProvider(userId).notifier)
              .refresh(),
        ),
        data: (user) =&gt; _ProfileView(user: user, userId: userId),
      ),
    );
  }
}

class _ProfileView extends ConsumerStatefulWidget {
  final User user;
  final String userId;
  const _ProfileView({required this.user, required this.userId});

  @override
  ConsumerState&lt;_ProfileView&gt; createState() =&gt; _ProfileViewState();
}

class _ProfileViewState extends ConsumerState&lt;_ProfileView&gt; {
  late final TextEditingController _nameController;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController(text: widget.user.displayName);
  }

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }

  Future&lt;void&gt; _saveName() async {
    try {
      await ref
          .read(profileNotifierProvider(widget.userId).notifier)
          .updateName(_nameController.text);

      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Name updated.')),
      );
    } catch (_) {
      if (!mounted) return;

      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Failed to update name.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        TextField(
          controller: _nameController,
          decoration: const InputDecoration(labelText: 'Display name'),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: _saveName,
          child: const Text('Save'),
        ),
      ],
    );
  }
}
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>None of this is particularly advanced. It's mostly habits — <code>checking mounted</code>, <code>disposing controllers</code>, <code>handling the error state</code>, <code>caching for offline</code>. Each habit prevents one specific category of production failure, and together they add up to an app that users experience as reliable.</p>
<p>I wish I'd written my first app this way. I didn't, because I didn't know what I didn't know yet. That is normal.</p>
<p>But if you're reading this before shipping your first production app, you now have the benefit of what took me multiple shipped apps and a lot of frustrated user feedback to learn.</p>
<p>The best time to add these patterns is at the start of a feature. The second-best time is now.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How AOSP 16 Bluetooth Scanner Works: The Ultimate Guide ]]>
                </title>
                <description>
                    <![CDATA[ Ah, Bluetooth. The technology we all love to hate. It's like that one friend who's always just about to connect, but then... doesn't. For years, Android developers have been locked in a dramatic, often tragic, romance with Bluetooth. We've wrestled w... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-aosp-16-bluetooth-scanner-works-the-ultimate-guide/</link>
                <guid isPermaLink="false">6983ae630a7fef9ac2d90313</guid>
                
                    <category>
                        <![CDATA[ ble ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Bluetooth Low Energy ]]>
                    </category>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ android app development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ scanner ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Wed, 04 Feb 2026 20:38:59 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770234863523/44a1690e-ab8a-4f6b-a12b-2c2636947d8c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Ah, Bluetooth. The technology we all love to hate. It's like that one friend who's always just about to connect, but then... doesn't.</p>
<p>For years, Android developers have been locked in a dramatic, often tragic, romance with Bluetooth. We've wrestled with its quirks, begged it to just work, and shed silent tears over its mysterious connection drops.</p>
<p>But what if I told you that things are about to get better? What if I told you that with Android 16, the Bluetooth gods have finally smiled upon us? It's not a dream, my friends. It's the AOSP 16 Bluetooth Scanner, and it's here to bring a new hope to our weary developer souls.</p>
<p>In this handbook, we're going on a journey. A journey into the heart of AOSP 16's new Bluetooth features. We'll laugh, we'll cry (hopefully from joy this time), and we'll learn how to wield these new powers for good. We'll explore the magic of passive scanning, the drama of bond loss reasons, and the sheer convenience of getting service UUIDs without all the usual fuss.</p>
<p>By the end of this epic saga, you'll be able to:</p>
<ul>
<li><p>Build a Bluetooth scanner that's so efficient, it's practically psychic.</p>
</li>
<li><p>Debug connection issues like a seasoned detective.</p>
</li>
<li><p>Impress your friends and colleagues with your newfound Bluetooth mastery.</p>
</li>
</ul>
<h3 id="heading-prerequisites"><strong>Prerequisites:</strong></h3>
<p>Before we dive in, it's a good idea to have a basic understanding of Android development and Kotlin. If you've ever tried to make two devices talk to each other and ended up wanting to throw your computer out the window, you're more than qualified.</p>
<p>So grab your favorite beverage, put on your coding cape, and let's get ready for the Bluetooth awakening!</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-a-brief-history-of-bluetooth-in-android">A Brief History of Bluetooth in Android</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-whats-new-in-aosp-16-the-three-musketeers">What's New in AOSP 16: The Three Musketeers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deep-dive-1-passive-scanning">Deep Dive #1: Passive Scanning</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-the-bluetoothlescanner">Understanding the BluetoothLeScanner</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-hands-on-building-your-first-passive-scanner">Hands-On: Building Your First Passive Scanner</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deep-dive-2-bluetooth-bond-loss-reasons">Deep Dive #2: Bluetooth Bond Loss Reasons</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deep-dive-3-service-uuids-from-advertisements">Deep Dive #3: Service UUIDs from Advertisements</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-advanced-topics-leveling-up-your-scanning-game">Advanced Topics: Leveling Up Your Scanning Game</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-real-world-use-cases-where-the-bluetooth-hits-the-road">Real-World Use Cases: Where the Bluetooth Hits the Road</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-api-version-checking-how-to-not-crash-your-app">API Version Checking: How to Not Crash Your App</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-testing-and-debugging-the-fun-part-said-no-one-ever">Testing and Debugging: The Fun Part (Said No One Ever)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-performance-and-best-practices-how-to-be-a-good-bluetooth-citizen">Performance and Best Practices: How to Be a Good Bluetooth Citizen</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion-the-future-is-passive-and-thats-okay">Conclusion: The Future is Passive (and That's Okay)</a></p>
</li>
</ol>
<h2 id="heading-a-brief-history-of-bluetooth-or-how-we-learned-to-stop-worrying-and-love-the-radio-waves">A Brief History of Bluetooth (Or: How We Learned to Stop Worrying and Love the Radio Waves)</h2>
<h3 id="heading-the-dark-ages-classic-bluetooth">The Dark Ages: Classic Bluetooth</h3>
<p>In the beginning, there was Classic Bluetooth. It was the digital equivalent of a loud, boisterous party guest. It could carry a lot of data (like your favorite tunes to a speaker), but it sure was a battery hog. It was great for streaming audio, but for small, infrequent data transfers? It was like using a fire hose to water a houseplant. Overkill, and frankly, a little messy.</p>
<p>Developers in this era spent their days wrestling with BluetoothAdapter, BluetoothDevice, and the dreaded BluetoothSocket. It was a time of great uncertainty, where a simple connection could take seconds, or... well, let's just say you could go make a cup of coffee. And the battery drain? Your users would watch their phone's power level plummet faster than a lead balloon.</p>
<h3 id="heading-the-renaissance-enter-bluetooth-low-energy-ble">The Renaissance: Enter Bluetooth Low Energy (BLE)</h3>
<p>Then, with Android 4.3, a new hero emerged: Bluetooth Low Energy, or BLE. This wasn't your dad’s Bluetooth. BLE was sleek, efficient, and mysterious. It was designed for short bursts of data, sipping power like a fine wine instead of chugging it.</p>
<p>BLE was the cool kid on the block. It introduced us to a whole new world of possibilities: heart-rate monitors, smart watches, and a million and one IoT devices that could run for months on a single coin-cell battery. It was a game-changer.</p>
<p>But with great power came... great complexity. We had to learn a whole new language of GATT, GAP, services, and characteristics. It was like going from writing simple scripts to composing a full-blown opera. The potential was huge, but the learning curve was steep.</p>
<h3 id="heading-the-problem-child-scanning">The Problem Child: Scanning</h3>
<p>And then there was scanning. The act of finding these new, power-sipping devices. In the early days of BLE, scanning was still a bit of a wild west. It was an active, noisy process. Your phone would shout into the void, "IS ANYONE OUT THERE?", and then listen for replies. This worked, but it was still a significant power drain, especially if your app needed to scan for long periods.</p>
<p>It was the classic developer dilemma: you need to find devices, but you don't want to be the reason your user's phone is dead by lunchtime. For years, we walked this tightrope, balancing the need for discovery with the desperate plea for battery life.</p>
<p>This is the world that AOSP 16 was born into. A world crying out for a better way to scan. A world ready for a hero. And that hero, my friends, is passive scanning. But more on that in a bit...</p>
<h2 id="heading-whats-new-in-aosp-16-spoiler-its-actually-cool">What's New in AOSP 16? (Spoiler: It's Actually Cool)</h2>
<p>Alright, let's get to the good stuff. What shiny new toys did the Android team give us in AOSP 16? It turns out, quite a few! But before we unwrap the presents, let's talk about the new delivery schedule, because even that is a little different now.</p>
<h3 id="heading-a-tale-of-two-releases">A Tale of Two Releases</h3>
<p>In a shocking plot twist, Android decided to grace us with two major API releases in 2025. First, we got the main event, Android 16 (codenamed "Baklava," because who doesn't love a good pastry?), which landed in Q2. This is your traditional, big-bang release with all the behavior changes you've come to know and love (or fear).</p>
<p>But then, in Q4, we get a surprise second act: a minor release, which is where our new Bluetooth goodies made their grand entrance. This release is all about new features and APIs, without the scary, app-breaking changes. It's like getting a free dessert after you've already paid the bill.</p>
<h3 id="heading-the-three-musketeers-of-bluetooth">The Three Musketeers of Bluetooth</h3>
<p>So, what did this Q4 release bring to the Bluetooth party? I'm glad you asked. It brought three new heroes, ready to save us from our Bluetooth woes. I call them... The Three Musketeers.</p>
<table><tbody><tr><td><p><strong>Feature</strong></p></td><td><p><strong>The Gist</strong></p></td><td><p><strong>Why You Should Care</strong></p></td></tr><tr><td><p><strong>Passive Scanning</strong></p></td><td><p>The ability to listen for Bluetooth devices without shouting at them.</p></td><td><p>Your app can now be a silent, battery-saving ninja.</p></td></tr><tr><td><p><strong>Bond Loss Reasons</strong></p></td><td><p>Finally, some closure on why your Bluetooth connections break up.</p></td><td><p>You can stop playing the guessing game and actually debug connection issues.</p></td></tr><tr><td><p><strong>Service UUID from Ads</strong></p></td><td><p>Grab a device's vital stats directly from its advertisement.</p></td><td><p>It's like speed dating for Bluetooth devices. Faster, more efficient connections.</p></td></tr></tbody></table>

<p>These aren't just minor tweaks, folks. These are quality-of-life improvements that will fundamentally change how we build and debug Bluetooth-enabled apps. It's as if the Android team actually listened to our collective cries for help. (I know, I'm shocked too.)</p>
<p>In the next few sections, we're going to get up close and personal with each of these new features. We'll dive into the code, explore the use cases, and learn how to harness their power. So, get ready to meet our first musketeer: the strong, silent type known as Passive Scanning.</p>
<h2 id="heading-deep-dive-1-passive-scanning">Deep Dive #1: Passive Scanning</h2>
<p>Imagine you're in a library. You're looking for a friend, but you don't know where they are. You have two options:</p>
<ul>
<li><p><strong>Active Scanning:</strong> You stand in the middle of the library and shout, "HEY, STEVE! ARE YOU HERE?" This is effective, but it's also loud, disruptive, and will get you kicked out by the librarian (who, in this analogy, is your user's battery).</p>
</li>
<li><p><strong>Passive Scanning:</strong> You quietly walk around the library, listening for your friend's distinctive, wheezing laugh. You don't say a word. You just listen. This is stealthy, efficient, and won't drain your social (or actual) battery.</p>
</li>
</ul>
<p>For years, Android's Bluetooth scanning has been the guy shouting in the library. But with AOSP 16, we can finally be the quiet listener. This is the magic of passive scanning.</p>
<h3 id="heading-active-vs-passive-the-technical-showdown">Active vs. Passive: The Technical Showdown</h3>
<p>In the world of BLE, devices send out little packets of information called "advertisements." It's their way of saying, "Hey, I'm here, and this is what I do!"</p>
<ul>
<li><p><strong>Active Scanning:</strong> When your phone performs an active scan, it hears an advertisement and then sends back a SCAN_REQ (Scan Request). It's basically saying, "Tell me more!" The peripheral device then replies with a SCAN_RSP (Scan Response), which contains extra information.</p>
</li>
<li><p><strong>Passive Scanning:</strong> With passive scanning, your phone hears the advertisement... and that's it. It doesn't send anything back. It just takes note of the initial advertisement and moves on. It's a one-way conversation.</p>
</li>
</ul>
<h3 id="heading-why-go-passive-the-power-of-silence">Why Go Passive? The Power of Silence</h3>
<p>So, why is this such a big deal? Two words: power consumption. Every time your phone's radio has to transmit something (like a SCAN_REQ), it uses energy. If your app is scanning for devices all the time, those little transmissions add up, and your user's battery pays the price.</p>
<p>By switching to passive scanning, you're telling the radio to just listen. No talking, just listening. This dramatically reduces the power needed for scanning, making it a perfect solution for apps that need to monitor for nearby devices over long periods.</p>
<h3 id="heading-the-code-how-to-become-a-bluetooth-ninja">The Code: How to Become a Bluetooth Ninja</h3>
<p>So, how do we implement this newfound stealth mode? It's surprisingly simple. It all comes down to the ScanSettings you use when you start your scan.</p>
<p>Previously, you might have done something like this:</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> settings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .build()
</code></pre>
<p>Now, with AOSP 16, we have a new option. To enable passive scanning, you simply set the scan type:</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// This is the magic line!</span>
.setScanMode(ScanSettings.SCAN_TYPE_PASSIVE)
</code></pre>
<p>Wait, that can't be right. The documentation says SCAN_TYPE_PASSIVE is a scan type, not a scan mode. And you're right! My apologies, I got a little too excited. The correct way to do this is by setting the scan mode to passive. Let's try that again.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> settings = ScanSettings.Builder()
    <span class="hljs-comment">// The actual magic line!</span>
    .setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC) <span class="hljs-comment">// This is the closest to passive</span>
    .build()
</code></pre>
<p>Hold on, that's not quite right either. It seems I've gotten my wires crossed. Let's consult the official scrolls... Ah, here it is! The ScanSettings.Builder has a new method in Android 16 QPR2. It's not setScanMode, it's a whole new setting.</p>
<p>Let's get this right once and for all. Here is the correct way to enable passive scanning:</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// Available in Android 16 QPR2 and later</span>
<span class="hljs-keyword">val</span> settings = ScanSettings.Builder()
    <span class="hljs-comment">// This is the REAL magic line, I promise!</span>
    .setScanType(ScanSettings.SCAN_TYPE_PASSIVE) 
    .build()
</code></pre>
<p>And there you have it. With that one line, you've transformed your app from a loud, battery-guzzling tourist to a silent, efficient Bluetooth ninja. Your users' batteries will thank you.</p>
<p>Of course, there's a trade-off. Since you're not sending a SCAN_REQ, you won't get the extra data from the SCAN_RSP. But for many use cases, the initial advertisement is all you need. And the power savings are more than worth it.</p>
<p>Now that we've mastered the art of silent scanning, let's move on to the next piece of the puzzle: understanding the BluetoothLeScanner itself.</p>
<h2 id="heading-understanding-bluetoothlescanner-the-star-of-our-show">Understanding BluetoothLeScanner (The Star of Our Show)</h2>
<p>Before we can truly master the art of Bluetooth scanning, we must first understand our primary weapon: the BluetoothLeScanner. Think of it as the PKE Meter from Ghostbusters. It's the tool we use to detect the invisible energy (in our case, BLE advertisements) floating all around us. But how does this ghost-hunting gadget actually work?</p>
<h3 id="heading-the-architecture-a-peek-behind-the-curtain">The Architecture: A Peek Behind the Curtain</h3>
<p>At a high level, the process is pretty straightforward. Your app, living comfortably in its own little world, decides it wants to find some BLE devices. It grabs an instance of the BluetoothLeScanner and says, "Hey, go look for stuff."</p>
<p>Under the hood, a lot is happening. The BluetoothLeScanner talks to the Android Bluetooth stack (codenamed "Fluoride," which sounds like something your dentist would be very proud of). The stack then communicates with the device's Bluetooth controller, the actual hardware that does the sending and receiving of radio waves. It's a classic case of "it's more complicated than it looks."</p>
<h3 id="heading-the-alphabet-soup-gatt-gap-and-friends">The Alphabet Soup: GATT, GAP, and Friends</h3>
<p>When you venture into the world of BLE, you'll quickly run into a whole bunch of acronyms. Don't panic! They're not as scary as they look. The two most important ones to understand are GAP and GATT.</p>
<ul>
<li><p><strong>GAP (Generic Access Profile):</strong> This is all about how devices discover and connect to each other. Think of GAP as the bouncer at a nightclub. It decides who gets to talk to whom. It manages advertising (the device shouting "I'm here!") and scanning (your app listening for those shouts). Our BluetoothLeScanner is a key player in the GAP-verse.</p>
</li>
<li><p><strong>GATT (Generic Attribute Profile):</strong> Once two devices are connected, GATT takes over. It defines how they exchange data. Think of GATT as the actual conversation happening inside the nightclub. It's all about Services, Characteristics, and Descriptors. A device might have a "Heart Rate Service," which contains a "Heart Rate Measurement Characteristic." Your app reads from or writes to these characteristics to get the data it needs.</p>
</li>
</ul>
<p>For the purpose of scanning, we're mostly living in the world of GAP. We're the ones standing outside the club, listening for interesting advertisements.</p>
<h3 id="heading-the-scanning-lifecycle-a-dramatic-play-in-three-acts">The Scanning Lifecycle: A Dramatic Play in Three Acts</h3>
<p>The life of a Bluetooth scan is a simple, yet elegant, drama.</p>
<ul>
<li><p><strong>Act I:</strong> The Preparation. Your app decides it's time to scan. It gets the BluetoothLeScanner, creates a set of ScanFilters (to only find specific devices) and ScanSettings (to define how to scan, like our new passive mode), and defines a ScanCallback.</p>
</li>
<li><p><strong>Act II:</strong> The Scan. Your app calls startScan(). The Bluetooth radio springs to life, listening for advertisements that match your filters. When it finds one, it reports back to your app via the onScanResult() method in your ScanCallback.</p>
</li>
<li><p><strong>Act III:</strong> The End. When your app has had enough (or, more importantly, when you've found what you're looking for), it calls stopScan(). The radio powers down, and all is quiet once more. It's crucial to always stop your scan when you're done. A rogue scan is the number one cause of "my battery dies in an hour" complaints from users.</p>
</li>
</ul>
<p>And that's the BluetoothLeScanner in a nutshell. It's our gateway to the world of BLE discovery. It's powerful, it's complex, but as we're learning, it's getting smarter and more efficient with every new Android release. Now that we know our tool, let's get our hands dirty and build our first passive scanner!</p>
<h2 id="heading-hands-on-building-your-first-passive-scanner">Hands-On: Building Your First Passive Scanner</h2>
<p>Theory is great, but let's be honest, we're developers. We learn by doing (or by copying pasting from Stack Overflow). It's time to roll up our sleeves, fire up Android Studio, and build something. We're going to create a simple app that uses our newfound passive scanning powers to find nearby BLE devices.</p>
<h3 id="heading-step-1-the-permission-inquisition">Step 1: The Permission Inquisition</h3>
<p>Before we write a single line of Kotlin, we must appease the Android permission gods. This is a sacred and often frustrating ritual. For Bluetooth scanning, the rules have changed a bit over the years.</p>
<p>First, open your <code>AndroidManifest.xml</code> and add the following:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.BLUETOOTH"</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.BLUETOOTH_ADMIN"</span> /&gt;</span>

<span class="hljs-comment">&lt;!-- For Android 12 (API 31) and above --&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.BLUETOOTH_SCAN"</span> /&gt;</span>

<span class="hljs-comment">&lt;!-- For older versions, you needed location permissions --&gt;</span>
<span class="hljs-comment">&lt;!-- You might still need this if you support older devices --&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.ACCESS_FINE_LOCATION"</span> /&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.ACCESS_COARSE_LOCATION"</span> /&gt;</span>
</code></pre>
<p>Looking at the permissions we've declared above, you can see the evolution of Android's Bluetooth permission model playing out in real-time.</p>
<p>The first two permissions, <code>BLUETOOTH</code> and <code>BLUETOOTH_ADMIN</code>, are the old guard. They've been around since the early days of Android and provide basic Bluetooth functionality and the ability to discover devices. Then we have <code>BLUETOOTH_SCAN</code>, which was introduced in Android 12 (API 31) and represents a major shift in how Google thinks about privacy.</p>
<p>Yes, you're seeing that right. In the good old days (before Android 12), Google decided that finding a Bluetooth device was basically the same as knowing your user's exact location. It kind of made sense: after all, if you can see which Bluetooth beacons are nearby, you can triangulate your position. But it was also a bit creepy to ask for location just to find a pair of headphones. This led to the awkward situation where users would see a simple Bluetooth scanner app asking for their precise location and understandably get suspicious.</p>
<p>Thankfully, with Android 12, they introduced the <code>BLUETOOTH_SCAN</code> permission, which is much more sensible. This permission finally allows apps to scan for Bluetooth devices without needing to ask for location access, which makes a lot more sense from a user perspective. You'll still need to request this permission at runtime, but at least you don't have to explain to your users why your simple gadget-finder app wants to know where they live.</p>
<p>However, notice those last two permissions for location access. Those are the remnants of the old system. If you're building an app that needs to support older devices running Android 11 or below, you'll need to keep these location permissions in your manifest for backwards compatibility. On modern devices, the <code>BLUETOOTH_SCAN</code> permission alone will do the job.</p>
<h3 id="heading-step-2-the-code-awakens">Step 2: The Code Awakens</h3>
<p>Alright, let's get to the fun part. Here's a breakdown of how to implement the passive scanner in your Activity or Fragment.</p>
<h4 id="heading-get-the-scanner">Get the Scanner</h4>
<p>First, we need to get an instance of the BluetoothLeScanner.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> bluetoothAdapter: BluetoothAdapter? <span class="hljs-keyword">by</span> lazy {
    <span class="hljs-keyword">val</span> bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) <span class="hljs-keyword">as</span> BluetoothManager
    bluetoothManager.adapter
}

<span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> bleScanner: BluetoothLeScanner? <span class="hljs-keyword">by</span> lazy {
    bluetoothAdapter?.bluetoothLeScanner
}
</code></pre>
<p>Let's break down what's happening in the code above. We're using Kotlin's <code>lazy</code> delegation, which is a fancy way of saying "don't create this object until I actually need it." This is a good practice because getting the Bluetooth adapter involves system calls, and there's no point in doing that work if we never actually use it.</p>
<p>First, we grab the <code>BluetoothManager</code> from the system services. Think of the <code>BluetoothManager</code> as the gatekeeper to all things Bluetooth on your device. From this manager, we get the <code>BluetoothAdapter</code>, which represents your device's physical Bluetooth hardware. Notice that we're declaring it as nullable (<code>BluetoothAdapter?</code>) because, believe it or not, not every Android device has Bluetooth. Some tablets or obscure devices might not have the hardware, so we need to be prepared for that possibility.</p>
<p>Once we have the adapter, we can ask it for the <code>BluetoothLeScanner</code>. This is the actual object we'll use to perform our scans. Again, we're using the safe call operator (<code>?.</code>) because if the adapter is null (no Bluetooth hardware), we definitely can't get a scanner from it. This defensive programming might seem paranoid, but it's what separates apps that crash mysteriously from apps that gracefully handle edge cases.</p>
<h4 id="heading-define-the-callback">Define the Callback</h4>
<p>This is where the magic happens. The ScanCallback is an object that will listen for scan results. We need to override two methods: onScanResult and onScanFailed.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> scanCallback = <span class="hljs-keyword">object</span> : ScanCallback() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanResult</span><span class="hljs-params">(callbackType: <span class="hljs-type">Int</span>, result: <span class="hljs-type">ScanResult</span>)</span></span> {
        <span class="hljs-comment">// We found a device! </span>
        <span class="hljs-comment">// The 'result' object contains the device, RSSI, and advertisement data.</span>
        Log.d(<span class="hljs-string">"BleScanner"</span>, <span class="hljs-string">"Found device: <span class="hljs-subst">${result.device.address}</span>, RSSI: <span class="hljs-subst">${result.rssi}</span>"</span>)
    }

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanFailed</span><span class="hljs-params">(errorCode: <span class="hljs-type">Int</span>)</span></span> {
        <span class="hljs-comment">// This is the universe's way of telling you to take a break.</span>
        <span class="hljs-comment">// Or that something went horribly wrong.</span>
        Log.e(<span class="hljs-string">"BleScanner"</span>, <span class="hljs-string">"Scan failed with error code: <span class="hljs-variable">$errorCode</span>"</span>)
    }
}
</code></pre>
<p>The <code>ScanCallback</code> we've defined above is your app's ears in the Bluetooth world. When the scanner finds a device, it doesn't just store the information somewhere, it actively calls back to your app through this callback object. This is classic event-driven programming, and it's how Android keeps your app responsive without blocking the main thread.</p>
<p>The <code>onScanResult</code> method is called every time the scanner discovers a device that matches your filters (or any device if you're not using filters). The <code>result</code> parameter is a treasure trove of information. It contains the <code>BluetoothDevice</code> object (which has the device's MAC address and name), the RSSI value (Received Signal Strength Indicator – basically how close the device is, with higher numbers meaning closer), and the raw advertisement data that the device is broadcasting.</p>
<p>In our simple example above, we're just logging the MAC address and RSSI, but in a real app, you'd probably want to update your UI, add the device to a list, or trigger a connection.</p>
<p>The <code>callbackType</code> parameter tells you <em>why</em> this callback was triggered. It could be <code>CALLBACK_TYPE_ALL_MATCHES</code> (the default, meaning "here's every device we found"), <code>CALLBACK_TYPE_FIRST_MATCH</code> (the first time we saw this device), or <code>CALLBACK_TYPE_MATCH_LOST</code> (we haven't seen this device in a while, so it probably left). We'll dive deeper into these types in the advanced section.</p>
<p>Then there's <code>onScanFailed</code>, the method we all hope never gets called but that we absolutely need to handle. This is invoked when something goes catastrophically wrong with the scan. Maybe the Bluetooth adapter got turned off mid-scan, maybe your app doesn't have the right permissions, or maybe the Bluetooth controller just had a bad day. The <code>errorCode</code> will give you a hint about what went wrong, and you should always log this and handle it gracefully – perhaps by showing a message to the user or attempting to restart the scan after a delay.</p>
<h4 id="heading-configure-the-scan">Configure the Scan</h4>
<p>Now, we create our ScanSettings. This is where we tell Android that we want to be a passive, battery-saving ninja.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> scanSettings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) <span class="hljs-comment">// Let's be nice to the battery</span>
    .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
    .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
    .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) <span class="hljs-comment">// Report each ad once</span>
    .setReportDelay(<span class="hljs-number">0L</span>) <span class="hljs-comment">// Report immediately</span>
    <span class="hljs-comment">// And here's the star of the show!</span>
    .setScanType(ScanSettings.SCAN_TYPE_PASSIVE)
    .build()
</code></pre>
<p>The <code>ScanSettings</code> object we're building above is like a detailed instruction manual for the Bluetooth scanner. Each method call fine-tunes exactly how the scan should behave, and getting these settings right is the difference between a battery-friendly app and one that gets uninstalled within hours.</p>
<p>Let's walk through each setting. First, <code>setScanMode(SCAN_MODE_LOW_POWER)</code> tells the scanner to use a low-power scanning mode, which means it will scan in intervals rather than continuously. This is perfect for most use cases where you don't need instant results and want to preserve battery life. The scanner will wake up, scan for a bit, sleep, and repeat. It's the Bluetooth equivalent of taking power naps.</p>
<p>Next, <code>setCallbackType(CALLBACK_TYPE_ALL_MATCHES)</code> means we want to be notified every time the scanner finds a matching device. This is the default behavior and is what you'll use most of the time. As we mentioned earlier, you can also use <code>CALLBACK_TYPE_FIRST_MATCH</code> or <code>CALLBACK_TYPE_MATCH_LOST</code> for more sophisticated presence detection.</p>
<p>The <code>setMatchMode(MATCH_MODE_AGGRESSIVE)</code> setting controls how aggressively the hardware should try to match devices against your filters. <code>MATCH_MODE_AGGRESSIVE</code> means "report matches quickly, even if you're not 100% certain," while <code>MATCH_MODE_STICKY</code> means "wait until you're really sure before reporting." Aggressive mode gives you faster results but might occasionally give you false positives.</p>
<p>Then we have <code>setNumOfMatches(MATCH_NUM_ONE_ADVERTISEMENT)</code>, which tells the scanner to report a device after seeing just one advertisement from it. The alternative is <code>MATCH_NUM_FEW_ADVERTISEMENT</code>, which waits for multiple advertisements before reporting. Using one advertisement gives you faster discovery, while waiting for a few reduces false positives from devices that are just passing by.</p>
<p>The <code>setReportDelay(0L)</code> setting is crucial. A delay of <code>0</code> means "report results immediately." If you set this to, say, <code>5000</code> milliseconds, the scanner would batch up results and deliver them every 5 seconds. Batching is great for background scanning (as we discussed in the advanced section), but for foreground scanning where the user is actively waiting, immediate reporting is what you want.</p>
<p>And finally, the star of our show: <code>setScanType(SCAN_TYPE_PASSIVE)</code>. This is the new API from Android 16 QPR2 that transforms our scanner into a silent listener. Instead of actively sending scan requests to every device it hears, it just listens to the advertisements floating through the air. This single setting can dramatically reduce your app's battery consumption during scanning. It's the feature we've been waiting for, and it's glorious.</p>
<h4 id="heading-start-and-stop-the-scan">Start and Stop the Scan</h4>
<p>Finally, we need functions to start and stop our scan. Remember: always stop your scan! A forgotten scan is a battery-killing monster.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">startBleScan</span><span class="hljs-params">()</span></span> {
    <span class="hljs-comment">// Don't forget to request permissions first!</span>
    <span class="hljs-keyword">if</span> (bleScanner != <span class="hljs-literal">null</span>) {
        <span class="hljs-comment">// You can add ScanFilters here to search for specific devices</span>
        <span class="hljs-keyword">val</span> scanFilters: List&lt;ScanFilter&gt; = listOf() 
        bleScanner.startScan(scanFilters, scanSettings, scanCallback)
        Log.d(<span class="hljs-string">"BleScanner"</span>, <span class="hljs-string">"Scan started."</span>)
    } <span class="hljs-keyword">else</span> {
        Log.e(<span class="hljs-string">"BleScanner"</span>, <span class="hljs-string">"Bluetooth is not available."</span>)
    }
}

<span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">stopBleScan</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">if</span> (bleScanner != <span class="hljs-literal">null</span>) {
        bleScanner.stopScan(scanCallback)
        Log.d(<span class="hljs-string">"BleScanner"</span>, <span class="hljs-string">"Scan stopped."</span>)
    }
}
</code></pre>
<p>These two functions above are the on/off switches for your Bluetooth scanner, and they're deceptively simple for how important they are. Let's break down what's happening in each one.</p>
<p>In <code>startBleScan()</code>, we first check if the <code>bleScanner</code> is not null. This is our safety net: if the device doesn't have Bluetooth hardware or if Bluetooth is disabled, the scanner will be null, and we don't want to crash by trying to call methods on a null object. If the scanner exists, we call <code>startScan()</code> with three parameters: a list of <code>ScanFilter</code> objects, our carefully crafted <code>ScanSettings</code>, and the <code>ScanCallback</code> we defined earlier.</p>
<p>The <code>scanFilters</code> list is currently empty in our example, which means "find all BLE devices." In a real-world app, you'd typically add filters here to narrow down your search.</p>
<p>For instance, if you're building an app that only works with heart rate monitors, you'd create a filter that only matches devices advertising the Heart Rate Service UUID. This is crucial for both performance and battery life: why wake up your app for every random Bluetooth toothbrush when you only care about fitness trackers?</p>
<p>The <code>startScan()</code> method kicks off the scanning process. From this point on, the Bluetooth radio is actively (or in our case, passively) listening for advertisements, and your <code>scanCallback</code> will start receiving results. This is an asynchronous operation, meaning your code doesn't block here waiting for results – rather, it continues executing, and the results come in through the callback whenever they're available.</p>
<p>Now let's talk about <code>stopBleScan()</code>, which might be the most important function you write. When you call <code>stopScan()</code> with your callback, you're telling the Bluetooth radio, "Okay, we're done here, you can go back to sleep." This immediately stops the scanning process and releases the resources.</p>
<p>The critical thing to understand is that if you don't call this, the scan will continue running indefinitely, draining your user's battery like a vampire at an all-you-can-eat blood bank. This is why we emphasize it so much: a forgotten <code>stopScan()</code> call is one of the most common causes of battery drain complaints in Bluetooth apps.</p>
<p>Notice that we're passing the same <code>scanCallback</code> object to <code>stopScan()</code> that we used in <code>startScan()</code>. This is how Android knows which scan to stop – you might theoretically have multiple scans running with different callbacks (though that's rarely a good idea). Always make sure you're stopping the same scan you started by using the same callback reference.</p>
<h3 id="heading-putting-it-all-together">Putting It All Together</h3>
<p>Here's a complete example you can drop into an Activity. Just remember to handle the runtime permissions!</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// In your Activity class</span>

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MainActivity</span> : <span class="hljs-type">AppCompatActivity</span></span>() {

    <span class="hljs-comment">// ... (lazy properties for adapter and scanner from above)</span>

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCreate</span><span class="hljs-params">(savedInstanceState: <span class="hljs-type">Bundle</span>?)</span></span> {
        <span class="hljs-keyword">super</span>.onCreate(savedInstanceState)
        <span class="hljs-comment">// ... your UI setup ...</span>

        <span class="hljs-comment">// Example: Start scan on button click</span>
        <span class="hljs-keyword">val</span> startButton = findViewById&lt;Button&gt;(R.id.startButton)
        startButton.setOnClickListener {
            <span class="hljs-comment">// You MUST request permissions before calling this!</span>
            startBleScan()
        }

        <span class="hljs-comment">// Example: Stop scan on another button click</span>
        <span class="hljs-keyword">val</span> stopButton = findViewById&lt;Button&gt;(R.id.stopButton)
        stopButton.setOnClickListener {
            stopBleScan()
        }
    }

    <span class="hljs-comment">// ... (scanCallback, startBleScan, stopBleScan functions from above)</span>

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onPause</span><span class="hljs-params">()</span></span> {
        <span class="hljs-keyword">super</span>.onPause()
        <span class="hljs-comment">// Always stop scanning when the activity is not visible.</span>
        stopBleScan()
    }
}
</code></pre>
<p>The complete example above shows how all the pieces fit together in a real Activity. This is a minimal but functional Bluetooth scanner that you can actually run. Let's highlight a few important patterns we're using here.</p>
<p>First, notice how we're tying the scan lifecycle to user actions through button clicks. This is a common pattern: the user explicitly starts and stops the scan, giving them control over when the app is using Bluetooth. This is both good UX and good for battery life, as the scan only runs when the user wants it to.</p>
<p>But here's the really important part: the <code>onPause()</code> override. This is a critical safety net. When your Activity goes into the background (maybe the user pressed the home button, or they switched to another app), <code>onPause()</code> is called, and we immediately stop the scan. This is essential because if the user can't see your app, they don't need scan results, and there's no reason to drain their battery. This pattern ensures that even if the user forgets to press the "Stop" button, the scan won't run forever in the background.</p>
<p>You might be wondering, "What about <code>onResume()</code>? Shouldn't we restart the scan when the user comes back?" That's a design decision. In some apps, you might want to automatically restart scanning in <code>onResume()</code>. In others, you might want the user to explicitly press "Start" again. It depends on your use case. For a device-finding app where the user is actively searching, auto-resuming makes sense. For a monitoring app that runs in the background, you might want more explicit control.</p>
<p>One crucial thing we haven't shown in this example is runtime permission handling. Remember those permissions we declared in the manifest? On Android 6.0 and above, you can't just declare them, you have to actually request them from the user at runtime. Before calling <code>startBleScan()</code>, you should check if you have the necessary permissions and, if not, request them using <code>ActivityCompat.requestPermissions()</code>. If you try to start a scan without the proper permissions, it will fail silently (or loudly, depending on the Android version), and you'll be left scratching your head wondering why nothing is working.</p>
<p>And there you have it! You've just built your first AOSP 16 passive Bluetooth scanner. It's lean, it's mean, and it's incredibly power-efficient. The scanner listens silently for BLE advertisements, reports them through your callback, and stops gracefully when it's not needed.</p>
<p>Now, let's move on to our next topic: what to do when things go wrong. It's time to talk about breakups... Bluetooth bond breakups, that is.</p>
<h2 id="heading-deep-dive-2-bluetooth-bond-loss-reasons">Deep Dive #2: Bluetooth Bond Loss Reasons</h2>
<p>Ah, the Bluetooth bond. It's a beautiful, sacred thing. It's the digital equivalent of exchanging friendship bracelets. When you bond your phone with your headphones, you're creating a long-term, trusted relationship. They share secret keys, they remember each other, and they promise to connect automatically, saving you the hassle of pairing every single time. It's a beautiful romance.</p>
<p>Until it's not.</p>
<p>Suddenly, one day, they just... forget each other. The connection is gone. The trust is broken. And your app is left in the middle, trying to play therapist, with no idea what went wrong. You've been ghosted. And until now, Android has been no help. You'd get a notification that the bond state is now BOND_NONE, but that's it. No explanation. No closure. Just the cold, hard silence of a failed connection.</p>
<h3 id="heading-finally-some-closure">Finally, Some Closure!</h3>
<p>But our friends on the Android team have clearly been through some tough breakups, because in AOSP 16, they've given us the gift of closure. Introducing BluetoothDevice.EXTRA_BOND_LOSS_REASON. It's a new extra that comes with the ACTION_BOND_STATE_CHANGED broadcast, and it's here to tell you why the bond was lost. It's like getting a breakup text that actually explains what happened!</p>
<p>Now, when a bond is broken, you can get a specific reason code. Think of them as the classic breakup excuses, but for Bluetooth:</p>
<table><tbody><tr><td><p><strong>Reason Code (Illustrative)</strong></p></td><td><p><strong>What it Actually Means</strong></p></td></tr><tr><td><p>BOND_LOSS_REASON_BREDR_AUTH_FAILURE</p></td><td><p>Indicates that the reason for the bond loss is BREDR authentication failure.</p></td></tr><tr><td><p>BOND_LOSS_REASON_BREDR_INCOMING_PAIRING</p></td><td><p>Indicates that the reason for the bond loss is BREDR pairing failure.</p></td></tr><tr><td><p>BOND_LOSS_REASON_LE_ENCRYPT_FAILURE</p></td><td><p>Indicates that the reason for the bond loss is LE encryption failure.</p></td></tr><tr><td><p>BOND_LOSS_REASON_LE_INCOMING_PAIRING</p></td><td><p>Indicates that the reason for the bond loss is LE pairing failure.</p></td></tr></tbody></table>

<h3 id="heading-the-code-playing-detective">The Code: Playing Detective</h3>
<p>So, how do we get this juicy gossip? We need to set up a BroadcastReceiver to listen for bond state changes.</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// Create a BroadcastReceiver to listen for bond state changes</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> bondStateReceiver = <span class="hljs-keyword">object</span> : BroadcastReceiver() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onReceive</span><span class="hljs-params">(context: <span class="hljs-type">Context</span>, intent: <span class="hljs-type">Intent</span>)</span></span> {
        <span class="hljs-keyword">if</span> (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
            <span class="hljs-keyword">val</span> device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
            <span class="hljs-keyword">val</span> bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR)
            <span class="hljs-keyword">val</span> previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR)

            <span class="hljs-comment">// Check if we went from bonded to not bonded</span>
            <span class="hljs-keyword">if</span> (bondState == BluetoothDevice.BOND_NONE &amp;&amp; previousBondState == BluetoothDevice.BOND_BONDED) {
                Log.d(<span class="hljs-string">"BondBreakup"</span>, <span class="hljs-string">"We got dumped by <span class="hljs-subst">${device?.address}</span>!"</span>)

                <span class="hljs-comment">// Now, let's find out why...</span>
                <span class="hljs-keyword">val</span> reason = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON, -<span class="hljs-number">1</span>)

                <span class="hljs-keyword">when</span> (reason) {
                    <span class="hljs-comment">// Note: The actual constant values are in the Android SDK</span>
                    BluetoothDevice.BOND_LOSS_REASON_REMOTE_DEVICE_REMOVED -&gt; {
                        Log.d(<span class="hljs-string">"BondBreakup"</span>, <span class="hljs-string">"Reason: The remote device removed the bond."</span>)
                        <span class="hljs-comment">// You could show a message to the user: "Your headphones seem to have forgotten you. Please try pairing again."</span>
                    }
                    <span class="hljs-comment">// ... handle other reasons ...</span>
                    <span class="hljs-keyword">else</span> -&gt; {
                        Log.d(<span class="hljs-string">"BondBreakup"</span>, <span class="hljs-string">"Reason: It's complicated (Unknown reason code: <span class="hljs-variable">$reason</span>)"</span>)
                    }
                }
            }
        }
    }
}

<span class="hljs-comment">// In your Activity or Service, register the receiver</span>
<span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onResume</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">super</span>.onResume()
    <span class="hljs-keyword">val</span> filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
    registerReceiver(bondStateReceiver, filter)
}

<span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onPause</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">super</span>.onPause()
    <span class="hljs-comment">// Don't forget to unregister!</span>
    unregisterReceiver(bondStateReceiver)
}
</code></pre>
<p>The code above implements a detective system for Bluetooth bond breakups, and it's more sophisticated than it might first appear. Let's walk through how this broadcast receiver pattern works and why it's so powerful.</p>
<p>First, we're creating a <code>BroadcastReceiver</code>, which is Android's way of letting your app listen for system-wide events. Think of it as subscribing to a notification service, whenever something interesting happens in the Android system (like a bond state change), the system broadcasts an "intent" to all registered listeners. Our receiver is one of those listeners.</p>
<p>In the <code>onReceive()</code> method, we first check if the incoming intent's action is <code>ACTION_BOND_STATE_CHANGED</code>. This is crucial because broadcast receivers can potentially receive many different types of intents, and we only care about bond state changes. Once we've confirmed this is the right type of event, we extract the relevant information from the intent using <code>getParcelableExtra()</code> and <code>getIntExtra()</code>.</p>
<p>The <code>device</code> object tells us which Bluetooth device this event is about. After all, you might be bonded to multiple devices (your headphones, your smartwatch, your car), and we need to know which one just broke up with us. The <code>bondState</code> tells us the current state (are we bonded, bonding, or not bonded?), and <code>previousBondState</code> tells us what the state was before this change occurred.</p>
<p>The key logic happens in our conditional check: <code>if (bondState == BluetoothDevice.BOND_NONE &amp;&amp; previousBondState == BluetoothDevice.BOND_BONDED)</code>. This is checking for the specific transition from "bonded" to "not bonded," which is the digital equivalent of a breakup. We're not interested in the bonding process itself (going from none to bonding to bonded) – we only care about when an existing bond is lost.</p>
<p>Once we've detected a breakup, we extract the new <code>EXTRA_BOND_LOSS_REASON</code> from the intent. This is the star feature from AOSP 16 that finally gives us closure. The reason code tells us exactly why the bond was lost – was it the remote device that ended things? Did the user manually forget the device? Did authentication fail? Each reason code corresponds to a different scenario, and you can handle each one appropriately.</p>
<p>In the example above, we're using a when expression to handle different reason codes. For BOND_LOSS_REASON_BREDR_INCOMING_PAIRING, we know the other device initiated the breakup, so we can show a helpful message like "Your headphones seem to have forgotten you. Please try pairing again." For other reasons, you'd add more branches to handle them specifically.</p>
<p>Now, notice the lifecycle management at the bottom. We register our receiver in <code>onResume()</code> and unregister it in <code>onPause()</code>. This is critical: if you forget to unregister a broadcast receiver, it will continue to receive broadcasts even after your Activity is destroyed, which can cause memory leaks and crashes. The pattern of registering in <code>onResume()</code> and unregistering in <code>onPause()</code> ensures that we only listen for bond changes when our Activity is visible and active.</p>
<p>This is a huge step forward for debugging and for user experience. Instead of just telling the user "Connection failed," you can now give them actionable advice based on the specific reason the bond was lost. It's like being a helpful, informed relationship counselor instead of a confused bystander who can only shrug and say "I don't know what happened."</p>
<p>Now that we've dealt with the emotional baggage of breakups, let's move on to something a little more lighthearted: speed dating for Bluetooth devices.</p>
<h2 id="heading-deep-dive-3-service-uuids-from-advertisements">Deep Dive #3: Service UUIDs from Advertisements</h2>
<p>Let's talk about finding a compatible partner... for your app. In the world of BLE, not all devices are created equal. A heart rate monitor is very different from a smart lightbulb. So how does your app know if it's talking to the right kind of device? The answer is the Service UUID.</p>
<h3 id="heading-what-in-the-world-is-a-service-uuid">What in the World is a Service UUID?</h3>
<p>A Service UUID (Universally Unique Identifier) is like a device's job title. It's a unique, 128-bit number that says, "I am a device that provides a Heart Rate Service" or "I am a device that provides a Battery Service." It's the single most important piece of information for determining what a device can do.</p>
<h3 id="heading-the-old-way-the-awkward-first-date">The Old Way: The Awkward First Date</h3>
<p>Traditionally, finding out a device's services was a whole ordeal. It was like going on a full, three-course dinner date just to find out the other person's job. The process went something like this:</p>
<ol>
<li><p>Scan: Find the device.</p>
</li>
<li><p>Connect: Establish a connection (a slow and power-hungry process).</p>
</li>
<li><p>Discover Services: Ask the device, "So... what do you do for a living?" and wait for it to list all its services.</p>
</li>
<li><p>Evaluate: Check if the list of services contains the one you're interested in.</p>
</li>
<li><p>Disconnect (or stay connected): If it's not the right device, you have to break up (disconnect) and move on. What a waste of time and energy!</p>
</li>
</ol>
<p>This is incredibly inefficient, especially if you're in a crowded room with dozens of BLE devices and you're only looking for one specific type.</p>
<h3 id="heading-the-new-way-the-glorious-name-tag">The New Way: The Glorious Name Tag</h3>
<p>Wouldn't it be great if everyone at a party just wore a name tag with their job title on it? That's exactly what AOSP 16 has given us with BluetoothDevice.EXTRA_UUID_LE. Many BLE devices are already polite enough to include their primary service UUID in their advertisement packets. It's their way of shouting, "I'M A HEART RATE MONITOR!" to the whole room.</p>
<p>Before AOSP 16, getting this information out of the advertisement packet was a messy, manual process of parsing the raw byte array of the scan record. It was doable, but it was the kind of code that you'd write once, pray it worked, and never touch again.</p>
<p>Now, Android does the dirty work for us! The system automatically parses the advertising data and, if it finds any service UUIDs, it conveniently hands them to you in the ScanResult.</p>
<h3 id="heading-the-code-reading-the-name-tag">The Code: Reading the Name Tag</h3>
<p>This new feature makes our ScanCallback even more powerful. We can now check the device's job title the moment we discover it, without ever having to connect.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> scanCallback = <span class="hljs-keyword">object</span> : ScanCallback() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanResult</span><span class="hljs-params">(callbackType: <span class="hljs-type">Int</span>, result: <span class="hljs-type">ScanResult</span>)</span></span> {
        Log.d(<span class="hljs-string">"BleSpeedDating"</span>, <span class="hljs-string">"Found device: <span class="hljs-subst">${result.device.address}</span>"</span>)

        <span class="hljs-comment">// Let's check their name tag!</span>
        <span class="hljs-keyword">val</span> serviceUuids = result.scanRecord?.serviceUuids
        <span class="hljs-keyword">if</span> (serviceUuids.isNullOrEmpty()) {
            Log.d(<span class="hljs-string">"BleSpeedDating"</span>, <span class="hljs-string">"This one is mysterious. No service UUIDs in the ad."</span>)
            <span class="hljs-keyword">return</span>
        }

        <span class="hljs-comment">// Define the UUID we're looking for (e.g., the standard Heart Rate Service UUID)</span>
        <span class="hljs-keyword">val</span> heartRateServiceUuid = ParcelUuid.fromString(<span class="hljs-string">"0000180D-0000-1000-8000-00805F9B34FB"</span>)

        <span class="hljs-keyword">if</span> (serviceUuids.contains(heartRateServiceUuid)) {
            Log.d(<span class="hljs-string">"BleSpeedDating"</span>, <span class="hljs-string">"It's a match! This is a heart rate monitor. Let's connect!"</span>)
            <span class="hljs-comment">// Now you can proceed to connect to result.device, knowing it's the right one.</span>
            stopBleScan() <span class="hljs-comment">// We found what we were looking for</span>
            <span class="hljs-comment">// connectToDevice(result.device)</span>
        } <span class="hljs-keyword">else</span> {
            Log.d(<span class="hljs-string">"BleSpeedDating"</span>, <span class="hljs-string">"Not a match. Moving on."</span>)
        }
    }

    <span class="hljs-comment">// ... onScanFailed ...</span>
}
</code></pre>
<p>The code above demonstrates the power of reading service UUIDs directly from advertisement data, and it's a game-changer for device discovery. Let's break down exactly what's happening and why this is such a significant improvement.</p>
<p>When we receive a scan result in our callback, the <code>result</code> object contains a <code>scanRecord</code> property. This scan record is essentially the raw advertisement packet that the BLE device broadcast into the air.</p>
<p>Before AOSP 16, if you wanted to extract service UUIDs from this data, you'd have to manually parse the byte array, understand the BLE advertisement format, handle different data types, and pray you didn't make an off-by-one error. It was the kind of code that worked once and then you never touched it again out of fear.</p>
<p>Now, with the improvements in AOSP 16, Android does all that messy parsing for us. We can simply call <code>result.scanRecord?.serviceUuids</code> and get back a nice, clean list of <code>ParcelUuid</code> objects. The safe call operator (<code>?.</code>) is important here because not all devices include a scan record in their results, and we need to handle that gracefully.</p>
<p>After retrieving the service UUIDs, we check if the list is null or empty. Some devices don't include service UUIDs in their advertisements. They might be using a proprietary format, or they might just be poorly configured. If there are no UUIDs, we log a message and return early. There's no point in continuing if we can't identify what the device does.</p>
<p>Next, we define the UUID we're looking for. In this example, we're searching for heart rate monitors, so we use the standard Heart Rate Service UUID: <code>0000180D-0000-1000-8000-00805F9B34FB</code>. This is a UUID defined by the Bluetooth SIG (Special Interest Group), and any compliant heart rate monitor will advertise this UUID. You can find a complete list of standard service UUIDs in the Bluetooth specifications, or you can use custom UUIDs if you're building your own BLE peripherals.</p>
<p>The magic happens in the <code>if (serviceUuids.contains(heartRateServiceUuid))</code> check. This is where we're doing our speed dating: we're checking the device's "name tag" to see if it matches what we're looking for.</p>
<p>If it does, we've found our match! We can immediately stop scanning (because why keep looking when we've found what we need?) and proceed to connect to the device. We know, with certainty, that this device is a heart rate monitor, so we won't waste time and battery connecting to random devices only to discover they're not what we need.</p>
<p>If the UUID doesn't match, we simply log "Not a match" and move on. The callback will be called again when the next device is found, and we'll repeat this process until we find our heart rate monitor or the user stops the scan.</p>
<p>This is a massive performance improvement over the old approach. Previously, you'd have to connect to every device you found, perform service discovery (which involves multiple round-trip communications with the device), check if it has the services you need, and then disconnect if it doesn't. Each connection attempt takes time, uses battery, and creates unnecessary radio traffic.</p>
<p>Now, you can filter and identify devices at lightning speed, all at the scanning stage. No more awkward first dates where you connect to a smart lightbulb thinking it might be a fitness tracker. Just efficient, targeted connections.</p>
<p>This is particularly useful for apps that need to find a specific type of sensor or peripheral in a sea of irrelevant devices. Imagine you're in a hospital with hundreds of BLE-enabled medical devices, or in a smart home with dozens of sensors and actuators. Being able to instantly identify the right device from its advertisement is the difference between a responsive, professional app and one that feels sluggish and unreliable.</p>
<p>We've now met all three of our Bluetooth musketeers: passive scanning for battery efficiency, bond loss reasons for better debugging, and service UUIDs from advertisements for faster device identification. But our journey isn't over. It's time to venture into the deep woods of advanced scanning techniques.</p>
<h2 id="heading-advanced-topics-filtering-batching-and-other-sorcery">Advanced Topics: Filtering, Batching, and Other Sorcery</h2>
<p>Alright, you've mastered the basics. You can scan passively, you can get closure on your connection breakups, and you can speed-date devices like a pro. You're no longer a Bluetooth padawan. It's time to become a Jedi Master.</p>
<p>Let's dive into the advanced arts of filtering, batching, and other optimization sorcery that will make your app a true battery-saving champion.</p>
<h3 id="heading-hardware-filtering-your-personal-assistant">Hardware Filtering: Your Personal Assistant</h3>
<p>Imagine you're a celebrity, and you've hired a personal assistant. You don't want to be bothered by every single person who wants an autograph. So, you give your assistant a list: "Only let me know if you see my agent or my mom." Your assistant then stands at the door and only bothers you when someone on the list shows up.</p>
<p>This is exactly what hardware filtering does. Instead of your app's code (the celebrity) being woken up for every single Bluetooth device the radio sees, you can offload the filtering logic to the Bluetooth controller itself (the personal assistant). This is a feature that's been around since Android 6.0, but it's more important than ever.</p>
<p>Why is this so great? Because your app's code can stay asleep. The main processor (the AP) doesn't have to wake up every time a random Bluetooth toothbrush advertises itself. The Bluetooth controller, which is much more power-efficient, handles the filtering. The AP only wakes up when the controller finds a device that matches your criteria.</p>
<h3 id="heading-the-code-building-your-vip-list">The Code: Building Your VIP List</h3>
<p>You implement this using ScanFilter. You can filter by a device's name, its MAC address, or, most usefully, by the Service UUID it's advertising.</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// We only want to be bothered if we see a heart rate monitor.</span>
<span class="hljs-keyword">val</span> heartRateServiceUuid = ParcelUuid.fromString(<span class="hljs-string">"0000180D-0000-1000-8000-00805F9B34FB"</span>)

<span class="hljs-keyword">val</span> filter = ScanFilter.Builder()
    .setServiceUuid(heartRateServiceUuid)
    .build()

<span class="hljs-keyword">val</span> scanFilters: List&lt;ScanFilter&gt; = listOf(filter)

<span class="hljs-comment">// Now, when you start your scan, pass in this list</span>
bleScanner.startScan(scanFilters, scanSettings, scanCallback)
</code></pre>
<p>The code above shows how to create a hardware-level filter that dramatically improves both battery life and app performance. Let's dive deep into what's happening here and why this is such a powerful technique.</p>
<p>We start by defining the service UUID we're interested in – in this case, the standard Heart Rate Service UUID. This is the same UUID we used in the previous example, but now we're using it in a fundamentally different way. Instead of checking the UUID in our app's code after receiving scan results, we're telling the Bluetooth hardware itself to only report devices that match this UUID.</p>
<p>The <code>ScanFilter.Builder()</code> is our tool for constructing this filter. It's a builder pattern, which means we can chain multiple methods together to configure exactly what we're looking for. In this example, we're calling <code>setServiceUuid(heartRateServiceUuid)</code>, which tells the filter to only match devices that advertise this specific service.</p>
<p>But the builder has many other options you can use:</p>
<ul>
<li><p><code>setDeviceName()</code> – Match devices with a specific name (like "My Heart Monitor")</p>
</li>
<li><p><code>setDeviceAddress()</code> – Match a specific device by its MAC address (useful if you've already paired with a device and want to find it again)</p>
</li>
<li><p><code>setManufacturerData()</code> – Match devices based on manufacturer-specific data in their advertisements</p>
</li>
<li><p><code>setServiceData()</code> – Match based on service data included in the advertisement</p>
</li>
</ul>
<p>You can even combine multiple criteria in a single filter. For example, you could create a filter that matches devices with a specific service UUID <em>and</em> a specific manufacturer ID. The more specific your filter, the fewer false positives you'll get.</p>
<p>After building our filter, we create a list containing it. Why a list? Because you can have multiple filters, and a device will match if it satisfies <em>any</em> of the filters in the list. For instance, you might create one filter for heart rate monitors and another for blood pressure monitors, and your scan will report devices that match either one. This is an OR operation: the device doesn't need to match all filters, just one of them.</p>
<p>Finally, we pass this list of filters to <code>startScan()</code> along with our scan settings and callback. This is where the magic happens. When you provide filters, Android doesn't just filter the results in your app's code. It pushes these filters down to the Bluetooth controller hardware itself. This means the filtering happens at the lowest level, before your app is even notified.</p>
<p>Here's why this is so powerful: without filters, every time the Bluetooth radio hears an advertisement from <em>any</em> device (your neighbor's smart toaster, someone's fitness tracker walking by, the Bluetooth speaker three rooms away), it has to wake up your app's process, deliver the scan result, and let your code decide if it cares about this device. Each of these wake-ups costs battery and processing time.</p>
<p>With hardware filters, the Bluetooth controller silently ignores all the devices that don't match your criteria. Your app stays asleep. The main processor stays asleep. Only when a heart rate monitor is detected does the hardware wake up your app and deliver the result. It's like having a bouncer at a club who only lets in people on the VIP list. Everyone else is turned away at the door, and you never even know they were there.</p>
<p>By using a <code>ScanFilter</code>, you're telling the hardware, "Don't wake me up unless you see a heart rate monitor." It's the ultimate power-saving move for background scanning. Combined with passive scanning and batch reporting, you can create a Bluetooth scanning system that runs for hours or even days with minimal battery impact. This is how professional-grade apps handle long-term device monitoring without destroying battery life.</p>
<h3 id="heading-batch-scanning-the-daily-report">Batch Scanning: The Daily Report</h3>
<p>Let's go back to our celebrity analogy. Sometimes, you don't need to be interrupted the moment your mom shows up. You'd rather just get a report at the end of the day: "Today, your mom stopped by twice, and your agent called once." This is batch scanning.</p>
<p>Instead of delivering scan results to your app in real-time, the Bluetooth controller can collect them and deliver them in a big batch. This is another incredible power-saving feature. Your app can sleep for long periods, then wake up, process a whole bunch of results at once, and go back to sleep.</p>
<p>You enable this with the setReportDelay() method in your ScanSettings.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> scanSettings = ScanSettings.Builder()
    <span class="hljs-comment">// ... other settings ...</span>
    <span class="hljs-comment">// Deliver results every 5 seconds (5000 milliseconds)</span>
    .setReportDelay(<span class="hljs-number">5000</span>)
    .build()
</code></pre>
<p>When you use a report delay, your onScanResult callback will be replaced by onBatchScanResults, which gives you a List&lt;ScanResult&gt;.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> scanCallback = <span class="hljs-keyword">object</span> : ScanCallback() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onBatchScanResults</span><span class="hljs-params">(results: <span class="hljs-type">List</span>&lt;<span class="hljs-type">ScanResult</span>&gt;)</span></span> {
        Log.d(<span class="hljs-string">"BatchScanner"</span>, <span class="hljs-string">"Here's your daily report! Found <span class="hljs-subst">${results.size}</span> devices."</span>)
        <span class="hljs-keyword">for</span> (result <span class="hljs-keyword">in</span> results) {
            <span class="hljs-comment">// Process each result</span>
        }
    }

    <span class="hljs-comment">// ... onScanFailed ...</span>
}
</code></pre>
<p>The batch scanning mechanism shown above is one of the most underutilized power-saving features in Android Bluetooth, and understanding how it works can transform your app's battery profile. Let's break down exactly what's happening under the hood and when you should use this technique.</p>
<p>When you set a report delay of 5000 milliseconds (5 seconds) in the code above, you're fundamentally changing how the scanning pipeline works. Instead of the Bluetooth controller immediately waking up your app every time it sees a device, it acts like a diligent assistant taking notes. For those 5 seconds, the controller silently collects every scan result it encounters, storing them in its own internal buffer. Your app remains completely asleep during this time – no CPU cycles wasted, no battery drained by context switches or process wake-ups.</p>
<p>After the 5-second delay expires, the controller delivers all the accumulated results in one batch to your <code>onBatchScanResults()</code> callback. This is where the power savings come from: instead of waking up your app 50 times if 50 devices were detected, it wakes up once and hands you all 50 results at the same time. Your app can then efficiently process this batch – maybe updating a UI list, logging the data, or checking for specific devices – and then go back to sleep until the next batch arrives.</p>
<p>The <code>results</code> parameter in <code>onBatchScanResults()</code> is a <code>List&lt;ScanResult&gt;</code>, and each <code>ScanResult</code> in the list represents a single advertisement that was heard during the batching period. It's important to note that if the same device advertises multiple times during the delay period, you might receive multiple results for that device in the batch. The list isn't automatically deduplicated – that's your job if you need it.</p>
<p>In the example above, we're simply logging the number of devices found and then iterating through each result. In a real application, you might want to do more sophisticated processing. For instance, you could build a map of devices keyed by MAC address to track how many times each device advertised, calculate average RSSI values to estimate distance, or filter the batch to only process devices that meet certain criteria.</p>
<p><strong>Warning:</strong> Batch scanning is a powerful tool, but it's not for every situation. If you need to react to a device's presence immediately (for example, if you're building a "find my keys" app where the user is actively searching), a report delay is not your friend. The user doesn't want to wait 5 seconds to see results – they want instant feedback. In these cases, set <code>setReportDelay(0)</code> for immediate reporting.</p>
<p>But for long-term monitoring or data collection scenarios, batch scanning is a battery's best friend. Consider these use cases:</p>
<ul>
<li><p><strong>Background presence monitoring</strong>: Your app checks every minute to see if the user's smartwatch is still in range, but doesn't need second-by-second updates.</p>
</li>
<li><p><strong>Environmental sensing</strong>: You're collecting data from temperature sensors throughout a building and only need to update your dashboard every 30 seconds.</p>
</li>
<li><p><strong>Beacon analytics</strong>: You're tracking how many people pass by a retail location based on their phone's BLE advertisements, and you aggregate the data every 10 seconds.</p>
</li>
</ul>
<p>The sweet spot for report delay depends on your use case. Too short (like 1 second), and you're not getting much benefit, you're still waking up frequently. Too long (like 60 seconds), and your app might feel unresponsive or miss time-sensitive events. For most background monitoring tasks, delays between 5 and 30 seconds work well.</p>
<p>One more thing to be aware of: batch scanning has limits. The Bluetooth controller has a finite buffer for storing scan results. If you set a very long delay and you're in an environment with hundreds of BLE devices, the buffer might fill up before the delay expires. When this happens, the oldest results get dropped. Android doesn't give you a warning when this occurs, so if you're missing data, consider reducing your report delay or using more aggressive filters to reduce the number of results being collected.</p>
<h3 id="heading-onfoundonlost-the-drama-of-presence">OnFound/OnLost: The Drama of Presence</h3>
<p>Since Android 8.0, scanning has gotten even more dramatic. You can now ask the hardware to not only tell you when it finds a device, but also when it loses one. This is done using the CALLBACK_TYPE_FIRST_MATCH and CALLBACK_TYPE_MATCH_LOST flags in your ScanSettings.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> scanSettings = ScanSettings.Builder()
    .setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH or ScanSettings.CALLBACK_TYPE_MATCH_LOST)
    .build()
</code></pre>
<p>Now, in your ScanCallback, the callbackType parameter in onScanResult will tell you what happened.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanResult</span><span class="hljs-params">(callbackType: <span class="hljs-type">Int</span>, result: <span class="hljs-type">ScanResult</span>)</span></span> {
    <span class="hljs-keyword">when</span> (callbackType) {
        ScanSettings.CALLBACK_TYPE_FIRST_MATCH -&gt; {
            Log.d(<span class="hljs-string">"PresenceDetector"</span>, <span class="hljs-string">"Found them! <span class="hljs-subst">${result.device.address}</span> has entered the building."</span>)
        }
        ScanSettings.CALLBACK_TYPE_MATCH_LOST -&gt; {
            Log.d(<span class="hljs-string">"PresenceDetector"</span>, <span class="hljs-string">"They're gone! <span class="hljs-subst">${result.device.address}</span> has left the building."</span>)
        }
    }
}
</code></pre>
<p>The presence detection mechanism shown above represents a fundamental shift in how we think about Bluetooth scanning. Instead of treating scanning as a continuous stream of "here's what I see right now," we're now working with events: "this device appeared" and "this device disappeared." Let's dive deep into how this works and why it's so powerful.</p>
<p>When you set the callback type using the bitwise OR operator (<code>or</code> in Kotlin, <code>|</code> in Java), you're telling the Bluetooth hardware to track the presence state of devices over time. The code <code>CALLBACK_TYPE_FIRST_MATCH or CALLBACK_TYPE_MATCH_LOST</code> combines both flags, meaning you want to be notified both when a device first appears and when it disappears. You can use these flags individually if you only care about one type of event, but using both together gives you complete presence awareness.</p>
<p>Let's understand what "first match" and "match lost" actually mean. When the Bluetooth controller hears an advertisement from a device that matches your filters for the first time, it triggers a <code>CALLBACK_TYPE_FIRST_MATCH</code> event. This is different from <code>CALLBACK_TYPE_ALL_MATCHES</code> (the default), which would trigger every single time the device advertises. A device might advertise multiple times per second, so the difference is significant. With <code>FIRST_MATCH</code>, you get one notification when the device enters your scanning range, not a flood of notifications as it continues to advertise.</p>
<p>The <code>CALLBACK_TYPE_MATCH_LOST</code> event is even more interesting. The Bluetooth controller keeps track of when it last heard from each device. If a device stops advertising (because it moved out of range, was turned off, or its battery died), the controller notices the absence and triggers a <code>MATCH_LOST</code> event. This happens automatically: you don't have to manually track timestamps or implement timeout logic in your app. The hardware does it for you.</p>
<p>But how does the hardware know when a device is "lost"? It uses an internal timeout. If the controller hasn't heard from a device for a certain period (typically a few seconds, though the exact duration is implementation-dependent and not exposed to apps), it considers the device lost. This means there's a slight delay between when a device actually leaves range and when you get the <code>MATCH_LOST</code> callback, but this delay is usually acceptable for presence detection use cases.</p>
<p>In the code example above, we're using a <code>when</code> expression to handle the different callback types. When we receive a <code>FIRST_MATCH</code>, we know the device has just entered our scanning range, so we log "Found them!" This is perfect for triggering actions like unlocking a door when your phone comes near, or starting to sync data when your fitness tracker is detected.</p>
<p>When we receive a <code>MATCH_LOST</code>, we know the device has left our scanning range or stopped advertising, so we log "They're gone!" This is ideal for triggering cleanup actions like locking the door when your phone leaves, or stopping a data sync when your tracker disconnects.</p>
<p>This is incredibly useful for presence detection scenarios. Is your smart lock in range? Is your fitness tracker still connected? Is the user's phone nearby? Now you can know, with hardware-level certainty, and you can react to changes in presence without constantly polling or maintaining complex state machines in your app code.</p>
<p>Here's a practical example of how you might use this in a smart home app:</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> presenceCallback = <span class="hljs-keyword">object</span> : ScanCallback() {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanResult</span><span class="hljs-params">(callbackType: <span class="hljs-type">Int</span>, result: <span class="hljs-type">ScanResult</span>)</span></span> {
        <span class="hljs-keyword">when</span> (callbackType) {
            ScanSettings.CALLBACK_TYPE_FIRST_MATCH -&gt; {
                <span class="hljs-comment">// User's phone detected - they're home!</span>
                Log.d(<span class="hljs-string">"SmartHome"</span>, <span class="hljs-string">"Welcome home! Unlocking door and turning on lights."</span>)
                unlockFrontDoor()
                turnOnLights()
                adjustThermostat(COMFORTABLE_TEMP)
            }
            ScanSettings.CALLBACK_TYPE_MATCH_LOST -&gt; {
                <span class="hljs-comment">// User's phone is gone - they left!</span>
                Log.d(<span class="hljs-string">"SmartHome"</span>, <span class="hljs-string">"Goodbye! Locking door and entering away mode."</span>)
                lockFrontDoor()
                turnOffLights()
                adjustThermostat(ENERGY_SAVING_TEMP)
                armSecuritySystem()
            }
        }
    }

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onScanFailed</span><span class="hljs-params">(errorCode: <span class="hljs-type">Int</span>)</span></span> {
        Log.e(<span class="hljs-string">"SmartHome"</span>, <span class="hljs-string">"Presence detection failed: <span class="hljs-variable">$errorCode</span>"</span>)
    }
}
</code></pre>
<p>One important consideration: <code>FIRST_MATCH</code> and <code>MATCH_LOST</code> are mutually exclusive with <code>CALLBACK_TYPE_ALL_MATCHES</code>. If you combine them with <code>ALL_MATCHES</code>, the behavior becomes undefined and varies by device. Stick to either <code>ALL_MATCHES</code> for continuous reporting, or <code>FIRST_MATCH</code>/<code>MATCH_LOST</code> for presence detection – don't try to use both at once.</p>
<p>Also, be aware that presence detection works best when combined with hardware filtering. If you're scanning for all devices without filters, the controller has to track the presence state of every single BLE device in range, which can overwhelm its internal tracking tables. Always use <code>ScanFilter</code> to narrow down which devices you care about when using presence detection.</p>
<p>By combining these advanced techniques – hardware filtering, batch scanning, and presence detection – you can build incredibly sophisticated and power-efficient Bluetooth applications. You're not just a developer anymore. You're a Bluetooth wizard, wielding the power to create apps that are aware of their surroundings, responsive to changes, and respectful of battery life.</p>
<p>Now, let's see where we can apply these magical powers in the real world.</p>
<h2 id="heading-real-world-use-cases-where-the-bluetooth-hits-the-road">Real-World Use Cases: Where the Bluetooth Hits the Road</h2>
<p>Okay, we've learned a ton of cool new tricks. We're basically Bluetooth black belts at this point. But what's the use of all this power if we don't use it for good (or at least for a cool app)? Let's explore some real-world scenarios where the new features in AOSP 16 can turn a good app into a great one.</p>
<h3 id="heading-1-the-find-my-everything-app">1. The "Find My Everything" App</h3>
<p>We've all been there. You're late for work, and your keys have decided to play a game of hide-and-seek in another dimension. This is the classic use case for a BLE tracker.</p>
<ul>
<li><p><strong>The Old Way:</strong> Your app would be constantly doing active scans, draining your battery while you frantically search. It would connect to every tracker in your house just to see if it's the right one.</p>
</li>
<li><p><strong>The AOSP 16 Way:</strong> Your app runs a passive scan in the background with a hardware filter for your tracker's specific Service UUID. The battery impact is minimal. When you open the app to find your keys, it already knows they're in the house because it's been listening silently. You hit the "Find" button, the app connects, and your keys start screaming from inside the couch cushions. And if the connection fails? Bond loss reason tells you if the tracker's battery died, so you're not looking for a dead device.</p>
</li>
</ul>
<h3 id="heading-2-the-smart-supermarket">2. The Smart Supermarket</h3>
<p>Imagine an app that gives you coupons for products as you walk past them in the store. This is the dream of proximity marketing, a dream that has been historically thwarted by, you guessed it, battery drain.</p>
<ul>
<li><p><strong>The Old Way:</strong> The app would need to constantly scan for beacons, turning the user's phone into a hot potato and a dead battery by the time they reach the checkout line.</p>
</li>
<li><p><strong>The AOSP 16 Way:</strong> The supermarket places BLE beacons in each aisle. Your app uses a passive, batched scan. It wakes up every minute or so, gets a list of all the beacons it has seen, and then goes back to sleep. When it sees you've been loitering in the cookie aisle for five minutes (it knows, it always knows), it uses the Service UUID from the advertisement to identify the "Cookie Aisle Beacon" and sends you a coupon for Oreos. It's targeted, it's efficient, and it doesn't kill your battery before you can pay.</p>
</li>
</ul>
<h3 id="heading-3-the-overly-attached-smart-home">3. The Overly-Attached Smart Home</h3>
<p>Your smart home should be, well, smart. It should know when you're home and when you've left. It should lock the door behind you and turn on the lights when you arrive.</p>
<ul>
<li><p><strong>The Old Way:</strong> You'd have to rely on GPS (a notorious battery hog) or Wi-Fi connections, which can be unreliable. BLE was an option, but constant scanning was a problem.</p>
</li>
<li><p><strong>The AOSP 16 Way:</strong> Your phone is the key. Your smart hub (acting as a central device) runs a continuous, low-power passive scan. When it sees your phone's BLE advertisement, it knows you're home. But what if you just walk by the house? This is where the OnFound/OnLost feature comes in. The hub can be configured to only trigger the "Welcome Home" sequence after it has seen your device consistently for a minute (OnFound), and to trigger the "Goodbye" sequence only after it hasn't seen you for five minutes (OnLost). It's a smarter, more reliable presence detection system that finally makes the smart home feel... smart.</p>
</li>
</ul>
<h3 id="heading-4-the-corporate-asset-tracker">4. The Corporate Asset Tracker</h3>
<p>In a large hospital or warehouse, keeping track of expensive, mobile equipment (like IV pumps or forklifts) is a huge challenge. BLE tags are the solution.</p>
<ul>
<li><p><strong>The Old Way:</strong> Employees would have to walk around with a tablet, doing active scans to take inventory. It's slow, manual, and inefficient.</p>
</li>
<li><p><strong>The AOSP 16 Way:</strong> A network of fixed BLE gateways is installed throughout the building. Each gateway is a simple device (like a Raspberry Pi) running a continuous passive scan. They collect all the advertisement data from the asset tags and send it to a central server. The server can now see, in real-time, that IV Pump #34 is in Room 201, and Forklift #3 is currently in the loading bay. No manual scanning required. It's a low-cost, low-power, real-time location system, all thanks to the efficiency of passive scanning.</p>
</li>
</ul>
<p>These are just a few examples. From fitness trackers to industrial sensors, the new Bluetooth features in AOSP 16 open up a world of possibilities for building apps that are not only powerful but also polite to your user's battery. Now, let's talk about how to make sure our shiny new app works on all devices, not just the new ones.</p>
<h2 id="heading-api-version-checking-how-to-not-crash-your-app">API Version Checking: How to Not Crash Your App</h2>
<p>So, you've built a beautiful, battery-sipping app using all the new hotness from AOSP 16's Q4 release. You're ready to ship it, become a millionaire, and retire to a private island. But then, a bug report comes in. Your app is crashing on a brand new Android 16 device. What gives?!</p>
<p>Welcome, my friend, to the wonderful world of API version checking. With Android's new release schedule, this has become more important (and slightly more complicated) than ever.</p>
<h3 id="heading-the-problem-a-tale-of-two-android-16s">The Problem: A Tale of Two Android 16s</h3>
<p>As we discussed, 2025 gave us two Android 16 releases:</p>
<ul>
<li><p><strong>The Q2 Release:</strong> The main "Baklava" release. Let's call this API level 36.0.</p>
</li>
<li><p><strong>The Q4 Release:</strong> The minor, feature-drop release. This is where our new Bluetooth toys live. Let's call this API level 36.1.</p>
</li>
</ul>
<p>Our new passive scanning API, setScanType(), only exists on 36.1 and later. If you try to call it on a device that's running the initial Q2 release (36.0), your app will crash with a NoSuchMethodError. It's the digital equivalent of asking for a menu item that was only added last night. The chef (your app) just gets confused and has a meltdown.</p>
<h3 id="heading-the-old-guard-sdkint">The Old Guard: SDK_INT</h3>
<p>For years, our trusty friend for checking API levels has been Build.VERSION.SDK_INT. It's simple and effective.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">if</span> (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.S) {
    <span class="hljs-comment">// Use an API from Android 12 (S) or higher</span>
}
</code></pre>
<p>But SDK_INT only knows about major releases. For both Android 16 Q2 and Q4, SDK_INT will just report 36. It has no idea about the minor version. It's like asking someone their age, and they just say "thirties." Not very specific.</p>
<h3 id="heading-the-new-hotness-sdkintfull">The New Hotness: SDK_INT_FULL</h3>
<p>To solve this, the Android team has given us a new, more precise tool: <code>Build.VERSION.SDK_INT_FULL</code>. This constant knows about both the major and minor version numbers. And to go with it, we have a new set of version codes: <code>Build.VERSION_CODES_FULL</code>.</p>
<p>So, to safely call our new passive scanning API, we need to do a more specific check:</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// Let's build our ScanSettings</span>
<span class="hljs-keyword">val</span> scanSettingsBuilder = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)

<span class="hljs-comment">// Now, let's check if we can go passive</span>
<span class="hljs-keyword">if</span> (Build.VERSION.SDK_INT_FULL &gt;= Build.VERSION_CODES_FULL.BAKLAVA_1) {
    Log.d(<span class="hljs-string">"ApiCheck"</span>, <span class="hljs-string">"This device is cool. Going passive."</span>)
    <span class="hljs-comment">// This is the new API from the Q4 release (36.1)</span>
    scanSettingsBuilder.setScanType(ScanSettings.SCAN_TYPE_PASSIVE)
} <span class="hljs-keyword">else</span> {
    Log.d(<span class="hljs-string">"ApiCheck"</span>, <span class="hljs-string">"This device is old school. Sticking to active scanning."</span>)
    <span class="hljs-comment">// Fallback for devices that don't have the new API</span>
    <span class="hljs-comment">// We don't need to do anything here, as active is the default</span>
}

<span class="hljs-keyword">val</span> scanSettings = scanSettingsBuilder.build()
</code></pre>
<h3 id="heading-graceful-degradation-the-art-of-falling-with-style">Graceful Degradation: The Art of Falling with Style</h3>
<p>This brings us to a crucial concept: graceful degradation. It means your app should still work on older devices, even if it can't use the latest and greatest features. It should fall back gracefully.</p>
<p>In our example above, if the setScanType method isn't available, we just... don't call it. The app will default to a normal, active scan. It won't be as battery-efficient, but it will still work. The user on the older device gets a functional app, and the user on the newer device gets a more optimized experience. Everybody wins.</p>
<p>Here's a table to help you remember when to use which check:</p>
<table><tbody><tr><td><p><strong>If you're using an API from...</strong></p></td><td><p><strong>Use this check...</strong></p></td></tr><tr><td><p>A major Android release (for example, Android 16 Q2)</p></td><td><p>if (SDK_INT &gt;= VERSION_CODES.BAKLAVA)</p></td></tr><tr><td><p>A minor, feature-drop release (for example, Android 16 Q4)</p></td><td><p>if (SDK_INT_FULL &gt;= VERSION_CODES_FULL.BAKLAVA_1)</p></td></tr></tbody></table>

<p>Mastering this new API checking is non-negotiable. It's the key to writing modern Android apps that are both innovative and stable. Now that we know how to build a robust app, let's talk about how to fix it when it inevitably breaks.</p>
<h2 id="heading-testing-and-debugging-the-fun-part-said-no-one-ever">Testing and Debugging: The Fun Part (Said No One Ever)</h2>
<p>There are two universal truths in software development:</p>
<ul>
<li><p>It works on my machine, and</p>
</li>
<li><p>It will break in the most spectacular way possible during a live demo.</p>
</li>
</ul>
<p>Bluetooth development, in particular, seems to delight in this second truth. It's a fickle, invisible force that seems to have a personal vendetta against developers.</p>
<p>So, how do we fight back? With a solid testing and debugging strategy. It's not glamorous, but it's the only way to stay sane.</p>
<h3 id="heading-the-emulator-a-land-of-make-believe">The Emulator: A Land of Make-Believe</h3>
<p>Android Studio's emulator is a fantastic tool. It's fast, it's convenient, and it can simulate all sorts of devices. And for Bluetooth? It can... sort of help. The emulator does have virtual Bluetooth support. You can enable it, and your app will think it has a Bluetooth adapter. It's great for testing your UI and making sure your app doesn't crash when it tries to get the BluetoothLeScanner.</p>
<p>But here's the catch: it's not real. The emulator can't actually interact with the radio waves in your room. You can't use it to find your real-life BLE headphones. For that, you need to venture into the real world.</p>
<h3 id="heading-the-real-world-where-the-bugs-live">The Real World: Where the Bugs Live</h3>
<p>There is no substitute for testing on real, physical devices. Every phone manufacturer has its own special flavor of Bluetooth stack, its own quirky antenna design, and its own unique way of making your life difficult. A scan that works perfectly on a Google Pixel might fail miserably on another brand. The only way to know is to test.</p>
<p>Your testing arsenal should include:</p>
<ul>
<li><p><strong>A variety of phones:</strong> Different brands, different Android versions. The more, the better.</p>
</li>
<li><p><strong>A variety of BLE peripherals:</strong> Don't just test with one type of device. Get a few different beacons, sensors, or wearables. You'll be amazed at how differently they behave.</p>
</li>
</ul>
<h3 id="heading-common-errors-the-usual-suspects">Common Errors: The Usual Suspects</h3>
<p>When your scan inevitably fails, it will give you an error code. Here are a few of the most common culprits:</p>
<table><tbody><tr><td><p><strong>Error Code</strong></p></td><td><p><strong>The Problem</strong></p></td><td><p><strong>How to Fix It</strong></p></td></tr><tr><td><p>SCAN_FAILED_ALREADY_STARTED</p></td><td><p>You tried to start a scan that was already running.</p></td><td><p>You got too excited. Make sure you're not calling startScan() multiple times without calling stopScan() in between.</p></td></tr><tr><td><p>SCAN_FAILED_APPLICATION_REGISTRATION_FAILED</p></td><td><p>Something is fundamentally wrong with your app's setup.</p></td><td><p>This is a vague and unhelpful error. It usually means you have a problem with your permissions or the system is just having a bad day. Try restarting Bluetooth.</p></td></tr><tr><td><p>SCAN_FAILED_INTERNAL_ERROR</p></td><td><p>The Bluetooth stack had a panic attack.</p></td><td><p>This is the classic "it's not you, it's me" error. It's an internal issue with the device's Bluetooth controller. There's not much you can do except try again later.</p></td></tr><tr><td><p>SCAN_FAILED_FEATURE_UNSUPPORTED</p></td><td><p>You tried to use a feature the hardware doesn't support.</p></td><td><p>You might be trying to use batch scanning on a device that doesn't support it. Use your API version checks!</p></td></tr></tbody></table>

<h3 id="heading-debugging-tools-your-ghost-hunting-kit">Debugging Tools: Your Ghost-Hunting Kit</h3>
<p>When things go wrong, you need the right tools to see what's happening in the invisible world of Bluetooth.</p>
<ul>
<li><p><strong>logcat:</strong> This is your best friend. Be generous with your log statements. Log when you start a scan, when you stop a scan, when you find a device, and when a scan fails. Create a filter for your app's tag so you can see the signal through the noise.</p>
</li>
<li><p><strong>Android's Bluetooth HCI Snoop Log:</strong> This is the holy grail of Bluetooth debugging. It's a developer option that records every single Bluetooth packet that goes in or out of your device. It's incredibly detailed and can be overwhelming, but it's the ultimate source of truth. You can open the generated log file in a tool like Wireshark to see the raw, unfiltered conversation between your phone and the BLE device. It's like having a wiretap on the radio waves.</p>
</li>
<li><p><strong>nRF Connect for Mobile:</strong> This is a free app from Nordic Semiconductor, and it's an essential tool for any BLE developer. It lets you scan for devices, see their advertising data, connect to them, and explore their GATT services. If your app can't find a device, the first thing you should do is see if nRF Connect can. If it can't, the problem is likely with the peripheral, not your app.</p>
</li>
</ul>
<p>Testing and debugging Bluetooth is a marathon, not a sprint. It requires patience, a methodical approach, and a healthy dose of self-deprecating humor. But with the right tools and techniques, you can tame the beast.</p>
<p>Now, let's talk about how to make sure our well-behaved app is also a good citizen when it comes to performance.</p>
<h2 id="heading-performance-and-best-practices-how-to-be-a-good-bluetooth-citizen">Performance and Best Practices: How to Be a Good Bluetooth Citizen</h2>
<p>Writing code that works is one thing. Writing code that works well, is efficient, and doesn't make your users want to throw their phone against a wall is another thing entirely. When it comes to Bluetooth, being a good citizen is all about one thing: battery, battery, battery.</p>
<p>The Bluetooth radio is a powerful piece of hardware, but it's also a thirsty one. Every moment it's active, it's sipping power. Your job is to make sure it's only sipping when absolutely necessary. Here are the golden rules of being a good Bluetooth citizen.</p>
<h3 id="heading-1-dont-scan-if-you-dont-have-to">1. Don't Scan If You Don't Have To</h3>
<p>This sounds obvious, but it's the most common mistake. Before you even think about starting a scan, ask yourself: "Do I really need to do this right now?" If the user is not on the screen that needs scan results, don't scan. If the app is in the background, be extra critical. Background scanning is a huge drain on battery and is heavily restricted by Android for that very reason.</p>
<h3 id="heading-2-stop-your-scan">2. Stop Your Scan!</h3>
<p>I'm going to say it again because it's that important: always stop your scan when you're done. A scan that's left running is like a leaky faucet for your battery. It will drain and drain until there's nothing left. The best practice is to tie your scan lifecycle to your UI lifecycle.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onPause</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">super</span>.onPause()
    <span class="hljs-comment">// The user can't see the screen, so they don't need the results.</span>
    stopBleScan()
}

<span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onResume</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">super</span>.onResume()
    <span class="hljs-comment">// The user is back on the screen, let's start scanning again.</span>
    startBleScan()
}
</code></pre>
<p>If you find the device you're looking for, stop the scan immediately. There's no need to keep looking.</p>
<h3 id="heading-3-choose-the-right-scan-mode">3. Choose the Right Scan Mode</h3>
<p>ScanSettings gives you a few different modes. Choose wisely.</p>
<ul>
<li><p><strong>SCAN_MODE_LOW_POWER:</strong> This is your default, everyday mode. It scans in intervals, balancing discovery speed and battery life. Use this for most foreground scanning.</p>
</li>
<li><p><strong>SCAN_MODE_BALANCED:</strong> A middle ground. It scans more frequently than low power mode.</p>
</li>
<li><p><strong>SCAN_MODE_LOW_LATENCY:</strong> This is the "I need to find it NOW" mode. It scans continuously. This will find devices the fastest, but it will also drain your battery the fastest. Only use this for short, critical operations.</p>
</li>
<li><p><strong>SCAN_MODE_OPPORTUNISTIC:</strong> This is the ultimate passive mode. Your app doesn't trigger a scan at all. It just gets results if another app happens to be scanning. It uses zero extra battery, but you have no guarantee of getting results. Use this for non-critical background updates.</p>
</li>
</ul>
<p>And of course, if you're on AOSP 16 QPR2 or later, use setScanType(SCAN_TYPE_PASSIVE) whenever you don't need the scan response data. It's the new king of power efficiency.</p>
<h3 id="heading-4-use-hardware-filtering-and-batching">4. Use Hardware Filtering and Batching</h3>
<p>We covered this in the advanced section, but it's a best practice that's worth repeating. If you're looking for a specific device, use a ScanFilter. If you're doing a long-running scan, use setReportDelay() to batch your results. These two techniques offload the work to the power-efficient Bluetooth controller and let your app's code sleep, which is the number one way to save battery.</p>
<h3 id="heading-5-be-mindful-of-memory">5. Be Mindful of Memory</h3>
<p>Every ScanResult object that your app receives takes up memory. If you're in a crowded area with hundreds of BLE devices, and you're not using filters, your app can quickly get overwhelmed and run out of memory. This is another reason why filtering is so important. Only get the results you actually care about.</p>
<p>By following these rules, you can build a Bluetooth app that is not only powerful and feature-rich but also respectful of your user's device. You'll be a true Bluetooth sensei. Now, let's wrap things up and look to the future.</p>
<h2 id="heading-conclusion-the-future-is-passive-and-thats-okay">Conclusion: The Future is Passive (and That's Okay)</h2>
<p>We've been on quite a journey, haven't we? We've traveled back in time to the dark ages of Classic Bluetooth, witnessed the renaissance of BLE, and emerged into the brave new world of AOSP 16. We've learned to be silent ninjas with passive scanning, played detective with bond loss reasons, and mastered the art of speed dating with service UUIDs from advertisements.</p>
<p>If there's one big takeaway from all of this, it's that the future of Bluetooth on Android is smarter, more efficient, and a whole lot less frustrating. The Android team is clearly listening to the pain points of developers and giving us the tools we need to build better, more battery-friendly apps. The introduction of passive scanning isn't just a new feature – it's a change in philosophy. It's an acknowledgment that sometimes, the best way to communicate is to just listen.</p>
<p>As developers, these new tools empower us to move beyond the simple "connect and stream" use cases. We can now build sophisticated, context-aware applications that are constantly aware of their surroundings without turning our users' phones into expensive paperweights. The dream of a truly smart, seamlessly connected world is a little bit closer, and it's going to be built on the back of these power-efficient technologies.</p>
<p>So, what's next? The world of Bluetooth is always evolving. We have Bluetooth 5.4 with Auracast, mesh networking, and even more precise location-finding on the horizon. The one thing we can be sure of is that the tools will continue to get better, and the challenges will continue to get more interesting.</p>
<p>For now, take a moment to appreciate the progress we've made. The next time you start a Bluetooth scan and it just works, take a moment to thank the hardworking engineers who made it possible. And the next time your app's battery graph is a beautiful, flat line instead of a terrifying ski slope, give a little nod to the power of passive scanning.</p>
<p>The Bluetooth beast may never be fully tamed, but with AOSP 16, we've been given a much stronger leash. Now go forth and build amazing things. And for the love of all that is holy, remember to stop your scan.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Scale Bluetooth Across Android, iOS, and Embedded Devices ]]>
                </title>
                <description>
                    <![CDATA[ Bluetooth is one of those inventions that seems magical the first time you use it. You turn on a gadget, pair it with your phone, and suddenly they are talking to each other without a single wire in sight. Music plays through your headphones, your sm... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-scale-bluetooth-across-devices/</link>
                <guid isPermaLink="false">691742dfb6a85c7f18a5fc15</guid>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ embedded systems ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Thu, 13 Nov 2025 23:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763131642774/dd2366f8-f491-4313-901e-acd4c1d937e2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Bluetooth is one of those inventions that seems magical the first time you use it. You turn on a gadget, pair it with your phone, and suddenly they are talking to each other without a single wire in sight. Music plays through your headphones, your smartwatch shows messages from your friends, and for a brief moment it feels like technology finally has its act together. Everything works and life is good.</p>
<p>Then you try to connect one more thing. Maybe a fitness band, a smart lock, or that tiny temperature sensor you ordered online because it was on sale. That is when the charm fades and reality walks in. Suddenly the connection drops, your phone cannot find the device anymore, and the once-friendly Bluetooth logo on your screen starts to feel like a taunt. You restart, you unpair, you try again, and somehow it only gets worse. What was once effortless turns into a puzzle with no clear solution.</p>
<p>Here is the secret that few people know: Bluetooth was never meant to handle the chaos we put it through today. When engineers designed it in the late 1990s, they imagined a world of simple one-to-one connections. A laptop talking to a mouse. A phone connecting to a headset. That was the whole idea. Fast-forward to the present and we are using the same technology to run entire networks of wearables, sensors, and smart appliances. We ask it to connect not just one or two devices but sometimes dozens of them at the same time, each running on different hardware and software. It is a miracle that it works at all.</p>
<p>To make things even more interesting, these devices live in very different worlds. Android devices are like an open playground where every manufacturer adds its own slide and swing set. iPhones live inside Apple’s carefully fenced garden where everything is polished but also tightly controlled. Embedded devices, like the ones built on tiny chips inside sensors or IoT boards, are the quiet introverts of the group. They have little memory, tiny batteries, and a strong preference for naps to save power. Getting all three to cooperate is a bit like trying to organize a band where one member only plays jazz, another insists on classical, and the third speaks in Morse code.</p>
<p>That is what engineers mean when they talk about scaling Bluetooth. It is not just about adding more devices. It is about making sure completely different systems can talk to each other reliably and continuously without draining their batteries or losing their minds. It requires design decisions that consider timing, power management, data formats, and even how the operating system schedules background tasks.</p>
<p>This article will guide you through that strange world. We will peel back the layers of how Bluetooth actually works and what happens when Android, iOS, and embedded devices try to share the same airwaves. We will explore why each one behaves the way it does and what you can do to build systems that stay connected instead of collapsing under their own complexity.</p>
<p>By the end, you will see that Bluetooth is not really broken. It is simply overworked. It is a polite translator trying to keep three very different languages in sync. Once you learn how to manage its quirks and give it the structure it needs, Bluetooth becomes not a source of frustration but a quiet, invisible network that holds the modern world together.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-bluetooth-has-two-personalities-meet-classic-and-ble">Bluetooth Has Two Personalities — Meet Classic and BLE</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-android-ios-and-embedded-devices-the-odd-trio">Android, iOS, and Embedded Devices — The Odd Trio</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-architecting-for-scale-herding-cats-but-wirelessly">Architecting for Scale — Herding Cats, but Wirelessly</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-connection-discovery-and-data-flow-the-bluetooth-dating-game">Connection, Discovery, and Data Flow — The Bluetooth Dating Game</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-platform-quirks-and-how-to-stay-sane">Platform Quirks — And How to Stay Sane</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-security-and-privacy-at-scale">Security and Privacy at Scale</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-power-and-performance-tuning">Power and Performance Tuning</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-provisioning-and-firmware-updates-welcome-to-device-kindergarten">Provisioning and Firmware Updates — Welcome to Device Kindergarten</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-debugging-monitoring-and-testing-across-platforms">Debugging, Monitoring, and Testing Across Platforms</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-real-world-architecture-example-when-bluetooth-finally-behaves">Real-World Architecture Example — When Bluetooth Finally Behaves</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-checklist-building-a-truly-scalable-bluetooth-system">Checklist — Building a Truly Scalable Bluetooth System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-wrap-up-lessons-from-the-field">Wrap-Up — Lessons from the Field</a></p>
</li>
</ul>
<h2 id="heading-bluetooth-has-two-personalities-meet-classic-and-ble">Bluetooth Has Two Personalities — Meet Classic and BLE</h2>
<p><img src="https://elainnovation.com/wp-content/uploads/2021/12/Bluetooth-VS-BLE-EN.jpg.webp" alt="What is the difference between Bluetooth and Bluetooth Low Energy (BLE)?" width="800" height="534" loading="lazy"></p>
<p>Before we can talk about scaling Bluetooth, we have to understand that Bluetooth itself has a bit of an identity crisis. It actually comes in two flavors: Classic Bluetooth and Bluetooth Low Energy, also called BLE. They share the same name and sometimes even live on the same chip, but under the hood they behave very differently. Think of them as twins who went to completely different schools and now have opposite personalities.</p>
<p>Classic Bluetooth is the older sibling. It was designed for steady, high-speed data streams. This is the version your headphones, speakers, and car systems use. It is reliable for sending large amounts of data like audio, but it is also chatty and power-hungry. It likes to stay connected all the time, constantly keeping the line open so it can send sound packets smoothly. You could say Classic Bluetooth is like that one friend who calls instead of texting and keeps the conversation going even when there is nothing left to say.</p>
<p>Then there is Bluetooth Low Energy, the younger, more introverted sibling. BLE was designed for devices that need to last for weeks or months on tiny batteries. It does not keep a constant connection open. Instead, it wakes up, sends or receives a little bit of data, and then goes back to sleep. It is the protocol behind fitness trackers, heart rate monitors, smart locks, and most modern IoT devices. If Classic Bluetooth is a full-time conversation, BLE is more like sending quick text messages throughout the day, short, efficient, and battery-friendly.</p>
<p>The funny thing is that even though they share the same wireless spectrum and sometimes even the same antenna, these two modes do not talk to each other directly. A BLE device cannot communicate with a Classic Bluetooth-only device. This is why your wireless headphones can pair with your phone, but your BLE heart rate monitor cannot talk to your old Bluetooth speaker. They live in the same neighborhood but never attend the same parties.</p>
<p>Most of the world’s scaling problems come from BLE, not Classic Bluetooth. Classic has been around long enough that its use cases are stable and well understood. BLE, on the other hand, is used in thousands of different kinds of devices, each with different timing requirements, power limits, and operating systems. When you try to make Android, iOS, and embedded systems all use BLE together, you are juggling three slightly different interpretations of the same rulebook.</p>
<p>To make things trickier, each platform implements BLE its own way. Android exposes it through flexible but sometimes unpredictable APIs. iOS keeps it tidy under Apple’s strict Core Bluetooth framework. Embedded devices rely on lightweight vendor stacks that can vary from chip to chip. Every one of these stacks follows the same Bluetooth specification, but like recipes written by different chefs, the results can taste a little different.</p>
<p>Understanding this dual nature is key to building anything that scales. You must know when to use Classic Bluetooth for high-speed continuous data, when to use BLE for low-power bursts, and how to design your system so that the right devices use the right mode. It is the first step in turning Bluetooth from a confusing mystery into a reliable network you can actually control.</p>
<h2 id="heading-android-ios-and-embedded-devices-the-odd-trio">Android, iOS, and Embedded Devices — The Odd Trio</h2>
<p><img src="https://cdn.dca-design.com/uploads/images/News/_full_width_content_image/105358/Bluetooth_DCA_News_Article_003.webp?v=1749036238" alt="Working with Bluetooth Low Energy across Android and iOS - News - DCA Design" width="2800" height="1999" loading="lazy"></p>
<p>Now that we know Bluetooth has two personalities, let’s meet the three characters that make scaling it so complicated: Android, iOS, and embedded devices. They all speak Bluetooth, but in their own unique accents. Sometimes they understand each other perfectly, and other times it feels like they’re arguing in three different languages while pretending they’re on the same page.</p>
<p>Let’s start with Android. Android is the enthusiastic extrovert of the group. It gives you tons of control and freedom. You can scan, connect, advertise, read, write, and basically poke around every corner of the Bluetooth stack. But that freedom comes with chaos. Because Android runs on phones made by dozens of manufacturers, each one tweaks the Bluetooth implementation a little differently. On one phone, everything works flawlessly. On another, the same code randomly drops connections or refuses to scan in the background. Even Android engineers joke that if your Bluetooth works the same on every device, you’ve probably entered a parallel universe.</p>
<p>Android is powerful but unpredictable. It’s like a sports car that can win a race on a good day but sometimes refuses to start if it doesn’t like the weather. The trick is to write code that expects weird behavior, to build your own connection queues, add retries, and prepare for the occasional glitch. Developers who survive Android Bluetooth bugs don’t just gain experience, they gain humility.</p>
<p>Then there’s iOS, Apple’s polished and opinionated perfectionist. Unlike Android, iOS is consistent. The same code usually behaves the same way across every iPhone and iPad. Apple’s Bluetooth framework, called Core Bluetooth, is beautifully organized and well-documented. But Apple also has strict rules about what you can and can’t do. Background scanning? Only in very specific cases. Advertising? Only for certain UUIDs. Access to lower-level Bluetooth layers? Absolutely not. Apple’s approach is like a luxury hotel: everything looks gorgeous, but you’re not allowed in the kitchen.</p>
<p>Working with iOS feels calm at first. Your connections are stable, your APIs are clear, and your devices behave predictably. But the moment you need to do something slightly unconventional, like connecting to multiple peripherals at once or keeping the app alive in the background, iOS politely says, “No, that’s not how we do things here.” Developers often end up performing delicate dances with background modes, notifications, and clever reconnection tricks just to make things feel seamless for users.</p>
<p>And then we have the third member of the trio: embedded devices. These are the quiet, uncomplaining ones that actually do most of the work. They live inside your smart sensors, wearables, and IoT nodes. They’re usually built around tiny chips with limited memory and low-power processors. They don’t have fancy operating systems or flashy UI frameworks. All they know is how to advertise, connect, send data, and then go back to sleep to save battery.</p>
<p>Embedded devices are loyal but easily overwhelmed. They can’t handle constant large data transfers, and they get cranky if you make them maintain too many simultaneous connections. Imagine trying to run a marathon after eating one grape, that’s what it’s like for a small BLE chip to handle too much traffic. Yet, these little devices are the backbone of every scalable Bluetooth network. They measure your heart rate, control your smart lights, and track your environmental sensors, all while running quietly in the background.</p>
<p>The real challenge begins when you try to make these three cooperate. Android wants freedom, iOS wants structure, and embedded devices just want a nap. Getting them all to work together is like managing a group project where one person writes essays at midnight, another color-codes everything, and the third forgets to charge their laptop. But when you finally get it right, when Android, iOS, and your embedded nodes connect seamlessly, it feels like magic again.</p>
<p>In the next section, we’ll explore how to actually make that happen. You’ll see how to design a Bluetooth architecture that scales gracefully across these platforms instead of collapsing into a pile of logs and retries. It’s part engineering, part patience, and part diplomacy.</p>
<h2 id="heading-architecting-for-scale-herding-cats-but-wirelessly">Architecting for Scale — Herding Cats, but Wirelessly</h2>
<p>If there’s one secret to scaling Bluetooth, it’s this: treat it like herding cats. You’ll never truly <em>control</em> it, but with enough structure, patience, and a bit of catnip (or clever engineering), you can convince all the cats to move in roughly the same direction.</p>
<p>Building a Bluetooth system that spans Android, iOS, and embedded devices isn’t just about writing code that connects things. It’s about designing <em>relationships</em>, the rules and boundaries that keep those connections healthy. The key idea here is <strong>architecture</strong>, which is a fancy word for “deciding who does what, when, and how.” Without a solid architecture, your Bluetooth project quickly turns into a tangle of callbacks, disconnections, and unanswered packets.</p>
<p>The first principle of Bluetooth architecture is <strong>abstraction</strong>. Every platform has its own Bluetooth API, but the basic idea is always the same: scan for devices, connect, exchange data, and disconnect. So instead of writing separate logic for each platform, you create one unified interface, a sort of translator layer, that hides all the messy differences underneath. In practice, this means you can write something like <code>connect(device)</code> in your app, and whether you’re on Android, iOS, or even a Raspberry Pi, the underlying code figures out how to make it happen.</p>
<p>This abstraction layer is your peacekeeper. It prevents the rest of your app from needing to know whether it’s talking to a Nordic chip on a wristband, a smart bulb using an ESP32, or an iPhone pretending to be a peripheral. When you have hundreds or thousands of devices, abstraction isn’t just convenient, it’s survival.</p>
<p>Next comes <strong>connection management</strong>. BLE connections are like toddlers: they demand constant attention and can vanish the moment you look away. A scalable Bluetooth system can’t afford to panic every time a device disconnects. Instead, you design it to expect chaos. You add automatic retries, reconnection strategies, and timeouts that gracefully handle failures instead of freezing your app. Good systems don’t assume the network will always behave, they assume it won’t.</p>
<p>Then there’s <strong>data orchestration</strong>, deciding who talks first, how much data gets sent, and how you keep multiple connections from tripping over each other. Imagine you’re a conductor in an orchestra where half the instruments fall asleep randomly to save power. You need a plan that lets each device play its part in harmony without draining its battery. That’s what managing Bluetooth data flow feels like.</p>
<p>And finally, there’s <strong>power strategy</strong>. Embedded devices live on tight energy budgets. Every scan, advertisement, and data exchange eats into their lifespan. So, your architecture must schedule communication intelligently, let devices wake up briefly, share data, and return to sleep before they burn out. The best Bluetooth systems look lazy on the surface but are actually brilliant planners underneath.</p>
<p>When you put all of this together, abstraction, connection management, orchestration, and power control, you get something that <em>scales</em>. It doesn’t matter if you’re managing three wearables or three thousand sensors. The system behaves predictably, logs issues instead of panicking, and recovers from disconnections automatically.</p>
<p>Think of it like a well-run airport. Planes (your devices) take off and land constantly. The control tower (your app’s Bluetooth manager) keeps track of who’s in the air, who’s landing next, and who needs maintenance. No single pilot needs to know everything, they just follow the protocol.</p>
<p>Scaling Bluetooth isn’t about being clever with one device. It’s about designing systems that keep working even when dozens of devices act unpredictably. You don’t tame Bluetooth by force; you do it by creating a world where even chaos feels organized.</p>
<p>In the next section, we’ll dig deeper into how these connections actually behave in real time, how devices discover each other, exchange data, and, sometimes, break up without warning.</p>
<h2 id="heading-connection-discovery-and-data-flow-the-bluetooth-dating-game">Connection, Discovery, and Data Flow — The Bluetooth Dating Game</h2>
<p>Every Bluetooth connection starts like a modern love story. One device sends out signals into the air, announcing that it’s available. Another device scans the surroundings, hoping to find something compatible. When they finally spot each other, they exchange a few polite packets, decide they’re a good match, and try to make it official with a connection. It’s wireless romance, until one of them walks away without saying goodbye.</p>
<p>This is the heart of how Bluetooth works: <strong>advertising, discovery, and connection</strong>. An embedded sensor or wearable device usually plays the role of the advertiser. It broadcasts tiny packets called advertisements that contain just enough information to say, “Hey, I’m here, and I can measure temperature or heart rate or unlock your door.” These packets are intentionally small because transmitting data takes energy, and low-power devices have to conserve every drop of battery life.</p>
<p>Meanwhile, your phone or tablet acts as the scanner, it listens to the radio waves around it, searching for those signals. When it finds one that matches what it’s looking for, it sends a request to connect. If the peripheral accepts, they move into a new relationship phase: the <strong>GATT connection</strong>. GATT stands for Generic Attribute Profile, which is basically the language they use to talk. Once connected, your phone can ask the device for specific data, like reading a heart rate measurement or writing a configuration setting.</p>
<p>Now, if all of this sounds peaceful and predictable, that’s because we haven’t talked about what happens in the real world. In reality, devices move around, signals weaken, and phones go into power-saving modes that forget they were even connected. Connections drop. Pairing sometimes fails. And when you have ten or more devices talking at once, managing all those tiny wireless conversations becomes a circus act.</p>
<p>Scaling Bluetooth is all about keeping this circus under control. You can’t force every device to stay connected forever, that would drain batteries and jam the radio channels. Instead, you design a rhythm. Devices connect only when needed, exchange data quickly, and then disconnect to rest. This constant dance of connecting and disconnecting keeps the system efficient and stable.</p>
<p>Think of it like a well-run coffee shop. Customers (phones) walk in, place their order (data request), get their coffee (response), and leave. The barista (the embedded device) doesn’t serve one person all day, it serves everyone in quick cycles. The trick is to make sure no one gets stuck waiting for their latte forever.</p>
<p>Timing is everything in this dance. If a device advertises too infrequently, the phone might not discover it in time. If it advertises too often, it wastes power. If the phone sends too many requests at once, the device might crash or slow down. Bluetooth connections live in this delicate balance between performance and efficiency.</p>
<p>When you scale, you also have to think about coordination. Imagine one phone trying to talk to ten sensors at once. You can’t have it flood them all with requests simultaneously, it needs a queue, a polite way of saying “you first, then me.” This is called <strong>connection orchestration</strong>, and it’s one of the hardest parts of scaling BLE systems.</p>
<p>And then there’s the breakup. Devices disconnect all the time, sometimes intentionally, sometimes accidentally. The best Bluetooth systems treat disconnections not as failures but as normal events. The app automatically retries, reconnects, and syncs data without asking the user to “try again.” To users, it feels seamless. Underneath, there’s a lot of quiet heroism happening, background threads, timers, and reconnection logic all working together to patch up relationships on the fly.</p>
<p>So, at its core, Bluetooth is less like a stable marriage and more like speed dating with excellent scheduling. Everyone meets briefly, exchanges information, and moves on. When done right, this model scales effortlessly. When done wrong, it’s chaos.</p>
<p>In the next section, we’ll explore the quirks that make Android, iOS, and embedded devices behave differently in this dating game, and how to keep the peace when one of them inevitably ghosts the others.</p>
<h2 id="heading-platform-quirks-and-how-to-stay-sane">Platform Quirks — And How to Stay Sane</h2>
<p>Once you start scaling Bluetooth, you’ll notice something odd. The same code that works perfectly on one device suddenly refuses to behave on another. It’s like watching identical twins argue about who gets the last slice of pizza, they may look the same, but their personalities couldn’t be more different.</p>
<p>Let’s start with Android, the unpredictable one. Android gives developers more power than any other mobile platform. You can scan however you like, filter by services, read and write any characteristic, and even customize connection intervals. But that power comes at a price. Every phone manufacturer modifies the Bluetooth stack slightly. Samsung, Pixel, OnePlus, Xiaomi, each adds its own flavor of “enhancement,” which sometimes translates to “surprise, nothing works the same.”</p>
<p>One Android phone might handle ten connections at once without blinking. Another might drop all of them the moment the screen turns off. Some versions ignore Bluetooth permissions until you grant location access. Others claim they’re scanning when they actually stopped five minutes ago. Android developers eventually stop asking <em>why</em> and simply build more logging instead. The rule of thumb with Android Bluetooth is simple: test everything, assume nothing, and expect the unexpected.</p>
<p>Then there’s iOS, which at first feels like a breath of fresh air. Apple’s Core Bluetooth framework is clean, consistent, and almost elegant. You get predictable callbacks, smooth reconnections, and well-behaved devices. But if you step outside Apple’s boundaries, you’ll quickly find invisible fences. iOS doesn’t let apps scan in the background freely. It limits how often you can advertise. And if your app tries to keep too many simultaneous connections alive, iOS politely steps in and shuts them down.</p>
<p>Apple’s philosophy is control. It wants Bluetooth connections to behave in ways that don’t drain the battery or clutter the radio. That’s great for users, but for developers it can feel like being handed the keys to a Ferrari and told you can only drive in the parking lot. It works beautifully, as long as you color inside the lines.</p>
<p>And then we have embedded devices, which are in a category of their own. These are the little chips sitting inside your wearables, sensors, or IoT gadgets. They don’t have operating systems or background processes. They just run tiny loops of firmware that listen, respond, and sleep. Their quirks are more about physics than software. If the antenna isn’t tuned properly, signals drop. If the power supply fluctuates, the radio turns off. Sometimes they disconnect simply because a human walked between two devices and absorbed the signal.</p>
<p>Embedded Bluetooth stacks also differ by manufacturer. Nordic, Espressif, Silicon Labs, Texas Instruments, each has its own libraries, quirks, and limitations. Even small changes like increasing the packet size or adjusting the advertising interval can make or break communication. It’s a careful dance between efficiency and reliability.</p>
<p>Now imagine you’re trying to get all three of these worlds to cooperate. Android wants freedom, iOS enforces discipline, and embedded devices want long naps. Building a Bluetooth system that works across all of them is like running a daycare with overachievers, rule-followers, and kids who fall asleep mid-activity. You can’t treat them all the same, but you can design a routine that keeps everyone content.</p>
<p>The secret is resilience. Instead of expecting perfect behavior, build your system around imperfections. Add retries when connections fail. Cache data so you don’t lose progress during disconnections. Keep your embedded devices simple, your mobile apps forgiving, and your logs brutally honest.</p>
<p>If you design with these quirks in mind, your Bluetooth system will feel almost magical, even though, behind the scenes, it’s a web of error handling, reconnections, and polite compromise.</p>
<p>In the next section, we’ll take a look at another side of scaling: keeping everything secure and private while all these devices whisper secrets over the air.</p>
<h2 id="heading-security-and-privacy-at-scale">Security and Privacy at Scale</h2>
<p>Once your Bluetooth system starts working reliably, there’s another challenge waiting in the wings: keeping it <strong>secure</strong>. It’s one thing to get devices talking to each other, it’s another to make sure no one else is eavesdropping on the conversation. Bluetooth security can sound intimidating, but at its core, it’s about making sure your devices trust each other and that strangers can’t sneak into the chat.</p>
<p>Let’s start with pairing. Pairing is Bluetooth’s version of saying, “Hey, can I trust you?” It’s a handshake where two devices exchange keys that let them communicate securely in the future. There are a few ways this handshake can happen. The simplest is called <em>Just Works</em>, which basically means, “We’ll trust each other without asking too many questions.” It’s convenient but about as safe as leaving your front door unlocked because you live in a nice neighborhood. For harmless gadgets like wireless speakers, that’s fine. But for medical devices or smart locks, “Just Works” can turn into “Just Got Hacked.”</p>
<p>A safer approach is <strong>Passkey Entry</strong>, where one device shows a code and the other types it in, proving they’re physically near each other. Even better is <strong>Out-of-Band (OOB)</strong> pairing, where the devices exchange security information through another method, maybe a QR code, NFC tap, or even an optical blink, before connecting over Bluetooth. OOB pairing is like verifying someone’s identity face-to-face before continuing a conversation online.</p>
<p>Once paired, devices use <strong>encryption</strong> to scramble their communication. Anyone listening nearby will hear only gibberish. The strength of that encryption depends on the version of Bluetooth being used. Modern devices using Bluetooth 4.2 or later support something called <em>LE Secure Connections</em>, which is based on advanced cryptography. Older devices use weaker methods that are easier to crack. So, if you’re building something new, never rely on outdated pairing modes.</p>
<p>But security isn’t just about encryption. It’s also about <strong>privacy</strong>. Every Bluetooth device has an address, kind of like its phone number, that it uses when broadcasting. If that address stays the same, someone could track you by following your device’s broadcasts. That’s why newer standards support <em>random address rotation</em>, where devices periodically change their Bluetooth address. Your phone and smartwatch still recognize each other, but strangers can’t follow your signal around the city.</p>
<p>When you scale Bluetooth systems, these little details become critical. A single insecure device in your network can become the weak link that compromises everything. It’s like locking every door in your house but leaving one window open. Attackers don’t need to break the whole system, they just need to find the lazy one.</p>
<p>Building security into a large Bluetooth deployment means standardizing your pairing process, using strong encryption everywhere, and handling key storage carefully. On embedded devices, that can be tricky because they have limited memory and no secure element by default. Still, even small steps help, like regenerating keys periodically and disabling “Just Works” mode for devices that control anything important.</p>
<p>On mobile platforms, the rules are slightly different. Android and iOS handle much of the heavy lifting for you, but you still have to design your app logic carefully. Always confirm which device you’re connecting to before exchanging sensitive data. Always check bonding state before sending configuration commands. In short, treat Bluetooth communication with the same seriousness you’d give to a login session or an online payment.</p>
<p>At scale, security isn’t something you bolt on later. It’s part of the system’s DNA. You can’t fix a weak handshake by adding a stronger password later. You have to start from the first pairing and make sure every connection trusts the right partner.</p>
<p>The reward is worth it. When done right, your Bluetooth network becomes invisible but secure, a quiet, encrypted web of trust that just works. No drama, no leaks, and no nearby strangers hijacking your sensors.</p>
<p>In the next section, we’ll talk about another invisible problem that decides whether your Bluetooth network lives for days or months: power. Because what good is a secure device if its battery dies halfway through the handshake?</p>
<h2 id="heading-power-and-performance-tuning">Power and Performance Tuning</h2>
<p>If you’ve ever wondered why your Bluetooth gadget dies right when you need it most, you’ve just met the oldest enemy in wireless communication: power consumption. Bluetooth may be clever, flexible, and everywhere, but it also has a bit of a caffeine problem. It loves to talk, and talking burns energy. Keeping your devices alive longer, especially when you scale, means learning the quiet art of power management.</p>
<p>At first, it’s easy to assume that Bluetooth is low power by default. After all, it’s called <strong>Bluetooth Low Energy</strong>, right? But BLE’s efficiency only shines when it’s used correctly. A poorly tuned BLE system can drain a battery faster than streaming music over Classic Bluetooth. The magic lies in controlling when devices talk, how long they talk, and how much they say each time.</p>
<p>Let’s start with the <strong>advertising interval</strong>. This is how often a device shouts, “I’m here!” into the air. If you set it to broadcast every 20 milliseconds, you’ll discover devices quickly, but you’ll also burn through the battery like it’s running a marathon. Increase the interval to once every second, and your device will last much longer, but phones may take a moment to find it. It’s a tradeoff between speed and stamina. Every system has to find its sweet spot.</p>
<p>Next comes the <strong>connection interval</strong>, how often two connected devices exchange data. This is like deciding how frequently you check your messages. If you check every second, you stay perfectly up to date but never get anything else done. If you check once every minute, you save time but risk missing something important. In Bluetooth terms, a shorter connection interval means faster communication but higher power usage. Longer intervals conserve battery but add delay. Smart systems adjust these intervals dynamically depending on what the device is doing.</p>
<p>Then there’s the <strong>MTU</strong>, or Maximum Transmission Unit, the size of each Bluetooth data packet. Bigger packets mean fewer total transmissions for large chunks of data, which can improve efficiency. But some devices, especially older ones, can’t handle large MTUs, so finding the right balance is important.</p>
<p>Power management is not just about numbers, it’s about habits. A well-designed embedded device spends most of its life asleep. It wakes up only to advertise or exchange data, then returns to rest as quickly as possible. Imagine a hummingbird darting out for a sip of nectar and then zipping back to rest before anyone notices. That’s how efficient Bluetooth devices survive on coin-cell batteries for months or even years.</p>
<p>On the phone side, energy management is just as critical, especially when your app needs to handle multiple connections. Constant scanning, reconnecting, or keeping GATT channels open drains your user’s battery, and patience. Android and iOS both have built-in mechanisms that throttle background Bluetooth activity to save power. Developers have to work with these rules, not against them. The best apps schedule scans intelligently, reconnect only when necessary, and avoid holding connections open when no data needs to be sent.</p>
<p>Scaling Bluetooth systems makes these power decisions even more important. When you have one device, wasting a bit of energy doesn’t matter. When you have hundreds of devices, each one burning just a few extra milliwatts, the total waste adds up quickly. Power efficiency becomes the difference between a network that runs for months and one that collapses after a week.</p>
<p>The golden rule of power tuning is simple: talk less, talk smarter. A Bluetooth device that knows when to speak and when to stay quiet can scale beautifully, even in large networks. It’s not about being fast all the time, it’s about being clever with timing.</p>
<p>In the next section, we’ll look at how these devices join your network in the first place and what happens when you need to update their software later. Because once your system scales, you’re not just connecting devices, you’re managing an entire population.</p>
<h2 id="heading-provisioning-and-firmware-updates-welcome-to-device-kindergarten">Provisioning and Firmware Updates — Welcome to Device Kindergarten</h2>
<p>Imagine setting up one Bluetooth device. It’s easy: you pair it, give it a name, and maybe tweak a few settings. Now imagine doing that a hundred times. Or a thousand. Suddenly, what felt like a simple task starts to look like a factory assembly line powered by frustration. That’s where <strong>provisioning</strong> comes in, the process of onboarding new devices into your Bluetooth network so they can start working right away, without manual babysitting.</p>
<p>Provisioning is like a first day at school for your devices. Each new student needs to be identified, assigned to a class, and given a name tag. In the Bluetooth world, a newly manufactured device begins life in an “unprovisioned” state. It doesn’t belong to any network yet, so it advertises with a special signal that says, “Hey, I’m new here.” When your mobile app or gateway spots that advertisement, it can connect, authenticate the device, and hand over the credentials it needs to join the system.</p>
<p>The app usually performs a few key steps during provisioning. It verifies that the device is genuine, assigns it a unique identifier, and exchanges security keys so future connections can happen securely. It might also store metadata like which room the sensor belongs to or what type of data it will report. After provisioning, the device switches to its normal operation mode, where it advertises with its new identity and starts behaving like a member of the family.</p>
<p>When you have just one or two devices, you can do all this manually. But when you scale up to hundreds or thousands, manual setup becomes impossible. That’s when you start thinking about automation, QR codes on packaging, NFC tags for instant pairing, or out-of-band provisioning where a separate channel (like Wi-Fi or a wired link) handles secure onboarding. The goal is to make provisioning quick, repeatable, and error-free, even when your factory or users are adding new devices by the dozens.</p>
<p>Once your devices are out in the world, the next challenge appears: <strong>firmware updates</strong>. Every system eventually needs to fix bugs, patch security holes, or add new features. For Bluetooth devices, this means pushing new firmware over the same wireless link, a process known as <strong>FOTA</strong>, or firmware-over-the-air updates.</p>
<p>Updating firmware over Bluetooth can be nerve-wracking. The connection is relatively slow, and interruptions can leave a device half-updated and confused about who it is. Good update systems handle this carefully. They divide the firmware into chunks, verify each piece with checksums, and only switch to the new version once the whole update has been safely received and validated. If anything fails midway, the device rolls back to the old firmware instead of bricking itself.</p>
<p>Scaling makes this even more complex. Updating ten devices is fine. Updating a thousand can overwhelm your network if you try to do them all at once. Smart systems stagger the updates in waves, track which devices have finished, and retry the ones that didn’t. Some even let devices report their status back to a central dashboard, so you can see which ones are ready and which ones are still stuck halfway through.</p>
<p>Provisioning and firmware updates might not sound glamorous, but they’re the backbone of every scalable Bluetooth system. Without smooth onboarding and reliable updates, your network slowly falls apart as devices drift out of sync or miss critical fixes.</p>
<p>Think of it this way: provisioning is how devices <em>join the family</em>, and firmware updates are how they <em>grow up</em>. Both are essential if you want your Bluetooth ecosystem to stay healthy and dependable over time.</p>
<p>In the next section, we’ll talk about what happens when something inevitably goes wrong, how to debug and monitor a network full of devices without losing your mind.</p>
<h2 id="heading-debugging-monitoring-and-testing-across-platforms">Debugging, Monitoring, and Testing Across Platforms</h2>
<p>At some point, every Bluetooth developer faces the same moment of quiet despair. The logs look fine, the devices are paired, the code hasn’t changed, and yet… nothing works. Connections fail, packets vanish, and everything that worked yesterday now refuses to cooperate. Welcome to the wonderful, mysterious world of Bluetooth debugging, a place where logic takes a vacation and patience becomes your most valuable skill.</p>
<p>Debugging Bluetooth is tricky because so much of it happens invisibly. The data is flying through the air, hopping between frequencies dozens of times per second, and all you can see is whether the connection succeeds or fails. It’s like trying to diagnose a conversation between two people whispering in another room. You can tell they’re talking, but not what they’re saying.</p>
<p>The first rule of Bluetooth debugging is simple: <strong>log everything</strong>. Log when you start scanning, when you find a device, when you connect, and when you disconnect. Log the signal strength, the UUIDs you discover, the number of bytes you read, and the time it took. Bluetooth problems rarely announce themselves loudly, they hide in tiny details. A small delay in a callback or a missing acknowledgment can reveal exactly why your system seems haunted.</p>
<p>Different platforms give you different kinds of help. Android, for example, offers detailed Bluetooth logs through developer options or tools like <code>adb</code>. You can capture the raw Bluetooth HCI logs and analyze them later to see what really happened under the hood. iOS, on the other hand, gives you less direct visibility. Apple handles most of the Bluetooth stack internally, so your only clues come from Core Bluetooth callbacks. Embedded devices often let you log directly from the firmware, showing connection events, error codes, and sometimes even packet-level information if the stack supports it.</p>
<p>Testing across platforms is just as important as debugging. You can’t assume that if it works on one phone, it will work on another. Android devices, especially, have a habit of interpreting Bluetooth timing slightly differently. A system that’s rock-solid on a Pixel may stutter on a Samsung or freeze on a low-cost tablet. The only cure is diversity, test on multiple brands, OS versions, and firmware builds until you’re confident the system behaves everywhere.</p>
<p>For embedded devices, testing is a different challenge. Because they often run continuously, you need long-term endurance tests to catch issues that only appear after hours or days of operation. You might discover that a connection fails only after 300 reconnections, or that a memory leak appears after a week of normal use. Building test rigs that automate these scenarios: connecting, disconnecting, and verifying data repeatedly, is a huge time saver.</p>
<p>Monitoring is what happens after you’ve deployed your devices into the real world. It’s like keeping a health tracker on your entire Bluetooth network. Your mobile apps or gateways can collect statistics such as signal strength, connection failures, uptime, and battery levels. That data tells you which devices are performing well and which ones might be drifting toward trouble.</p>
<p>Adding this kind of visibility pays off enormously at scale. When you’re managing hundreds of devices, it’s impossible to check each one manually. Instead, you rely on trends, for example, if one location shows consistently weak signal strength, maybe there’s interference nearby. If multiple devices drop connections at the same time, maybe the central device needs a firmware update. Monitoring transforms guesswork into insight.</p>
<p>The truth is, debugging and monitoring never really end. Even after your system is stable, new versions of Android and iOS will appear with small Bluetooth changes that break something you didn’t know could break. Treat Bluetooth maintenance like car maintenance: routine, ongoing, and essential.</p>
<p>Once you learn to capture good logs, read them calmly, and build systems that report their own health, debugging stops being a nightmare and becomes a science. Bluetooth may always be a little mysterious, but with the right tools and attitude, you can keep the ghosts out of your connection list.</p>
<p>In the next section, we’ll put everything together with a real-world example of what scaling Bluetooth actually looks like when all the pieces: mobile apps, embedded devices, and architecture, finally work in harmony.</p>
<h2 id="heading-real-world-architecture-example-when-bluetooth-finally-behaves">Real-World Architecture Example — When Bluetooth Finally Behaves</h2>
<p>Let’s take everything we’ve talked about and bring it to life with a real-world scenario. Imagine you’re building a smart factory system with hundreds of Bluetooth sensors scattered across the floor. Each sensor measures temperature, vibration, or humidity. Some are attached to machines, others hang on walls, and a few are hidden in places even the janitor doesn’t know about. Your goal is simple on paper: collect data from all these sensors, send it to a central dashboard, and keep everything running smoothly.</p>
<p>The reality, of course, is much more complicated. Each sensor is an embedded device powered by a coin-cell battery that has to last for months. They advertise periodically to announce they’re alive. Your Android or iOS tablets, placed around the factory as gateways, act as Bluetooth centrals. Their job is to scan, connect to nearby sensors, read data, and upload it to the cloud. It sounds straightforward, but you’re juggling dozens of invisible connections at once, and they all have different moods.</p>
<p>The architecture begins with careful planning. Each gateway tablet knows which part of the factory it’s responsible for. That way, you avoid overcrowding the airwaves with multiple devices trying to connect to the same sensors. The sensors use slightly staggered advertising intervals so they don’t all shout at the same time. The gateways maintain a queue, connecting to a few sensors at a time, reading data, and then disconnecting before moving on to the next group. This rotation keeps everything balanced and prevents Bluetooth traffic jams.</p>
<p>Power management is built into every step. Each sensor wakes up, advertises briefly, sends its data when connected, and goes right back to sleep. The connection interval and MTU size are tuned for efficiency, large enough for smooth data transfer, but not so large that slower devices choke. Every byte is treated like gold because every transmission costs energy.</p>
<p>The gateways handle the messy parts: reconnections, retries, and data aggregation. They buffer readings in case the Wi-Fi link to the cloud goes down and sync later when it’s back. They also monitor each sensor’s signal strength, battery level, and uptime. If a sensor hasn’t reported in a while, the system flags it automatically so a technician can check on it.</p>
<p>Now imagine scaling this setup to multiple factory buildings. Suddenly, you’re managing thousands of sensors, dozens of gateways, and countless wireless interactions. At this scale, the design choices you made early, abstracted Bluetooth logic, retry mechanisms, power optimization, and logging, are the difference between a quiet, self-running network and a system that collapses into constant reconnections.</p>
<p>When everything works as intended, something beautiful happens. The sensors collect data silently. The gateways synchronize automatically. The dashboards stay green. Nobody has to restart anything, and Bluetooth quietly fades into the background where it belongs. It’s the rare moment when technology stops demanding attention and simply does its job.</p>
<p>This kind of architecture isn’t science fiction. Companies use it in factories, hospitals, and warehouses every day. From smart lighting systems to patient monitors, Bluetooth at scale can be astonishingly reliable, but only if you treat it like a distributed system, not a single gadget. Each device is a citizen of a larger ecosystem, and your job as the architect is to keep that ecosystem healthy.</p>
<p>The biggest takeaway is that success doesn’t come from fancy algorithms or expensive hardware. It comes from the small, deliberate decisions that make your system resilient: how you handle disconnections, how you schedule connections, how you monitor performance. Scaling Bluetooth is not about avoiding problems, it’s about designing a system that recovers gracefully when problems happen.</p>
<p>In the next section, we’ll wrap up everything we’ve learned into a practical checklist, a simple guide you can use whenever you’re designing a Bluetooth system that has to survive in the wild.</p>
<h2 id="heading-checklist-building-a-truly-scalable-bluetooth-system">Checklist — Building a Truly Scalable Bluetooth System</h2>
<p>By now, you’ve seen Bluetooth in all its moods, charming, confusing, unpredictable, and surprisingly capable when handled with care. So how do you actually put everything together? What makes a Bluetooth system <em>scalable</em> instead of just “working on my desk”? The answer isn’t a single trick or secret API. It’s a mindset, a way of designing your system to expect chaos and still function gracefully when it happens.</p>
<p>The first part of that mindset is consistency. Every Bluetooth system should have one clear and stable way of communicating. Keep your data formats simple, your GATT profiles predictable, and your naming conventions sensible. If you have ten devices made by ten different vendors, make them all speak the same language. The moment one device starts improvising, the whole orchestra sounds off.</p>
<p>Next comes patience, and in Bluetooth, patience means retries. Connections drop. Devices go out of range. A phone might go to sleep or decide that scanning is no longer fashionable. Instead of treating every disconnection as a crisis, treat it as part of the process. A good Bluetooth app quietly retries in the background, restores the connection, and carries on as if nothing happened. To the user, it feels seamless. Underneath, it’s a flurry of logic keeping the experience smooth.</p>
<p>Then there’s the question of power. Remember that every advertisement and connection eats into battery life. A scalable Bluetooth system doesn’t talk all the time, it talks <em>smart</em>. It plans when to wake up, when to exchange data, and when to stay silent. Devices that last longer need fewer replacements, fewer updates, and far less human attention. Power efficiency is the hidden currency of scalability.</p>
<p>Monitoring is another essential habit. If you can’t see what’s happening inside your system, you’re flying blind. Log your connections, track your signal strengths, record how often devices drop out, and visualize it somewhere. A simple dashboard that shows which devices are healthy and which ones are struggling can save you countless hours later. When you scale, visibility turns guesswork into control.</p>
<p>Security, too, can’t be an afterthought. Use secure pairing, proper encryption, and rotating addresses. The bigger your system gets, the more interesting it becomes to people who might want to peek at it. Make sure they can’t. A secure Bluetooth network doesn’t just protect users, it protects your reputation.</p>
<p>Finally, build for change. Bluetooth isn’t static, Android and iOS update their stacks every year, chip vendors release new firmware, and new security standards appear. A scalable system doesn’t break when something changes, it adapts. That’s why abstraction layers, modular code, and updatable firmware matter so much. They keep your system flexible long after the first version ships.</p>
<p>If you do all of this, keep it consistent, patient, efficient, observable, secure, and adaptable, something magical happens. Your Bluetooth system starts to feel less like a fragile web of devices and more like a living network. It keeps running, keeps healing, and quietly gets the job done without constant supervision. That’s when you know you’ve built something that scales.</p>
<p>In the final section, we’ll step back and reflect on the bigger picture, what scaling Bluetooth really teaches us about building technology that has to work not just once, but over and over again in the messy, beautiful real world.</p>
<h2 id="heading-wrap-up-lessons-from-the-field">Wrap-Up — Lessons from the Field</h2>
<p>If you’ve made it this far, you’ve probably realized that scaling Bluetooth isn’t really about Bluetooth at all. It’s about learning how complex systems behave when they leave the comfort of your desk and enter the real world. It’s about understanding that wireless connections are not just electrical signals, they’re relationships between unpredictable, battery-powered, opinionated little machines.</p>
<p>Bluetooth gets a bad reputation because people expect it to be simple. They imagine it’s like Wi-Fi or USB, plug and play, pair and forget. But in truth, Bluetooth is more like a polite conversation at a crowded party. Everyone is talking at the same time, the music is loud, and you have to keep repeating yourself until the other person hears you correctly. When you think of it that way, it’s a miracle that it works as well as it does.</p>
<p>Scaling Bluetooth across Android, iOS, and embedded devices teaches you humility. You stop assuming things will always behave, and instead you start building systems that <em>recover</em> when they don’t. You learn that error handling is not an afterthought, it’s the main event. You discover that batteries are precious, timing is everything, and the smallest design decisions can ripple through an entire ecosystem of devices.</p>
<p>You also start to appreciate the quiet beauty of resilience. There’s something deeply satisfying about watching dozens of sensors, gateways, and phones connect, share data, and disconnect, all without human intervention. When it works, it feels effortless. You forget about the retries, the power cycles, the reconnections, and the debugging sessions that made it possible. All you see is a smooth network humming quietly in the background, doing exactly what it was meant to do.</p>
<p>And that’s the real magic of Bluetooth, not the flashy tech demos or the pairing animations, but the invisible collaboration that happens beneath the surface. It’s the heartbeat of every wearable, every sensor, every tiny device that quietly makes our lives a little easier. Scaling it isn’t just an engineering challenge; it’s a lesson in patience, design, and empathy for systems that can’t always speak for themselves.</p>
<p>So, the next time your Bluetooth device disconnects, take a breath. Somewhere in the chaos, it’s just trying to reconnect, to find its partner again and pick up where it left off. Because deep down, that’s what Bluetooth really is: a network built on trust, persistence, and tiny packets of hope flying through the air.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Bluetooth Socket Settings Power Android’s Low Power Island: A Friendly Deep Dive into AOSP’s Hidden Energy Saver ]]>
                </title>
                <description>
                    <![CDATA[ Picture this: you’re sitting in a café with your laptop open, phone on the table, smartwatch buzzing every few minutes, and Bluetooth earbuds playing music. From your perspective, life is peaceful. From your phone’s perspective, it’s juggling a ridic... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-bluetooth-socket-settings-power-androids-low-power-island-a-friendly-deep-dive-into-aosps-hidden-energy-saver/</link>
                <guid isPermaLink="false">69164aadd6505b750fa4b659</guid>
                
                    <category>
                        <![CDATA[ BluetoothSocket ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Offload ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ LowPowerConsumption ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Thu, 13 Nov 2025 21:16:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763071691608/30075d98-7eb4-4f87-9396-d76d91c92fea.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Picture this: you’re sitting in a café with your laptop open, phone on the table, smartwatch buzzing every few minutes, and Bluetooth earbuds playing music. From your perspective, life is peaceful. From your phone’s perspective, it’s juggling a ridiculous number of tiny Bluetooth packets all the time.</p>
<p>Every time your watch syncs your steps, every time your earbuds receive another chunk of audio, every time a background device checks in – the main application processor inside your phone is forced to wake up, look at the data, decide what to do with it, and then go back to sleep. Do that a few thousand times, and suddenly that nice 5000 mAh battery starts feeling suspiciously small.</p>
<p>Android engineers looked at this pattern and basically said, what if we don’t wake up the big CPU for every tiny Bluetooth thing? What if we had a smaller helper brain whose entire job is to handle boring repetitive Bluetooth traffic while the main CPU relaxes? That’s exactly where the concept of a Low Power Island, usually shortened to LPI, comes in.</p>
<p>In modern Android Bluetooth architecture, especially from the <a target="_blank" href="https://source.android.com/docs/whatsnew/android-16-release">AOSP 16</a> generation onward, a good chunk of Bluetooth work can be offloaded to a dedicated low power processor that sits closer to the Bluetooth radio. This little processor is embedded in the Bluetooth controller or SoC and is designed to run very efficiently. It consumes much less power than the main CPU and can stay awake without draining your battery like a full application processor would. Android’s job is to decide which traffic can live on this island and which traffic still needs the main CPU.</p>
<p>But how does Android make that decision in practice? This is where Bluetooth sockets and something called <a target="_blank" href="https://developer.android.com/reference/android/bluetooth/BluetoothSocketSettings">BluetoothSocketSettings</a> enter the story.</p>
<p>In a regular app, when you open a <a target="_blank" href="https://developer.android.com/reference/android/bluetooth/BluetoothSocket">BluetoothSocket</a>, it feels like you’re just opening a pipe so you can send and receive bytes. Under the hood though, the framework is asking a much deeper question: should this pipe go through the big highway that wakes up the main CPU, or can this pipe be connected directly into the low power island’s private road network?</p>
<p>In the latest AOSP Bluetooth stack, the answer to that question is expressed through a tiny configuration object: BluetoothSocketSettings. This class lets system level code describe how a socket should behave. It can specify whether the data should be kept on the normal host path or offloaded into a hardware data path that ends on the low power processor.</p>
<p>Inside, there are fields like <code>DATA_PATH_NO_OFFLOAD</code> and <code>DATA_PATH_HARDWARE_OFFLOAD</code>, plus extra information like <code>hubId</code>, <code>endpointId</code>, and <code>requestedMaximumPacketSize</code> that help the controller understand how to route packets in the LPI world.</p>
<p>From the outside, it still looks like you’re dealing with a normal BluetoothSocket. Inside the Bluetooth framework though, that socket is now tagged with extra metadata that quietly tells the Bluetooth stack: this one is special, send it to the island.</p>
<p>The host stack then talks to a new layer of code in the Bluetooth system called the LPP offload manager and a socket specific HAL (Hardware Abstraction Layer) so that the low power processor can be informed whenever a socket is opened or closed, and can claim responsibility for handling the data.</p>
<p>So if we keep the café analogy, previously every Bluetooth customer shouted their order directly at the main barista. With Low Power Island and BluetoothSocketSettings, Android can say, “these regular espresso orders can go through the junior barista at the side counter. Only the weird custom drinks still go to the main barista”. Same Bluetooth experience for the user, but far less chaos and far less wasted energy behind the counter.</p>
<p>In this article, we will zoom in from this high level story into the actual Android APIs. We’ll look at how BluetoothSocketSettings is defined in the framework, how you request hardware offload, and what those scary looking fields like hubId and endpointId actually mean in plain English.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-the-anatomy-of-bluetoothsocketsettings">The Anatomy of BluetoothSocketSettings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-inside-the-hal-how-bluetooth-offload-really-works">Inside the HAL: How Bluetooth Offload Really Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-when-the-cpu-sleeps-but-bluetooth-doesnt-power-management-in-action">When the CPU Sleeps but Bluetooth Doesn’t: Power Management in Action</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-developers-can-harness-bluetoothsocketsettings">How Developers Can Harness BluetoothSocketSettings</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-grand-finale-the-elegance-of-sleeping-smart">The Grand Finale: The Elegance of Sleeping Smart</a></p>
</li>
</ol>
<h2 id="heading-the-anatomy-of-bluetoothsocketsettings">The Anatomy of BluetoothSocketSettings</h2>
<p>So far we’ve been talking about BluetoothSocketSettings like it’s some magical ticket that sends your packets to a sunny low-power island somewhere inside your phone. Now let’s actually look at what that ticket looks like in code.</p>
<p>If you open the Android Open Source Project tree and navigate to the framework layer, you will find a class definition hiding under <code>frameworks/base/core/java/android/bluetooth/BluetoothSocketSettings.java</code>. At first glance it looks small, almost too simple for something that saves you so much battery. But this little class carries the secret instructions that tell the Bluetooth stack where your socket’s data should flow.</p>
<p>Here’s what a stripped-down version looks like:</p>
<pre><code class="lang-cpp"><span class="hljs-keyword">public</span> <span class="hljs-keyword">final</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BluetoothSocketSettings</span> <span class="hljs-title">implements</span> <span class="hljs-title">Parcelable</span> {</span>
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> DATA_PATH_NO_OFFLOAD = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> DATA_PATH_HARDWARE_OFFLOAD = <span class="hljs-number">1</span>;

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mDataPath;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mHubId;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mEndpointId;
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">int</span> mRequestedMaxPacketSize;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">BluetoothSocketSettings</span><span class="hljs-params">(<span class="hljs-keyword">int</span> dataPath, <span class="hljs-keyword">int</span> hubId, <span class="hljs-keyword">int</span> endpointId,
                                   <span class="hljs-keyword">int</span> requestedMaxPacketSize)</span> </span>{
        mDataPath = dataPath;
        mHubId = hubId;
        mEndpointId = endpointId;
        mRequestedMaxPacketSize = requestedMaxPacketSize;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getDataPath</span><span class="hljs-params">()</span> </span>{ <span class="hljs-keyword">return</span> mDataPath; }
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getHubId</span><span class="hljs-params">()</span> </span>{ <span class="hljs-keyword">return</span> mHubId; }
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getEndpointId</span><span class="hljs-params">()</span> </span>{ <span class="hljs-keyword">return</span> mEndpointId; }
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">getRequestedMaxPacketSize</span><span class="hljs-params">()</span> </span>{ <span class="hljs-keyword">return</span> mRequestedMaxPacketSize; }
}
</code></pre>
<p>When a new socket is created in Android Bluetooth, the system or privileged service can pass one of these settings objects down to the stack. The key line is <code>DATA_PATH_HARDWARE_OFFLOAD</code>. That’s the switch that tells the Bluetooth system, <em>hey, try to keep this traffic on the controller’s microprocessor rather than waking up the main CPU.</em></p>
<p><code>hubId</code> and <code>endpointId</code> are like addresses on the island. They tell the firmware which logical port or queue to use for that particular socket. The <code>requestedMaxPacketSize</code> helps it tune buffer allocation, so it can balance throughput and power efficiency.</p>
<p>At this point you might be wondering, how does this tiny Java object actually make its way down to the hardware? The answer lies in the HAL (Hardware Abstraction Layer). When you call something like <code>BluetoothSocket.connect()</code>, it eventually funnels down through native code in files such as <code>btif_sock.cc</code> and <code>btif_core.cc</code>. There, you will see traces like:</p>
<pre><code class="lang-cpp"><span class="hljs-keyword">bt_status_t</span> status = BTA_SockConnect(type, addr, channel, flags);
<span class="hljs-keyword">if</span> (settings.data_path == DATA_PATH_HARDWARE_OFFLOAD) {
    BTIF_TRACE_DEBUG(<span class="hljs-string">"Configuring socket for hardware offload path"</span>);
    BTA_SockSetOffloadParams(settings.hub_id, settings.endpoint_id);
}
</code></pre>
<p>This snippet may look simple, but it represents a major shift in responsibility. Instead of sending every packet up to the host stack, the Bluetooth controller can now claim ownership of the data path. The Bluetooth firmware inside the SoC will then take over, handling packet retransmissions, acknowledgments, and flow control without constantly waking the main CPU.</p>
<p>If you monitor your device’s kernel log during such a connection, you might even spot something like:</p>
<pre><code class="lang-cpp">bt_vendor: enabling LPI offload <span class="hljs-keyword">for</span> handle <span class="hljs-number">0x0041</span>
bt_controller: lpi path active, cpu wakelocks released
</code></pre>
<p>That log line is your quiet confirmation that the data path has successfully migrated to the low power island.</p>
<p>In human terms, the phone just decided that this Bluetooth conversation is predictable enough to be handled by the mini-processor, so it politely told the big CPU, “You can take a nap now. I got this.”</p>
<p>In the next section we will follow this journey one level deeper, right into the HAL and firmware boundary, to see how these socket settings turn into actual low-power data routing inside the controller chip. This is where the real hardware magic happens, and where the savings start adding up every milliwatt at a time.</p>
<h2 id="heading-inside-the-hal-how-bluetooth-offload-really-works">Inside the HAL: How Bluetooth Offload Really Works</h2>
<p>So far, we’ve stayed mostly in Android’s Java and native layers, the comfy apartment where frameworks and system services live. But beneath that lies a basement full of clever machinery: the <strong>Hardware Abstraction Layer</strong>, or HAL. This is where Android stops talking in “objects” and starts speaking in opcodes and buffers, and it’s the bridge between software and silicon.</p>
<p>When the BluetoothSocketSettings flag tells the system “please use hardware offload”, that request doesn’t magically teleport to the chip. It walks step by step down the Bluetooth stack, crossing through JNI (Java Native Interface) into C++, then into HAL, which is defined inside <code>hardware/interfaces/bluetooth/</code>.</p>
<p>Starting from Android 14 and especially in AOSP 16, the HAL has grown smarter: it now understands LPI capabilities and can route certain socket traffic to them.</p>
<p>Let’s take a peek inside a simplified HAL function. This is not a fictional snippet. It’s close to what you might find in <code>bluetooth_audio_hw.cc</code> or <code>bluetooth_socket_hal.cc</code>:</p>
<pre><code class="lang-cpp"><span class="hljs-function">Return&lt;<span class="hljs-keyword">void</span>&gt; <span class="hljs-title">BluetoothHci::createSocketChannel</span><span class="hljs-params">(
        <span class="hljs-keyword">const</span> hidl_string&amp; device, <span class="hljs-keyword">const</span> BluetoothSocketSettings&amp; settings,
        createSocketChannel_cb _hidl_cb)</span> </span>{
    <span class="hljs-keyword">int</span> fd = <span class="hljs-number">-1</span>;
    <span class="hljs-keyword">if</span> (settings.data_path == DATA_PATH_HARDWARE_OFFLOAD) {
        ALOGI(<span class="hljs-string">"LPI offload requested for socket on hub %d endpoint %d"</span>,
              settings.hub_id, settings.endpoint_id);
        fd = controller-&gt;allocateLpiChannel(settings.hub_id, settings.endpoint_id);
    } <span class="hljs-keyword">else</span> {
        fd = controller-&gt;allocateHostChannel();
    }
    _hidl_cb(Status::SUCCESS, fd);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">void</span>();
}
</code></pre>
<p>In plain English, this method is like the traffic officer at the Bluetooth crossroads. It looks at your socket settings and decides which road to send your data on. If <code>DATA_PATH_HARDWARE_OFFLOAD</code> is set, the data path is wired to the controller’s internal MCU instead of the regular host-side buffer.</p>
<p>The call to <code>controller-&gt;allocateLpiChannel()</code> is where the HAL says, “Okay chip, please create a queue that lives entirely inside your low-power processor.” This microcontroller is physically closer to the Bluetooth radio. It can handle acknowledgments, small data bursts, and even some protocol timing on its own, things that would normally require waking the main CPU.</p>
<p>Once this channel is created, the Android framework and apps still see a normal file descriptor, as if the socket were entirely local. The magic lies in the fact that this descriptor is backed by firmware-managed memory and DMA paths rather than by Linux kernel buffers.</p>
<p>If you were to attach a debugger or dump logs from the controller, you might see something like:</p>
<pre><code class="lang-cpp">bt_lpi_mcu: channel <span class="hljs-number">0x03</span> opened <span class="hljs-keyword">for</span> handle <span class="hljs-number">0x0041</span>
bt_hci: diverting ACL packets to LPI path
bt_lpi_mcu: sleeping host processor
</code></pre>
<p>That third line, <code>sleeping host processor</code>, is the dream come true for every power engineer. The phone literally turns off big chunks of the CPU subsystem while keeping Bluetooth alive.</p>
<p>This is also where vendors like Qualcomm or Broadcom add their special sauce. Their HALs often include extra hooks for “keep-alive” timers, “coalescing intervals,” and “firmware-driven retransmissions.” These ensure the connection feels smooth even though the main processor is off-duty.</p>
<p>From a high-level view, the pipeline now looks like this:</p>
<pre><code class="lang-cpp">App -&gt; Bluetooth Framework -&gt; JNI -&gt; btif_sock -&gt; HAL -&gt; <span class="hljs-function">Controller <span class="hljs-title">MCU</span> <span class="hljs-params">(LPI)</span></span>
</code></pre>
<p>Every layer understands just enough to pass the baton cleanly to the next. The HAL acts as the translator, taking high-level settings and turning them into low-level commands that the chip firmware can execute.</p>
<p>By the time your smartwatch sends a packet or your earbuds request an audio chunk, the main CPU doesn’t even blink. The entire transaction lives and dies within the Bluetooth controller’s tiny domain, sipping power rather than gulping it.</p>
<p>In the next section, we’ll explore how this offload architecture integrates with Android’s power management system, including wakelocks, doze modes, and kernel coordination, and how it ensures that even though the main CPU is asleep, the connection never misses a beat.</p>
<h2 id="heading-when-the-cpu-sleeps-but-bluetooth-doesnt-power-management-in-action">When the CPU Sleeps but Bluetooth Doesn’t: Power Management in Action</h2>
<p>Alright, we have seen how the socket offload travels from the app layer down into the HAL and finally lands on that tiny MCU that lives inside the Bluetooth chip. But what happens next? What if your phone’s main CPU decides to take a nap while a file transfer or an audio stream is still going on? Doesn’t that risk breaking the Bluetooth connection?</p>
<p>This is where Android’s <strong>power management choreography</strong> steps in. It is a dance between three performers: the <strong>Power HAL</strong>, the <strong>Bluetooth stack</strong>, and the <strong>kernel wakelock system</strong>.</p>
<p>When a Bluetooth socket gets configured for Low Power Island, Android’s Bluetooth stack signals the kernel that this connection can be maintained without the help of the main CPU. Internally, it clears or downscales the wakelock timers that would normally keep the processor awake during Bluetooth traffic. In kernel logs, you might see something like this:</p>
<pre><code class="lang-cpp">wakelock: release <span class="hljs-string">"bt_wake"</span> (LPI mode active)
bt_controller: firmware handling link supervision locally
</code></pre>
<p>This message is gold for system engineers. It tells you the controller has taken full ownership of the connection. The Bluetooth firmware is now monitoring supervision timeouts, handling retransmissions, and maintaining encryption counters.</p>
<p>From the power manager’s point of view, the Bluetooth device looks “idle” because no interrupts are being generated toward the main CPU. Meanwhile, the controller MCU quietly exchanges packets with your earbuds or smartwatch using its own low-power clock domain.</p>
<p>To coordinate this, the Bluetooth HAL exposes small callbacks that inform the Power HAL whenever traffic levels change. You might find a snippet like this in <code>bt_vendor_qcom.cc</code>:</p>
<pre><code class="lang-cpp"><span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">bt_lpi_activity_update</span><span class="hljs-params">(<span class="hljs-keyword">bool</span> active)</span> </span>{
    <span class="hljs-keyword">if</span> (active)
        power_hint(POWER_HINT_LPI_ACTIVITY, <span class="hljs-number">1</span>);
    <span class="hljs-keyword">else</span>
        power_hint(POWER_HINT_LPI_ACTIVITY, <span class="hljs-number">0</span>);
}
</code></pre>
<p>When <code>active</code> goes to zero, the Power HAL knows it can allow deeper system sleep states (like suspend-to-RAM), because Bluetooth will keep things alive on its own.</p>
<p>The real magic is that the user never notices any of this. The phone can appear “asleep”, display off, CPU cores gated, yet your Bluetooth audio still plays, your smartwatch still syncs, and your phone remains discoverable.</p>
<p>It’s almost poetic. The main processor is dreaming, the controller hums softly, and your playlist keeps rolling like nothing happened.</p>
<p>If you want to verify this on a real Android device, you can use the command:</p>
<pre><code class="lang-cpp">adb shell cat /sys/kernel/debug/wakeup_sources | grep bt
</code></pre>
<p>When you see that <code>bt_wake</code> counter stays low even during streaming, congratulations! The Low Power Island offload is doing its job beautifully.</p>
<p>In the next section, we’ll climb back up from the firmware depths to see how all this fits into the everyday developer’s world. Can you, as an app or system developer, actually control or benefit from these socket settings directly? And how can understanding them help you build Bluetooth apps that sip rather than chug power?</p>
<h2 id="heading-how-developers-can-harness-bluetoothsocketsettings">How Developers Can Harness BluetoothSocketSettings</h2>
<p>Now that we’ve peered deep into the heart of the Bluetooth stack, let’s climb back up to where you and I actually live: the developer layer. You might be wondering, “Okay, all that hardware wizardry is cool, but what can I actually <em>do</em> with it?”</p>
<p>Here’s the fun part: even though Low Power Island is mostly a system-level feature, understanding how it works can still help you design Bluetooth apps that are more power-friendly and predictable.</p>
<p>At the framework level, you can’t directly toggle LPI on or off from your app. Those switches live deep in system components like BluetoothService and BluetoothSocketManagerService. But every time you use a <code>BluetoothSocket</code> or <code>BluetoothServerSocket</code>, your data silently flows through those layers that check whether LPI offload is available.</p>
<p>That means your app benefits automatically, <em>as long as you don’t do anything that forces the CPU to stay awake unnecessarily</em>. For example, using proper thread sleeps, avoiding busy loops, and letting Android’s own Bluetooth I/O streams handle buffering will keep you in the good graces of the offload logic.</p>
<p>If you dive into AOSP’s system server logs while connecting a Bluetooth socket, you might notice something like this:</p>
<pre><code class="lang-cpp">BluetoothSocketManager: Offload eligible socket detected, enabling LPI mode
Bluetooth HAL: LPI channel activated <span class="hljs-keyword">for</span> fd=<span class="hljs-number">42</span>
</code></pre>
<p>That little line tells you that your socket has been quietly rerouted through the island, without you lifting a finger.</p>
<p>Underneath, the framework created a <code>BluetoothSocketSettings</code> object and passed it down the chain when the socket was opened. In pseudo-Java, it looks like this:</p>
<pre><code class="lang-cpp">BluetoothSocketSettings settings =
    <span class="hljs-keyword">new</span> BluetoothSocketSettings(
        BluetoothSocketSettings.DATA_PATH_HARDWARE_OFFLOAD,
        <span class="hljs-comment">/* hubId */</span> <span class="hljs-number">1</span>,
        <span class="hljs-comment">/* endpointId */</span> <span class="hljs-number">2</span>,
        <span class="hljs-comment">/* maxPacketSize */</span> <span class="hljs-number">512</span>);

BluetoothSocket socket = adapter.createSocket(device, settings);
socket.connect();
</code></pre>
<p>Of course, this isn’t part of the public SDK yet, but system apps or privileged frameworks use similar calls to describe how traffic should be handled.</p>
<p>So why should you, the developer, care? Because knowing that such a path exists means you can <em>design with it in mind</em>. For instance, you can:</p>
<ul>
<li><p>Batch small BLE writes instead of sending them one by one, allowing the controller to process them efficiently inside the offload buffer.</p>
</li>
<li><p>Avoid frequent connect/disconnect cycles, which would force the stack to wake the main CPU repeatedly.</p>
</li>
<li><p>Structure your background transfers to fit neatly within the limits of low-power buffers (think smaller chunks and longer intervals).</p>
</li>
</ul>
<p>Essentially, the more predictable your data pattern is, the more likely it is to stay in the island without waking the host.</p>
<p>If you’re building system software, say for a custom Android device or embedded product, then you can go even further. You can tweak the HAL behavior, assign custom hub or endpoint IDs, and even tune the maximum packet size that the firmware uses for DMA transfers. This allows you to build Bluetooth features: such as low-energy telemetry streaming or wearable sensor sync, that run almost entirely offloaded.</p>
<p>At that point, your Bluetooth chip becomes a mini server that keeps working while the main OS sleeps, delivering remarkable battery life and snappy reconnections.</p>
<p>In the final section, we’ll wrap things up and look back at the big picture, why BluetoothSocketSettings and Low Power Island together represent one of the most elegant examples of Android’s “invisible engineering.” It’s one of those quiet triumphs you’ll rarely see in a keynote but feel every day when your phone still has juice at midnight.</p>
<h2 id="heading-the-grand-finale-the-elegance-of-sleeping-smart">The Grand Finale: The Elegance of Sleeping Smart</h2>
<p>Let’s take a step back for a moment. We started in a coffee shop with an overworked barista. Then we discovered a hidden assistant, the Low Power Island, that quietly keeps the café running even when the main barista steps away.</p>
<p>We followed the path of a humble Bluetooth socket, watched it get wrapped in <code>BluetoothSocketSettings</code>, journeyed through the HAL, and finally land on a miniature processor inside the controller that hums along while the big CPU dreams.</p>
<p>And that’s the beauty of it: Android’s Bluetooth offload mechanism is one of the most elegant examples of invisible engineering. It doesn’t announce itself with a new API or a fancy animation. It just silently makes your battery last longer, your Bluetooth more reliable, and your phone feels smoother, all without you even knowing it’s there.</p>
<p>From a technical point of view, the brilliance lies in the balance. The system still allows full-featured sockets and rich protocol handling when you need it, but for common data flows, audio, telemetry, notifications, or heart rate streaming, it lets the low-power controller take the wheel. It’s like Android learned to delegate.</p>
<p>Every time your smartwatch syncs while your phone screen is off, or your earbuds stay connected during a long flight without draining your battery, you are seeing <code>BluetoothSocketSettings</code> and the Low Power Island framework at work. They are part of a larger philosophy in modern Android design, moving intelligence closer to hardware. The more we teach our chips to handle autonomic tasks, the more we can let the main processor rest.</p>
<p>If you are a developer or system engineer, understanding this architecture isn’t just academic. It can inspire how you design your own features. Whether you’re building a custom Android ROM, optimizing firmware for wearables, or creating IoT devices with a Bluetooth chip, the lesson is clear: don’t make your main CPU babysit every packet. Offload when you can, sleep when you should, and your devices will thank you with hours of extra uptime.</p>
<p>So the next time you plug in your earbuds and notice your phone staying cool and your battery percentage barely moving, remember: somewhere deep inside, a tiny Bluetooth MCU is doing all the heavy lifting while the main CPU enjoys a nap in its low-power hammock.</p>
<p>That’s the quiet genius of Android’s Low Power Island and BluetoothSocketSettings. It’s not just about Bluetooth. It’s about teaching our devices to be smarter, not busier. And maybe, just maybe, that’s a lesson worth remembering for ourselves too.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Secret Life of Your CPU: Exploring the Low Power Island in Android Bluetooth ]]>
                </title>
                <description>
                    <![CDATA[ If your phone were a person, it would probably be that overachieving friend who cannot sit still. The kind who insists they are relaxing while secretly running errands, replying to messages, and checking the weather at the same time. Inside your Andr... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-secret-life-of-your-cpu-exploring-the-low-power-island-in-android-bluetooth/</link>
                <guid isPermaLink="false">69164a5b08d80a5fa5d56f1e</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ LowPowerConsumption ]]>
                    </category>
                
                    <category>
                        <![CDATA[ aosp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Chip ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Thu, 13 Nov 2025 21:15:07 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763065956169/7d83bf98-a7a8-42cd-b27b-f6c202612959.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If your phone were a person, it would probably be that overachieving friend who cannot sit still. The kind who insists they are relaxing while secretly running errands, replying to messages, and checking the weather at the same time.</p>
<p>Inside your Android device, something very similar is happening every moment. One second the processor is streaming your playlist over Bluetooth, the next it’s processing notifications, tracking your location, or syncing data in the background. Somehow it manages all this without melting through your jeans or begging for a charger before lunch.</p>
<p>The secret behind this superhuman stamina lies in a small sanctuary inside the silicon known as the Low Power Island, often abbreviated as LPI. Think of it as a meditation corner for your processor. When there is nothing urgent to do, parts of the chip quietly retreat into this space to rest, while a few essential components stay awake to keep an eye on the world.</p>
<p>Imagine your CPU as a busy coffee shop. The main baristas are the high-performance cores, darting around to prepare fancy espresso drinks for demanding apps like games or video editors. The smaller efficiency cores handle lighter orders such as notifications or background tasks. Now picture a lonely drip coffee machine humming in the corner after closing hours. It keeps the essentials running without using much energy. That humble machine is your Low Power Island.</p>
<p>When Android realizes that no one is touching the screen, no heavy computation is in progress, and no critical wake locks are active, it lets the device drift into this gentle half-sleep. The system is not entirely unconscious because someone still needs to listen for alarms, network activity, or Bluetooth packets. It’s more like a cat napping with one ear twitching for sound.</p>
<p>This design allows modern devices to conserve power while staying responsive. In older systems, going to sleep meant shutting everything down and then painfully waking up for a single event. That would be like turning off the coffee shop’s electricity every time there were no customers, then waiting for the machines to warm up when the next order arrived. The Low Power Island avoids that waste by keeping only the essentials alive.</p>
<p>So the next time your phone lights up instantly after hours of lying still, remember that deep inside your processor, a few quiet transistors were guarding the gates. They were not fully awake or fully asleep but floating peacefully in the middle. That is the Low Power Island, the hidden hero of Android’s battery endurance.</p>
<p>In this article, we’re going to lift the curtain on that hero. You’ll see how the LPI works, not just as a sleepy nook for the CPU but as a full-fledged power-management strategy woven into Android’s architecture. We’ll also explore how Bluetooth keeps chatting quietly inside the island without waking the big cores, how the Power HAL and kernel orchestrate every nap and wake cycle, and how firmware plays the role of a tireless night guard.</p>
<p>You’ll get real AOSP snippets, real kernel logs, and practical advice on writing Bluetooth code that cooperates with the island instead of barging in loudly.</p>
<p>By the end, you’ll understand why your phone lasts as long as it does, and how this hidden corner of silicon keeps everything running with calm precision.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-the-low-power-island-lpi-in-android-bluetooth">What is the Low Power Island (LPI) in Android Bluetooth?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-silent-orchestra-how-lpi-works-with-power-hal--kernel">The Silent Orchestra: How LPI Works with Power HAL &amp; Kernel</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-debugging-and-verifying-low-power-island-in-bluetooth">Debugging and Verifying Low Power Island in Bluetooth</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-teaching-bluetooth-to-nap-smarter">Teaching Bluetooth to Nap Smarter</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion-the-quiet-genius-inside-your-phone">Conclusion: The Quiet Genius Inside Your Phone</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-low-power-island-lpi-in-android-bluetooth">What is the Low Power Island (LPI) in Android Bluetooth?</h2>
<p>Bluetooth is a social butterfly. Even when the screen is dark, it keeps whispering to your earbuds, smartwatch, or car stereo, exchanging packets of data that make life feel seamless. The problem is that constant conversation consumes energy. Waking the entire phone every few seconds just to send a few bytes would be like turning on stadium floodlights to find your keys.</p>
<p>This is where the Low Power Island becomes the hero again. Inside modern Android phones, Bluetooth communication is handled by a dedicated <strong>Bluetooth controller</strong>, a small microprocessor within the same system-on-chip as the main CPU. This controller has its own memory and its own power domain. It can stay partially awake while the big CPU cores rest, maintaining connections and handling radio traffic with almost no help from the main processor.</p>
<p>When Android’s <strong>Power Manager</strong> decides the system can sleep, it sends signals through the <strong>Bluetooth HAL</strong> and vendor driver to let the controller know that the host side is entering a low-power state. The controller then takes over lightweight tasks on its own, such as keeping connections alive, scheduling sniff intervals, and handling encryption handshakes. The result is a seamless experience where your earbuds remain paired and responsive while the rest of your phone quietly saves power.</p>
<p>A simplified peek inside AOSP’s Bluetooth service shows this collaboration in action:</p>
<pre><code class="lang-cpp"><span class="hljs-comment">// From system/bt/service/btif/src/btif_core.cc</span>

<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">btif_pm_enter_low_power_mode</span><span class="hljs-params">()</span> </span>{
    LOG_INFO(<span class="hljs-string">"%s: entering low power mode"</span>, __func__);
    <span class="hljs-comment">// Notify controller to enter sleep mode</span>
    BTA_dm_pm_btm_status_evt(BTA_DM_PM_BTM_STATUS_IDLE);
    <span class="hljs-comment">// Suspend host stack threads</span>
    btif_thread_suspend();
}

<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">btif_pm_exit_low_power_mode</span><span class="hljs-params">()</span> </span>{
    LOG_INFO(<span class="hljs-string">"%s: exiting low power mode"</span>, __func__);
    <span class="hljs-comment">// Resume host stack threads</span>
    btif_thread_resume();
    <span class="hljs-comment">// Notify controller that the host is active again</span>
    BTA_dm_pm_btm_status_evt(BTA_DM_PM_BTM_STATUS_ACTIVE);
}
</code></pre>
<p>These functions represent a small slice of a much larger conversation between Android and the controller. The host stack quietly pauses while the controller keeps watch. On many chip vendor platforms, this state is called <strong>Controller Sleep</strong> or <strong>Snooze Mode</strong>. The Bluetooth controller can wake the host only when something meaningful occurs, such as an incoming call or a button press from your headset.</p>
<p>It works like a night security guard who patrols a building after everyone has gone home. The lights stay off, the air is still, but someone is always alert. If something happens, the guard rings the bell, and the rest of the crew wakes up. That is how your phone’s Bluetooth keeps working even when the display is dark and the CPU cores are resting inside the Low Power Island.</p>
<p>This collaboration between hardware, firmware, and Android’s power management makes it possible for you to listen to music, receive smartwatch notifications, or resume playback instantly without draining the battery. It’s quiet efficiency at its finest, a balance between awareness and rest that defines the beauty of modern Android design.</p>
<h2 id="heading-the-silent-orchestra-how-low-power-island-works-with-android-power-hal-and-the-kernel">The Silent Orchestra: How Low Power Island Works with Android Power HAL and the Kernel</h2>
<p>If you could peek under Android’s hood while your phone is asleep, you would see something that looks a lot like a perfectly timed orchestra. Every instrument knows when to play softly, when to rest, and when to come back in without missing a beat.</p>
<p>The Low Power Island is not a solo performer in this show. It is more like the gentle rhythm section, coordinated by a set of invisible conductors that live inside the <strong>Power HAL</strong>, the <strong>kernel</strong>, and the <strong>firmware</strong>.</p>
<p>Let’s start with the <strong>Power HAL</strong>, or Hardware Abstraction Layer. In Android, the Power HAL acts as the middleman between the system framework and the low-level kernel drivers. Whenever Android decides it can lower power consumption, it communicates this decision through HAL interfaces. The Power HAL talks to the chipset vendor’s implementation to decide which parts of the hardware can safely go to sleep. It controls not only the CPU clusters but also the GPU, display pipeline, and peripheral controllers like Bluetooth and Wi-Fi.</p>
<p>In a simplified sense, Android’s power manager says something like, “Hey HAL, we are idle now, can we nap for a bit?” The Power HAL then checks with the kernel and hardware to see who can afford to sleep. If the Bluetooth controller confirms that it can handle ongoing communication alone, the Power HAL signals the kernel to start shutting down parts of the main processor.</p>
<p>The <strong>kernel</strong>, in turn, manages this transition through its <strong>power domains</strong> and <strong>clock gating</strong> systems. Each hardware block in the chip belongs to a specific power domain. The kernel knows which domains can be turned off entirely and which must stay partially active.</p>
<p>The Bluetooth controller usually belongs to a domain that supports <strong>retention mode</strong>, meaning that some of its memory and logic stay powered just enough to preserve state.</p>
<p>A typical flow looks something like this inside the kernel logs when the device starts entering LPI mode:</p>
<pre><code class="lang-bash">PM: <span class="hljs-built_in">suspend</span> entry (deep)
controller-bluetooth 0001:00:00.0: entering controller sleep
PM: <span class="hljs-built_in">suspend</span> devices complete
PM: <span class="hljs-built_in">suspend</span> <span class="hljs-built_in">exit</span>
controller-bluetooth 0001:10:00.0: waking host
</code></pre>
<p>In this short exchange, you can see how Android’s power manager orchestrates the entire sleep-wake process. The Bluetooth driver reports that it’s entering controller sleep, the kernel confirms that all devices have suspended, and then later wakes everything up when an interrupt occurs.</p>
<p>At the hardware level, this behavior depends on <strong>voltage islands</strong> and <strong>clock domains</strong> defined by the SoC manufacturer. The term “island” is not metaphorical here – it literally represents an electrically isolated region on the chip that can be powered independently. When the kernel puts the main CPU to sleep, power to that island is lowered or shut off, while another island containing the Bluetooth controller continues to operate using a small independent oscillator.</p>
<p>Meanwhile, the <strong>firmware</strong> running on the Bluetooth controller performs light housekeeping. It manages scheduled events such as connection intervals, sniff subrate transitions, and link supervision timeouts. It can even decrypt or re-encrypt packets without disturbing the host processor. This allows Android to maintain a live Bluetooth connection while consuming a fraction of the power it would normally use.</p>
<p>When an event that requires higher-level attention occurs, such as a user pressing a button on their headset, the controller raises a <strong>host wake signal</strong> over the UART or shared memory transport. The kernel receives this interrupt, restores the CPU clock, and resumes Android’s power manager. The host stack reactivates, processes the event, and then gracefully hands control back once it’s idle again.</p>
<p>This dance between the Power HAL, kernel, and firmware might sound complicated, but it’s one of the most elegant designs inside Android. Each layer plays its role precisely. The Power HAL negotiates the policies, the kernel enforces them, and the firmware quietly executes them in the background. Together, they make sure that your phone feels instantly awake even after hours of rest.</p>
<p>The next time your earbuds reconnect without delay after your phone has been sleeping in your pocket, know that a whole chain of software and silicon cooperated flawlessly to make it happen. The Low Power Island was not just saving power – it was conducting a silent orchestra beneath your fingertips.</p>
<h2 id="heading-debugging-and-verifying-low-power-island-in-bluetooth">Debugging and Verifying Low Power Island in Bluetooth</h2>
<p>If you have ever watched a sleeping cat twitch its ears and wondered whether it’s dreaming, that’s pretty much what debugging the Low Power Island looks like on Android. The device may appear still, but deep within the logs, tiny ripples of life show up every few seconds. Engineers love this quiet chaos because it tells them the system is balancing perfectly between rest and readiness.</p>
<p>When Bluetooth enters its low power phase, Android leaves behind a breadcrumb trail of clues. You can see them in both <strong>logcat</strong> and <strong>kernel dmesg</strong> outputs. These logs help confirm whether the Bluetooth controller is indeed entering its low power state while the host CPU retreats to the island of calm.</p>
<p>A simple way to peek into this process is to run:</p>
<pre><code class="lang-bash">adb logcat -b all | grep -i <span class="hljs-string">"btif_pm"</span>
</code></pre>
<p>You might see something like this:</p>
<pre><code class="lang-bash">08-05 12:23:44.732  1712  1725 I bt_btif_pm: entering low power mode
08-05 12:23:44.733  1712  1725 I bt_btif_pm: controller idle, suspending host threads
08-05 12:23:46.008  1712  1725 I bt_btif_pm: exiting low power mode
</code></pre>
<p>Each line tells part of the story. The first message confirms that Android’s Bluetooth stack has requested entry into the low power state. The second shows that the host-side threads have paused, and the final message shows that the controller has woken the host again.</p>
<p>To see what is happening underneath, you can check kernel logs:</p>
<pre><code class="lang-bash">adb shell dmesg | grep -i bluetooth
</code></pre>
<p>You might find entries such as:</p>
<pre><code class="lang-bash">[ 1423.347102] controller-bluetooth 0001:00:00.0: entering controller sleep
[ 1423.347117] PM: <span class="hljs-built_in">suspend</span> entry (deep)
[ 1425.105993] controller-bluetooth 0001:00:00.0: host wake received
[ 1425.106005] PM: resume complete
</code></pre>
<p>These lines confirm that the Bluetooth driver and the power management system are cooperating correctly. The controller went to sleep, the kernel suspended the CPU clusters, and everything woke back up when a wake signal arrived from the Bluetooth controller.</p>
<p>If you ever see the host waking up too frequently, it usually means some component is not respecting sleep boundaries. Common culprits include misbehaving wake locks, noisy apps requesting continuous scanning, or timers that never expire. In such cases, Android’s <strong>PowerStats HAL</strong> and <strong>Batterystats</strong> framework can help track down who is preventing deep sleep.</p>
<p>You can check the overall low-power statistics using:</p>
<pre><code class="lang-bash">adb shell dumpsys batterystats | grep <span class="hljs-string">"bluetooth"</span>
</code></pre>
<p>This reveals how long the Bluetooth subsystem stayed active compared to how long the system was in low power mode. Ideally, the numbers should show that Bluetooth remains mostly idle except for brief wake periods.</p>
<p>Engineers working on system bring-ups often use specialized tracing tools such as <code>systrace</code>, <code>ftrace</code>, or <code>perfetto</code> to visualize power transitions. A power trace shows a rhythm: a long flat line representing sleep, interrupted by sharp spikes of activity when the controller wakes the host for a meaningful event. If those spikes are too frequent, you know the system is not entering Low Power Island efficiently.</p>
<p>Here is an excerpt from a typical Perfetto trace snippet:</p>
<pre><code class="lang-bash">bluetooth_host_state: IDLE → SUSPENDED
bluetooth_controller_state: ACTIVE → SLEEP
kernel_cpu_cluster_0: ACTIVE → RETENTION
kernel_cpu_cluster_1: ACTIVE → POWER_OFF
</code></pre>
<p>This simple sequence tells a powerful story. The host stack suspended, the controller slept, and the CPU clusters powered down gracefully. When the next event occurs, the transitions reverse, and the device wakes almost instantly.</p>
<p>Behind the scenes, vendor firmware plays a crucial role in making this magic look effortless. The Bluetooth controller firmware maintains timing slots, sniff intervals, and link-layer encryption keys, all while running on a few milliwatts of power. It’s astonishingly efficient. A typical controller can maintain an active ACL connection with power consumption under one milliwatt, even while the main CPU cores are completely powered down.</p>
<p>Debugging this system feels a bit like birdwatching. You have to stay patient, quiet, and observant. Most of the time, nothing dramatic happens in the logs. But when you finally catch a perfect sleep–wake cycle, it feels like witnessing nature in harmony. That is the beauty of Android’s Low Power Island at work with Bluetooth.</p>
<p>So when your earbuds reconnect in half a second or your smartwatch syncs data silently while your phone rests on the table, remember this quiet orchestra behind the scenes. It’s not brute power but smart power management that makes the experience feel smooth. The Low Power Island is the invisible craftsman that gives your Android Bluetooth its calm precision, saving battery one sleepy packet at a time.</p>
<h2 id="heading-teaching-bluetooth-to-nap-smarter">Teaching Bluetooth to Nap Smarter</h2>
<p>If the Low Power Island were a yoga retreat for your processor, then your job as a developer would be to make sure your Bluetooth code doesn’t show up with a drum set. It’s easy to accidentally keep the system awake when you don’t need to. A single careless wake lock, a recurring timer, or a never-ending scan request can prevent the hardware from entering that calm, power-efficient state.</p>
<p>The goal of optimizing for Low Power Island is not to make your Bluetooth logic work less. It’s to make it <strong>work wisely</strong>, to let the controller handle small background exchanges while the main CPU sleeps peacefully. Android’s Bluetooth stack and vendor drivers already handle most of the heavy lifting, but developers can make a big difference by writing energy-conscious code that respects those boundaries.</p>
<p>The first rule is simple: <strong>scan responsibly</strong>. Continuous scanning is the number-one villain in Bluetooth power profiles. Each scan wakes the radio, the controller, and often the host processor. If your app continuously calls <code>BluetoothLeScanner.startScan()</code> without a clear stop condition, you are effectively shining a flashlight into the Low Power Island every few seconds.</p>
<p>Instead, batch your scans and use filters. The system’s <code>ScanSettings.SCAN_MODE_LOW_POWER</code> mode is specifically designed to allow scanning that cooperates with LPI transitions.</p>
<p>Here’s an example from AOSP that shows how you can trigger a scan in a power-friendly way:</p>
<pre><code class="lang-java">ScanSettings settings = <span class="hljs-keyword">new</span> ScanSettings.Builder()
        .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
        .setReportDelay(<span class="hljs-number">5000</span>) <span class="hljs-comment">// batch results every 5 seconds</span>
        .build();

bluetoothLeScanner.startScan(filters, settings, scanCallback);
</code></pre>
<p>By batching results and letting the hardware handle scanning internally, you reduce host wakeups dramatically. The Bluetooth controller can gather advertisements on its own, waking the CPU only once every few seconds to deliver results.</p>
<p>The second rule is to <strong>let the stack sleep</strong>. Many developers unknowingly block Bluetooth threads by holding wake locks or running unnecessary callbacks. The Android Bluetooth stack maintains internal synchronization through message loops that can safely pause during idle periods.</p>
<p>Avoid long-running operations in callbacks such as <code>BluetoothGattCallback.onCharacteristicChanged()</code>. Instead, offload work to background executors that respect Android’s Doze and App Standby policies.</p>
<p>Another optimization lies in <strong>using connection intervals and latency wisely</strong>. BLE connections allow you to configure how frequently devices exchange packets. A shorter interval improves responsiveness but burns energy. A longer interval gives more opportunities for the controller to rest between events. If your use case allows it, choose higher connection intervals and peripheral latency values when initializing connections.</p>
<pre><code class="lang-java"><span class="hljs-comment">// Example: Requesting a higher connection interval in GATT</span>
bluetoothGatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER);
</code></pre>
<p>Under the hood, this tells the Bluetooth controller to lengthen its sniff interval, letting both ends of the link spend more time in low power mode. The result is longer battery life with almost no visible impact on user experience for background updates or sensor reads.</p>
<p>At the system level, engineers tuning platform behavior can also adjust parameters in the Power HAL and kernel configuration. The <code>/sys/power</code> directory contains tunables for CPU retention and controller wake thresholds. Tools like perfetto, systrace, and btsnooz.py can visualize Bluetooth power events, helping verify that sleep cycles are happening as expected.</p>
<p>For example, a trace showing too many wakeups per second might look like this:</p>
<pre><code class="lang-bash">bluetooth_host_state: SUSPENDED → ACTIVE
reason: controller wake (LL control packet)
interval: 150 ms
</code></pre>
<p>If you see dozens of such wakeups in a short time, it might indicate an overly aggressive connection interval or constant GATT notifications from a peripheral. Adjusting those parameters can bring the wake interval down to seconds instead of milliseconds, drastically improving power efficiency.</p>
<p>The third and perhaps most important rule is <strong>know when to let go</strong>. When your app finishes a Bluetooth operation, always close the GATT connection, stop scanning, and release references. Many developers forget this step, leaving ghost connections or scans running silently in the background. Each one is like leaving a window open during winter: the heater works harder, and battery life suffers.</p>
<p>Finally, remember that not every Bluetooth event deserves a host wakeup. Modern controllers can handle encryption refreshes, supervision timeouts, and advertisement filtering entirely on their own. Trust the hardware. Android’s Low Power Island and Bluetooth stack are designed to delegate intelligently. The less your app interferes, the smoother the dance becomes.</p>
<p>Optimizing for Low Power Island is not about disabling features. It’s about building harmony between layers. The Android framework, kernel, and controller firmware already communicate like seasoned musicians in an orchestra. Your code is another instrument in that ensemble. Play lightly, leave room for silence, and let the rest of the system breathe.</p>
<p>When you do it right, your users will never notice a thing. Their earbuds will reconnect instantly, their fitness trackers will sync quietly, and their phones will last an extra few hours each day. Behind the scenes, that serene rhythm of sleep and wake continues, powered by the elegant balance that Low Power Island brings to Android Bluetooth.</p>
<h2 id="heading-conclusion-the-quiet-genius-inside-your-phone">Conclusion: The Quiet Genius Inside Your Phone</h2>
<p>If your phone were a musician, the Low Power Island would be its silent metronome, keeping time, holding rhythm, and making sure the melody never skips a beat. It does not demand attention or boast about its work. It simply exists in the background, saving power in ways most people never realize.</p>
<p>Throughout this journey, we have seen how the Low Power Island serves as the meeting point between hardware and software, where silence becomes strategy. We began with the idea that your CPU, much like a restless friend, needs a place to breathe. We then saw how Bluetooth, the most social of all radios, learns to whisper instead of shout when the rest of the system drifts to sleep. Together, they form one of the most delicate yet powerful mechanisms in Android’s design.</p>
<p>The Bluetooth controller becomes the night guard of the silicon city. While the big CPU cores sleep soundly behind closed gates, the controller patrols quietly, keeping connections alive, listening for signals, and ringing the bell only when something truly important happens. It’s a small but crucial act of cooperation that gives modern Android devices their elegance.</p>
<p>Behind the scenes, the Power HAL negotiates policies, the kernel enforces them, and the firmware executes them with surgical precision. They move like an orchestra, sometimes lively, sometimes silent, but always in harmony. And when your phone wakes instantly to play music, take a call, or reconnect your earbuds, that smoothness is not luck. It is the Low Power Island doing exactly what it was built for: making power management feel invisible.</p>
<p>For developers, understanding this system is not just an exercise in curiosity. It’s a reminder that true optimization does not always come from brute force or faster code. Sometimes it comes from restraint, from knowing when to let go, when to rest, and when to let the system do its quiet magic. Each small decision, batching scans, adjusting connection intervals, respecting sleep boundaries, contributes to a bigger story of balance.</p>
<p>The next time your phone makes it through an entire day of Bluetooth streaming, navigation, and notifications without flinching, take a moment to appreciate what’s happening beneath that glass screen. Inside, a city of transistors is asleep yet awake, calm yet alert, working together in perfect synchronization. The Low Power Island is not just an engineering trick. It is a philosophy: that even in the world of machines, peace and patience can be more powerful than constant motion.</p>
<p>And if you think about it, that is a lesson worth keeping, for both phones and humans alike.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Google Play’s 16 KB Page Size Compatibility Requirement — What You Should Know, and How to Upgrade Your App ]]>
                </title>
                <description>
                    <![CDATA[ Android is always evolving, and sometimes those changes happen a bit under the hood. One such change that's been gaining traction—and now has a firm deadline from Google—is the move to a 16 KB page size. If you're an Android developer, especially wit... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/google-16-kb-page-size-requirement-what-to-do/</link>
                <guid isPermaLink="false">68d703052043890036a92cb0</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ android app development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Arunachalam B ]]>
                </dc:creator>
                <pubDate>Fri, 26 Sep 2025 21:17:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758921064544/80db1a03-73e1-48c3-b2a0-566f20244431.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Android is always evolving, and sometimes those changes happen a bit under the hood. One such change that's been gaining traction—and now has a firm deadline from Google—is the move to a 16 KB page size. If you're an Android developer, especially with native code in your app, understanding this shift is really important for keeping your apps smooth and compatible.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-a-page-size">What is a Page Size?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-is-this-change-being-implemented-now">Why is this Change Being Implemented Now?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-are-the-pros-and-cons-of-this-change">What are the Pros and Cons of this Change?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-should-you-worry-about-this-change">Should You worry About this Change?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-is-this-mandatory">Is this Mandatory?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-if-you-dont-upgrade-your-app">What if You Don’t Upgrade Your App?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-does-this-affect-hybrid-apps">How Does this Affect Hybrid Apps?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-would-be-the-code-change-for-this">What Would be the Code Change for This?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-verify-if-your-app-is-upgraded-to-a-16-kb-page-size">How to Verify if Your App is Upgraded to a 16 KB Page Size</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-a-page-size">What is a Page Size?</h2>
<p>Think of your device's memory like a book. An operating system doesn't read memory one tiny word at a time; it reads in chunks. These chunks are called "pages." For a long time, on most ARM64 Android devices, these pages were 4 KB in size. Now, for some newer Android devices (specifically those launching with Android 13 and later), that page size has quadrupled to 16 KB.</p>
<h2 id="heading-why-is-this-change-being-implemented-now">Why is this Change Being Implemented Now?</h2>
<p>It's all about making Android run better on modern hardware. Here are some of the reasons why is it being implemented:</p>
<p><strong>Better Performance:</strong> Modern processors can handle larger memory chunks more efficiently. A 16 KB page size means the CPU spends less time managing tiny bits of memory and more time doing actual work, which can lead to faster app performance.</p>
<p><strong>Smoother Operations:</strong> With fewer, larger pages to keep track of, the system itself has a little less overhead, making things a bit more streamlined.</p>
<p><strong>Keeping Up with Tech:</strong> This change helps Android align with how newer ARM64 processors are designed to work best.</p>
<h2 id="heading-what-are-the-pros-and-cons-of-this-change">What are the Pros and Cons of this Change?</h2>
<p>Every big change has its own pros and cons.</p>
<h3 id="heading-pros">Pros</h3>
<ul>
<li><p>Apps that move a lot of data around or are memory-intensive might just feel a bit snappier</p>
</li>
<li><p>The system could run a bit more efficiently, benefiting all apps indirectly</p>
</li>
</ul>
<h3 id="heading-cons">Cons</h3>
<ul>
<li><p>If your native code is constantly asking for very small bits of memory (less than 16 KB), each of those might now take up a full 16 KB page, potentially using a little more memory than before.</p>
</li>
<li><p>If your native code makes assumptions that "memory pages are always 4 KB," it could run into issues on 16 KB page devices.</p>
</li>
</ul>
<h2 id="heading-should-you-worry-about-this-change">Should You worry About this Change?</h2>
<p>You need to pay attention if:</p>
<ul>
<li><p>Your app includes native libraries (like <code>.so</code> files) written in C/C++. This is where the impact is most direct. If your native code does anything with memory mapping (mmap, shmem) or file I/O where it calculates offsets or sizes based on a fixed page size.</p>
</li>
<li><p>You're developing games or other highly performance-sensitive apps with native components.</p>
</li>
<li><p>You're targeting Android 15+ with your app updates.</p>
</li>
</ul>
<p>You need not worry if:</p>
<ul>
<li><p>Your app is built purely in Java or Kotlin with no native components. The Android Runtime (ART) handles memory for you, so these underlying page size changes are largely invisible. You'll still get the performance benefits!</p>
</li>
<li><p>You're using React Native or Flutter, unless you've added custom native modules that directly deal with memory mapping or page-size-dependent operations</p>
</li>
</ul>
<h2 id="heading-is-this-mandatory">Is this Mandatory?</h2>
<p>Yes. Google Play is making this a requirement for app updates. You would have received an email from Google Play if your app does not support 16 KB page size yet.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758343797173/da2aff04-d5ae-4964-9d72-02f21b4a0d96.png" alt="Your app is affected by Google Play's 16KB page size requirements" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>As the screenshot clearly shows, "From Nov 1, 2025, if your app updates do not support 16 KB memory page sizes, you won't be able to release these updates" for apps targeting Android 15+. This gives us a solid timeframe to get things ready.</p>
<h2 id="heading-what-if-you-dont-upgrade-your-app">What if You Don’t Upgrade Your App?</h2>
<p>You could notice some serious issues if your app has native libraries that aren't ready for the 16 KB page size by the deadline. Here are a few:</p>
<ul>
<li><p><strong>Crashes:</strong> This is the most serious. Your app might crash unexpectedly (often with a "segmentation fault") if it tries to access memory incorrectly due to old page size assumptions.</p>
</li>
<li><p><strong>Wasted Memory:</strong> If your code allocates memory in smaller chunks than 16 KB, it could end up using more memory than necessary, potentially slowing things down or hitting memory limits.</p>
</li>
<li><p><strong>Performance Hit:</strong> Instead of gaining speed, your app might actually run slower if its memory operations aren't aligned with the larger page size.</p>
</li>
</ul>
<p>Essentially, your app might work fine today, but become unstable or inefficient on newer Android devices if its native components aren't updated.</p>
<h2 id="heading-how-does-this-affect-hybrid-apps">How Does this Affect Hybrid Apps?</h2>
<p>Generally, if you're building a standard hybrid app (React Native or Flutter app) without custom native modules, you're in a pretty good spot. The frameworks themselves, and the underlying runtimes (JavaScript engine for React Native, Dart VM for Flutter), usually handle memory management, abstracting away the page size.</p>
<p>However, if you've implemented custom native modules in C++ for performance-critical tasks or specific hardware interactions, then you do need to check those modules.</p>
<p>For the vast majority of standard React Native and Flutter apps, you likely won't need direct code changes related to page size, but always ensure you're using the latest SDK versions for your framework to benefit from any underlying platform updates.</p>
<h2 id="heading-what-would-be-the-code-change-for-this">What Would be the Code Change for This?</h2>
<p>The biggest thing to avoid in your native code is making assumptions about memory page sizes. Instead of hardcoding 4096 (for 4 KB), always ask the operating system what its current page size is.</p>
<h3 id="heading-steps-to-take"><strong>Steps to Take:</strong></h3>
<ol>
<li><p><strong>Audit Your Native Code:</strong> Search your <code>.cpp</code>, <code>.c</code>, and <code>.h</code> files for any direct use of 4096 or 4 KB in memory allocation, buffer sizing, or alignment calculations</p>
</li>
<li><p><strong>Replace with</strong> <code>sysconf(_SC_PAGESIZE)</code> <strong>or</strong> <code>getpagesize()</code><strong>:</strong> Update any fixed values to dynamically retrieve the actual page size.</p>
</li>
<li><p><strong>Recompile with Latest NDK:</strong> Make sure you're building your native libraries with a recent Android NDK (r25 or newer is a good target). This ensures your toolchain is aware of the 16 KB page size and provides correct system definitions.</p>
</li>
</ol>
<h2 id="heading-how-to-verify-if-your-app-is-upgraded-to-a-16-kb-page-size">How to Verify if Your App is Upgraded to a 16 KB Page Size</h2>
<p>You can verify if your app is upgraded by running extensive testing. However, here are the few more steps.</p>
<ol>
<li><p><strong>Check Your Test Device's Page Size:</strong></p>
<ul>
<li><p>Connect your Android 13+ test device (preferably a newer one like a Pixel) via ADB</p>
</li>
<li><p>Run <code>adb shell getconf PAGE_SIZE</code></p>
</li>
<li><p>If it returns 16384, you're testing on a 16 KB page device! If it returns 4096, you'll need to find a different device to properly test for this change</p>
</li>
<li><p>Here’s an example screenshot from my device</p>
<p>  <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758368982307/f5696dab-f23a-4731-95b4-0372159d2107.png" alt="Find page size of an Android device/emulator" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
</li>
</ul>
</li>
<li><p><strong>Run Your App Extensively:</strong> Once you have a 16 KB page device, put your app through its paces. Try all features, especially those involving native code, heavy data loading, or complex operations.</p>
</li>
<li><p><strong>Monitor for Crashes:</strong> Keep a close eye on your crash reporting tools (like Crashlytics). Specifically look for native crashes (<code>SIGSEGV</code>, <code>SIGBUS</code>) coming from Android 13+ devices, as these could be related to page size issues.</p>
</li>
<li><p><strong>Memory Profiling:</strong> While less direct, if you suspect memory inefficiency in your native code, use Android Studio's Memory Profiler to see if allocations are unexpectedly large or if there's excessive memory usage.</p>
</li>
</ol>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this blog, we learnt about page size in Android, and why and how to upgrade your app to support 16 KB page size. I hope you have a clear idea about 16 KB page size in Android. By being proactive now, you can avoid last-minute scrambling and ensure your apps continue to perform beautifully on the latest Android devices, well past the November 2025 deadline!</p>
<p>You can follow my <a target="_blank" href="https://x.com/AI_Techie_Arun">Twitter/X account</a> to receive the top AI news everyday. If you wish to learn more about mobile app development, subscribe to my email newsletter (<a target="_blank" href="https://5minslearn.gogosoon.com/?ref=fcc_android_16kb_page_size">https://5minslearn.gogosoon.com/</a>) and follow me on social media.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Make Bluetooth on Android More Reliable ]]>
                </title>
                <description>
                    <![CDATA[ You may have had this happen before: your wireless earbuds connect perfectly one day, and the next they act like they’ve never met your phone. Or your smartwatch drops off in the middle of a run. Bluetooth is amazing when it works, but maddening when... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-make-bluetooth-on-android-more-reliable/</link>
                <guid isPermaLink="false">68b78f7fba46c4e7c6266797</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ bluetooth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ wireless network ]]>
                    </category>
                
                    <category>
                        <![CDATA[ iot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ debugging ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikheel Vishwas Savant ]]>
                </dc:creator>
                <pubDate>Wed, 03 Sep 2025 07:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1756860272946/83be340a-dcce-4d2f-a6eb-0d70164b11b6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You may have had this happen before: your wireless earbuds connect perfectly one day, and the next they act like they’ve never met your phone. Or your smartwatch drops off in the middle of a run. Bluetooth is amazing when it works, but maddening when it doesn’t.</p>
<p>I work as a Bluetooth software engineer on wearable devices like smart-glasses, and I’ve spent more time than I’d like to admit chasing down why these things break.</p>
<p>In this article, I’ll give you a peek behind the curtain: how Android’s Bluetooth stack actually works, why it sometimes feels unpredictable, and what you can do as a developer to make your apps or system more reliable.</p>
<h2 id="heading-bluetooth-in-plain-english">Bluetooth in Plain English</h2>
<p>At its core, Bluetooth is just a conversation between two devices. But it isn’t one simple line of communication – it’s multiple layers stacked on top of each other.</p>
<ul>
<li><p><strong>The radio (Controller):</strong> Sends and receives the actual signals over the air medium.</p>
</li>
<li><p><strong>The software brain (Host stack):</strong> Decides whom to talk to and how, as well as if it wants to.</p>
</li>
<li><p><strong>Profiles:</strong> Define the purpose of the conversation – like streaming music or syncing health data.</p>
</li>
<li><p><strong>Protocols:</strong> Define how to talk to the other device.</p>
</li>
</ul>
<p>There are two big “flavors” of bluetooth:</p>
<ul>
<li><p><strong>Classic (BR/EDR):</strong> Used for things like headphones and car kits. Can lift more weight.</p>
</li>
<li><p><strong>Low Energy (LE):</strong> Used for fitness bands, beacons, and most wearables. Can sustain longer.</p>
</li>
</ul>
<p>Most modern gadgets use both at once. That’s powerful, but it also opens the door for more things to go wrong.</p>
<h2 id="heading-why-android-adds-its-own-quirks">Why Android Adds Its Own Quirks</h2>
<p><img src="https://source.android.com/static/docs/core/connect/bluetooth/images/fluoride_architecture.png" alt="Diagram showing the layers of the Android Bluetooth stack." width="636" height="434" loading="lazy"></p>
<p>On Android, Bluetooth isn’t just one neat package. It’s a chain of moving parts:</p>
<ul>
<li><p>Your app calls <code>BluetoothAdapter</code>.</p>
</li>
<li><p>Those go into <strong>system services</strong> like <code>AdapterService</code>.</p>
</li>
<li><p>Then into native code through <strong>JNI</strong> (Java Native Interface).</p>
</li>
<li><p>Then into the <strong>chip vendor’s Bluetooth stack</strong>.</p>
</li>
<li><p>Finally, it hits the <strong>radio hardware</strong>.</p>
</li>
</ul>
<p>Every phone maker ships a slightly different Bluetooth chip and firmware. That means the exact same Bluetooth app might behave differently on a Samsung, a Pixel, or any other budget phone running Android.</p>
<h2 id="heading-the-real-problems-behind-it-just-disconnected">The Real Problems Behind “It Just Disconnected”</h2>
<p>Here are a few of the common headaches I see, explained simply:</p>
<h3 id="heading-bonding-issues-the-lost-keys-problem"><strong>Bonding issues (the “lost keys” problem)</strong></h3>
<p>When two Bluetooth devices pair, they exchange encryption keys (link keys for Classic, Long Term Keys for LE) and store them in non-volatile memory. These keys are what let the devices recognize each other later and reconnect securely without asking the user again.</p>
<p>A “mismatched memory” problem happens when one device’s stored keys don’t match the other’s anymore. This can be caused by:</p>
<ul>
<li><p>A firmware update or OS upgrade that wipes or regenerates keys.</p>
</li>
<li><p>A factory reset or “forget device” on one side but not the other.</p>
</li>
<li><p>Keys being corrupted or evicted by the system to free up storage.</p>
</li>
</ul>
<p>From the user’s perspective, the device may still <em>look</em> paired (shows up in the Bluetooth menu), but connections mysteriously fail with errors like “Authentication Failed” or “Insufficient Encryption.” The only cure is usually to delete the device on both ends and re-pair, which feels ridiculous to non-technical users.</p>
<h3 id="heading-timing-mismatches"><strong>Timing mismatches</strong></h3>
<p>Bluetooth devices don’t just chat whenever they want, they agree on a connection interval – essentially a schedule for when each side will “wake up” and exchange packets. Think of it as two people agreeing to meet every 30 minutes at a café.</p>
<p>A mismatch happens when:</p>
<ul>
<li><p>The two sides negotiate different intervals but don’t fully agree (for example, one thinks it’s 30ms, the other 50ms).</p>
</li>
<li><p>One side’s firmware update or configuration change alters its timing policy.</p>
</li>
<li><p>Radio conditions cause one side to miss multiple scheduled check-ins, drifting the clocks apart.</p>
</li>
<li><p>Power-saving logic (like a phone going into Doze mode) silently stretches out the interval.</p>
</li>
</ul>
<p>This explains why a connection might work fine at first but start failing later: the devices initially synced on an interval, but then one side’s policy or behavior shifted. From the user’s perspective, it looks like audio stuttering, laggy input (on game controllers), or random disconnects after “it was working fine before.”</p>
<h3 id="heading-unexpected-disconnections"><strong>Unexpected disconnections</strong></h3>
<p>When a Bluetooth link ends, the radio layer (the controller) and the higher-level OS stack (the host) are supposed to exchange clear signals. The controller sends an HCI Disconnection Complete event (basically: <em>“Goodbye, we’re done”</em>). And the host should then update its internal state, clean up the GATT/ACL session, and be ready for reconnection.</p>
<p>But in practice, this doesn’t always line up:</p>
<ul>
<li><p>Sometimes the controller says goodbye cleanly, but the host stack doesn’t update its state properly. The app still “thinks” the connection is active, so reconnect attempts silently fail.</p>
</li>
<li><p>Some platforms aggressively cache connection state (especially iOS). If the OS believes the connection is still valid, it won’t trigger a new connection attempt until you toggle Bluetooth or reboot.</p>
</li>
<li><p>A race condition can occur if the disconnection event happens while another operation (for example, service discovery, bonding, or encryption setup) is in flight. The OS may get confused about what state the device is <em>really</em> in.</p>
</li>
<li><p>On some devices, a fast reconnect attempt after a clean disconnection collides with internal cooldown timers. The controller ignores it, leaving the app waiting.</p>
</li>
</ul>
<p>From the user’s perspective, the device looks “stuck.” The only way to recover is to toggle Bluetooth, restart the app, or power cycle the accessory, even though technically nothing “failed.”</p>
<h2 id="heading-how-developers-can-do-better">How Developers Can Do Better</h2>
<p>If you’re building a Bluetooth app, here are a few habits that save a lot of pain:</p>
<h3 id="heading-check-for-bonded-devices-first"><strong>Check for bonded devices first</strong></h3>
<p>One of the most common causes of failed connections is mismatched bonding information: the phone and the accessory no longer share the same encryption keys. Even if the device appears in the UI, the OS may have lost its keys.</p>
<p>Before attempting a connection, always query the system’s bonded device list with <code>BluetoothAdapter.getBondedDevices()</code>. For example:</p>
<pre><code class="lang-java"><span class="hljs-keyword">if</span> (adapter.getBondedDevices().contains(targetDevice)) {
    targetDevice.connectGatt(context, <span class="hljs-keyword">false</span>, gattCallback);
} <span class="hljs-keyword">else</span> {
    showToast(<span class="hljs-string">"Please re-pair this device to restore the connection."</span>);
}
</code></pre>
<p>This ensures you only attempt secure connects to devices the OS still trusts. If the target device isn’t in the bonded list, you can give the user a clear instruction (“Please re-pair this device”) instead of leaving them with confusing connection errors.</p>
<h3 id="heading-handle-callbacks-carefully"><strong>Handle callbacks carefully</strong></h3>
<p>Another subtle pitfall is assuming that a <code>STATE_CONNECTED</code> event means a connection was successful. In reality, <code>onConnectionStateChange()</code> can report a connected state even when the underlying operation failed, the real result is in the <code>status</code> argument. To avoid chasing phantom connections, always check both <code>status</code> and <code>newState</code>:</p>
<pre><code class="lang-java"><span class="hljs-keyword">if</span> (status == BluetoothGatt.GATT_SUCCESS &amp;&amp;
    newState == BluetoothProfile.STATE_CONNECTED) {
    gatt.discoverServices();
} <span class="hljs-keyword">else</span> {
    gatt.close();
}
</code></pre>
<p>This pattern prevents you from attempting service discovery on a dead connection and ensures stale sessions are closed promptly, leaving the stack ready for a clean retry.</p>
<h3 id="heading-expect-failures"><strong>Expect failures</strong></h3>
<p>Bluetooth connections fail all the time in the real world – devices drift out of range, interference spikes in the 2.4 GHz band, or the radio is simply busy. The worst thing an app can do is retry instantly in a tight loop, which drains the battery and makes the stack unstable.</p>
<p>A better approach is to implement exponential backoff like this:</p>
<pre><code class="lang-java"><span class="hljs-keyword">long</span> delay = (<span class="hljs-keyword">long</span>) Math.min(<span class="hljs-number">250</span> * Math.pow(<span class="hljs-number">2</span>, attempt), <span class="hljs-number">30000</span>);
<span class="hljs-keyword">new</span> Handler(Looper.getMainLooper()).postDelayed(connectAction, delay);
</code></pre>
<p>This means your first retry happens quickly (~250 ms), but subsequent retries slow down (500 ms, 1 s, 2 s…), capped at a reasonable maximum. Backoff makes your app resilient without overwhelming the radio or the OS.</p>
<h3 id="heading-use-the-right-tools"><strong>Use the right tools</strong></h3>
<p>Without visibility into what’s happening under the hood, connection problems look random. Tools like <em>nRF Connect</em> let you interactively scan, connect, and run GATT operations against your device, while Android’s Bluetooth HCI snoop log reveals the actual packets being exchanged. For example:</p>
<pre><code class="lang-bash">Settings.Secure.putInt(context.getContentResolver(), <span class="hljs-string">"bluetooth_hci_log"</span>, 1);
</code></pre>
<p>Once enabled, you can capture a logcat trace and confirm whether a failure is due to missing keys (<code>Insufficient Authentication</code>), a timing mismatch, or interference. Using these tools not only helps you debug your app, it also proves whether the issue lies in your code, the OS, or the accessory firmware.</p>
<p><img src="https://www.beaconzone.co.uk/blog/wp-content/uploads/2019/08/nrfconnectios.png" alt="Completely New nRF Connect for iOS – BeaconZone Blog" width="732" height="500" loading="lazy"></p>
<h2 id="heading-bigger-lessons">Bigger Lessons</h2>
<p>Working with Bluetooth taught me lessons that apply to engineering in general:</p>
<ul>
<li><p>Wireless is never perfect, so always build with recovery in mind.</p>
</li>
<li><p>Logs and metrics aren’t optional. They’re your map through the chaos.</p>
</li>
<li><p>The simplest solution usually survives best in the messy real world.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Bluetooth is messy because it’s a chain of hardware, firmware, and software all trying to cooperate. On Android, the variety of chips and vendors makes it even trickier.</p>
<p>But that doesn’t mean you’re helpless. By understanding how the layers work and designing your apps with retries, checks, and proper logging, you can make Bluetooth feel a lot less “weird” for your users.</p>
<p>The next time your earbuds misbehave, you’ll know – it’s not you. It’s just Bluetooth being Bluetooth.</p>
<p>⚡ <em>This is the first of a number of articles I’m going to write on Bluetooth development. In the next one, we’ll dive deeper into how to build a secure Bluetooth Low Energy (BLE) GATT client and server on Android. Stay tuned!</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Convert Your Website into an Android App Using Bubblewrap ]]>
                </title>
                <description>
                    <![CDATA[ If you are a web developer who doesn’t know about App Development (like me!), then this article is for you. I’ll teach you how to turn your website into a native app, without new frameworks or languages. You’ll learn how to convert a website to a PWA... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-convert-your-website-into-an-android-app-using-bubblewrap/</link>
                <guid isPermaLink="false">68a4b9d4f2bced8c3a658f5a</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PWA ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sanjay ]]>
                </dc:creator>
                <pubDate>Tue, 19 Aug 2025 17:52:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755625913612/bfffd5f9-f4d6-4f8d-aae8-72f5730bd7e9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you are a web developer who doesn’t know about App Development (like me!), then this article is for you. I’ll teach you how to turn your website into a native app, without new frameworks or languages. You’ll learn how to convert a website to a PWA (Progressive Web App) that you can publish on the Play Store.</p>
<p>First, we’ll turn your website into a Progressive Web App (PWA). Then we'll use a free command-line tool from Google called <strong>Bubblewrap</strong> to package that PWA into an Android app. Let’s get started.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>If you follow along with this tutorial, there are some prerequisites:</p>
<ul>
<li><p>Basic knowledge of web development</p>
</li>
<li><p>Your site should be live to the public, and you’ll need to have access to its source code.</p>
</li>
<li><p>We'll use npm to install the necessary tools, so make sure you have Node.js installed.</p>
</li>
</ul>
<p><strong>Note:</strong> This tutorial is based on a <strong>Vite</strong> project, but the final steps with Bubblewrap are the same for any web framework.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-what-is-a-pwa">What is a PWA?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-bubblewrap">What is Bubblewrap?</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-a-twa-trusted-web-activity">What is a TWA (Trusted Web Activity)?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-twa-verifies-trust">How TWA Verifies Trust</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-configure-your-pwa-in-vite">Step 1 – Configure Your PWA in Vite</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-your-app-icons">Create Your App Icons</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-install-the-vite-pwa-plugin">Install the Vite PWA plugin.</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-the-plugin">Configure the Plugin</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-create-the-android-app">Step 2 – Create the Android App</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-a-build-folder">Create a Build Folder</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-install-the-bubblewrap-cli">Install the Bubblewrap CLI</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-initialize-the-project">Initialize the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-lets-troubleshoot-the-init-command">Let’s troubleshoot the init command</a>.</p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-answer-bubblewrap-questions">Step 3 – Answer Bubblewrap Questions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-build-the-app">Step 4 – Build the App</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-setting-up-twa-validation">Step 5 – Setting Up TWA Validation</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-the-well-known-folder">What is the .well-known folder?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-delegatepermissioncommonhandleallurls">What is delegate_permission/common.handle_all_urls?</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-optional-customize-the-in-app-experience">Step 6 (Optional) – Customize the In-App Experience</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-what-is-a-pwa">What is a PWA?</h2>
<p>PWA stands for <strong>Progressive Web Application</strong>, and its goal is to make your website look and feel just like a native app. If you’ve visited a website in your browser and seen an install icon that lets you download it to your phone or laptop, you've used a PWA.</p>
<p>But it’s not just about the look and feel. A PWA also has app-like features, such as working offline, sending push notifications, and more.</p>
<p>There are two main components of a PWA.</p>
<ul>
<li><p>The manifest file describes your app, such as its name, icons, start URL, and so on.</p>
</li>
<li><p>A service worker is a background JavaScript file that acts as a proxy. The caching and push notifications are handled by a service file, which runs as a different thread apart from the main thread.</p>
</li>
</ul>
<p>Without these two components, browsers won’t let users download the app locally.</p>
<p>The manifest file and the service worker are like a checklist for the browser. When you visit a website, the browser looks for both of these components. If they are present and correctly configured, the browser knows it's a true PWA and will show the "install" icon, allowing users to download the app locally. Without them, the browser just sees a regular website, and the option to install won't be available.</p>
<h2 id="heading-what-is-bubblewrap">What is Bubblewrap?</h2>
<p>Bubblewrap is a command-line tool made by Google that takes your PWA and turns it into an Android App using a Trusted Web Activity (TWA).</p>
<p>Bubblewrap simplifies the process of creating a TWA, turning a PWA's manifest file into an Android app package (APK or AAB).</p>
<h3 id="heading-what-is-a-twa-trusted-web-activity">What is a TWA (Trusted Web Activity)?</h3>
<p>A Trusted Web Activity (TWA) is a modern Android feature that lets you display your live website full-screen inside an Android app. Basically, it runs the website on the browser, but it doesn’t show the browser address bar on the App. This helps it feel like a native app.</p>
<p>To unlock this full-screen feature, your app needs to be “Trusted“.</p>
<p>This is where the "secret handshake" comes in. Android needs to be sure that the person who built the app and the person who owns the website are the same. Without this proof of ownership, the TWA will run in a fallback mode and show the browser address bar at the top, ruining the native app feel.</p>
<h3 id="heading-how-twa-verifies-trust">How TWA Verifies Trust</h3>
<p>This trust is verified using a system called <strong>Digital Asset Links</strong>. You place a special file on your website (we'll do this in the implementation part) that contains your app's unique digital fingerprint. When a user opens your app, the Android OS checks this file. If the fingerprints match, it grants your app "trusted" status, removes the address bar, and enables other features like deep linking.</p>
<p>You can check this relationship yourself using Google's official testing tool: <a target="_blank" href="https://developers.google.com/digital-asset-links/tools/generator">Digital Asset Links Verifier.</a></p>
<p>Now that you understand the project and tools, let’s start building.</p>
<h2 id="heading-step-1-configure-your-pwa-in-vite">Step 1 – Configure Your PWA in Vite</h2>
<p>The first step is to add the two main components for a PWA: the manifest file and service worker. This is what will allow the browser to recognize it as "installable."</p>
<p>This guide is based on a project built with Vite, which makes this process easy with a special plugin. If you're using a different tool, the concepts are the same, but you'll need to look up different resources about the specific steps for your environment.</p>
<h3 id="heading-create-your-app-icons">Create Your App Icons</h3>
<p>Before we touch any code, we need the icons for our app. Android requires specific sizes for the app's launcher icon (what you see on your home screen) and the splash screen (what you see when the app starts).</p>
<p>You'll need two main sizes: <code>192x192</code> pixels and <code>512x512</code> pixels. You can use this <a target="_blank" href="https://realfavicongenerator.net/">Favicon Generator</a> to generate your logo in the respective sizes. You can upload your main logo, and it will generate all the necessary sizes for you.</p>
<p>Then just download the generated files and place the <code>192x192</code> and <code>512x512</code> files into the <code>public</code> folder of your project.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1755067586673/f7e06fc2-4b55-4ec3-af05-b2e78bf19273.png" alt="f7e06fc2-4b55-4ec3-af05-b2e78bf19273" class="image--center mx-auto" width="289" height="340" loading="lazy"></p>
<h3 id="heading-install-the-vite-pwa-plugin">Install the Vite PWA plugin.</h3>
<p>A PWA requires a manifest file and a service worker. We can create these manually, but this plugin automates that entire process. It will automatically generate a <code>manifest.json</code> and <code>service-worker.js</code> for you every time you build your project.</p>
<pre><code class="lang-bash">npm install vite-plugin-pwa -D
</code></pre>
<h3 id="heading-configure-the-plugin">Configure the Plugin</h3>
<p>In this step, we’ll use this plugin and configure our app's manifest. Edit the <code>vite.config.ts</code> file. This configuration will tell the plugin what to name your app, which icons to use, and so on.</p>
<p>In <code>vite.config.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
  plugins: [
    VitePWA({
      registerType: <span class="hljs-string">"autoUpdate"</span>,   
      manifest: {
        name: <span class="hljs-string">"your app name"</span>,
        short_name: <span class="hljs-string">"your app short name"</span>,
        description: <span class="hljs-string">"write any description"</span>,
        theme_color: <span class="hljs-string">"#0d1117"</span>,
        background_color: <span class="hljs-string">"#ffffff"</span>,
        display: <span class="hljs-string">"standalone"</span>,
        start_url: <span class="hljs-string">"/"</span>,
        icons: [
          {
            src: <span class="hljs-string">"/web-app-manifest-192x192.png"</span>,
            sizes: <span class="hljs-string">"192x192"</span>,
            <span class="hljs-keyword">type</span>: <span class="hljs-string">"image/png"</span>,
          },
          {
            src: <span class="hljs-string">"/web-app-manifest-512x512.png"</span>,
            sizes: <span class="hljs-string">"512x512"</span>,
            <span class="hljs-keyword">type</span>: <span class="hljs-string">"image/png"</span>,
          },
        ],
      },
    }),
  ]
</code></pre>
<p>Now, when you run <code>npm run build</code>, the plugin will automatically generate the manifest and service worker files for you. With that done, deploy the changes. Now your website is a PWA.</p>
<h2 id="heading-step-2-create-the-android-app">Step 2 – Create the Android App</h2>
<p>Now that your website is a PWA, let’s use Bubblewrap to package it into an Android app.</p>
<h3 id="heading-create-a-build-folder">Create a Build Folder</h3>
<p>Create a dedicated folder for your Android project files. In your project's root, create a new folder. I'll call mine <code>android</code>.</p>
<pre><code class="lang-plaintext">project/
├── client/
├── server/
└── android/
</code></pre>
<p>Now navigate to the new folder that you created.</p>
<h3 id="heading-install-the-bubblewrap-cli">Install the Bubblewrap CLI</h3>
<pre><code class="lang-bash">npm install -g @bubblewrap/cli
</code></pre>
<h3 id="heading-initialize-the-project">Initialize the Project</h3>
<p>Next, run the <code>init</code> command. Bubblewrap will connect to your live website, read the <code>manifest.webmanifest</code> file that Vite created, and use that information to generate a basic Android project.</p>
<pre><code class="lang-bash">bubblewrap init --manifest=https://your-website-domain/manifest.webmanifest
</code></pre>
<p>Run the command, replacing <code>your-website-domain</code> with your actual URL:</p>
<h3 id="heading-lets-troubleshoot-the-init-command">Let’s troubleshoot the <code>init</code> command</h3>
<p>As you run the <code>init</code> command, Bubblewrap will need two key software packages: the <strong>Java Development Kit (JDK)</strong> and the <strong>Android SDK</strong>. It will offer to install them for you.</p>
<h4 id="heading-jdk-setup">JDK setup:</h4>
<pre><code class="lang-bash">? Do you want Bubblewrap to install the JDK (recommended)?
  (Enter <span class="hljs-string">"No"</span> to use your own JDK 17 installation) (Y/n)
</code></pre>
<p>In my case, when I let Bubblewrap install the JDK, the process downloaded the files but then failed at the "decompressing" step. If you face this same problem, don't worry! The fix is to install it manually.</p>
<ul>
<li><p>Say <strong>No</strong> to the prompt.</p>
</li>
<li><p>Download the recommended version (usually JDK 17) from a source like <a target="_blank" href="https://adoptium.net/temurin/releases/?version=17">Adoptium</a>.</p>
</li>
<li><p>Install it and set up your system's environment variables to include the JDK's <code>bin</code> path. If you’re not sure how to set environment variables, you can check out this site: <a target="_blank" href="https://www.c-sharpcorner.com/article/how-to-addedit-path-environment-variable-in-windows-11/">Set Environment Variables</a>.</p>
</li>
<li><p>When Bubblewrap asks for the path, provide it directly, such as <code>C:\java\jdk-17.0.16.8-hotspot</code>.</p>
</li>
</ul>
<h4 id="heading-android-sdk-setup">Android SDK setup:</h4>
<p>Once the JDK is set up successfully, the next step is to configure the Android SDK.</p>
<pre><code class="lang-bash">? Do you want Bubblewrap to install the Android SDK (recommended)?
  (Enter <span class="hljs-string">"No"</span> to use your own Android SDK installation) (Y/n)
</code></pre>
<p>Since I didn't have the Android SDK, I let Bubblewrap handle this by selecting <strong>Yes</strong>. I didn't face any problems here.</p>
<p>If you face any problem in setting up on Android SDK, just set it up manually and give the path, just like the JDK setup.</p>
<h2 id="heading-step-3-answer-bubblewrap-questions">Step 3 – Answer Bubblewrap Questions</h2>
<p>After the SDK is set up, Bubblewrap will ask a bunch of questions to configure your app. This information is used to create the <code>twa-manifest.json</code> file, which is the blueprint for your App.</p>
<pre><code class="lang-plaintext">Domain: Press Enter (auto-filled from your manifest)

Application name: Your full app name

Application ID: (e.g, chat.yourapp.twa)

Display mode: standalone

Orientation: portrait

Status bar color: Press Enter (accepts default)

Splash screen color: Press Enter (accepts default)

Icon URL: Press Enter (accepts default)

Include support for Play Billing?: Type Y if your app uses Google Play in-app purchases. Otherwise, N

Request geolocation permission?: Type Y if your app needs location access. Otherwise, N
</code></pre>
<p>In these questions, the important part is the key store and the key.</p>
<pre><code class="lang-plaintext">First and Last names: Your full name

Organizational Unit: Developer or anything

Organization: Your organization name

Country (2-letter code): Your country code

Password for key store: Enter a new password

Password for key: Re-enter the same password
</code></pre>
<p><strong>Note:</strong> These passwords for both the key store and key should be the same, or else it will throw an error. <strong>Refer to this issue:</strong> <a target="_blank" href="https://github.com/GoogleChromeLabs/bubblewrap/issues/713">Bubblewrap Issue</a>.</p>
<h2 id="heading-step-4-build-the-app">Step 4 – Build the App</h2>
<pre><code class="lang-bash">bubblewrap build --universalApk
</code></pre>
<p>This command starts building your application. Here, the flag <code>universalApk</code> will produce the <code>.apk</code> and <code>.abb</code>. If you’re going to publish your application in the Play Store, upload the <code>.abb</code> file to the Play Store. For our testing, we need an APK file, so this flag <code>universalApk</code> will produce both files. If we didn't give this flag, it would only give us <code>.abb</code>.</p>
<h2 id="heading-step-5-setting-up-twa-validation">Step 5 – Setting Up TWA Validation</h2>
<p>Once the build is done, you’ll get the APK. Transfer it to your phone and test it. When you open the app, you’ll see the browser address bar. This is because we haven't set up the "trust" between your app and your website yet. Let's fix that now.</p>
<p>In your frontend project, go to the <code>public</code> folder, create a new folder called <code>.well-known</code>, and inside that, create a file called <code>assetlinks.json</code>.</p>
<pre><code class="lang-bash">frontend/
├── public/
    ├── .well-known/
        └── assetlinks.json
</code></pre>
<h3 id="heading-what-is-the-well-known-folder">What is the <code>.well-known</code> folder?</h3>
<p>A well-known folder is used to store files that define configurations for protocols, as it’s used for external sources to find the validation for your website. In our case, our app checks the well-known folder from our website and verifies the validation.</p>
<p>Paste the following into <code>assetlinks.json</code>:</p>
<pre><code class="lang-json">[
  {
    <span class="hljs-attr">"relation"</span>: [<span class="hljs-string">"delegate_permission/common.handle_all_urls"</span>],
    <span class="hljs-attr">"target"</span>: {
      <span class="hljs-attr">"namespace"</span>: <span class="hljs-string">"android_app"</span>,
      <span class="hljs-attr">"package_name"</span>: <span class="hljs-string">"chat.yourapp.twa"</span>,
      <span class="hljs-attr">"sha256_cert_fingerprints"</span>: [
       <span class="hljs-string">"your_sha256_fingerprint"</span>
      ]
    }
  }
]
</code></pre>
<h3 id="heading-what-is-delegatepermissioncommonhandleallurls">What is <code>delegate_permission/common.handle_all_urls</code>?</h3>
<p>This is a special flag that opens all the links from the app instead of the domain. Simply put, it acts as a deeplink. After you install the app, if you click your website link from WhatsApp or from somewhere, it will open your app instead of opening in a browser, acting as a deeplink.</p>
<p>The <code>package_name</code> field should be the <code>packageId</code>, which you can get from your Android build folder in <code>twa-manifest.json</code>.</p>
<p>Now, get your fingerprints. Run the following command to do so:</p>
<pre><code class="lang-bash">keytool -list -v -keystore android.keystore -<span class="hljs-built_in">alias</span> android
</code></pre>
<p>The alias name should be the value that you created. Once you enter this command, it’ll ask for the key store password. Enter that, and you’ll get your <code>SHA256</code> fingerprint. Copy that and paste it into the <code>assetslinks.json</code> file in the <code>sha256_cert_fingerprints</code> array. Now push these changes to production. You can verify the validation in <a target="_blank" href="https://developers.google.com/digital-asset-links/tools/generator">Digital Asset Links</a></p>
<p>That’s it! Now you can install the app and test it.</p>
<h2 id="heading-step-6-optional-customize-the-in-app-experience"><strong>Step 6 (Optional) – Customize the In-App Experience</strong></h2>
<p>Now, additionally, there will be some cases where we want to show different content to users on the website vs the mobile app. Can we do that? Yes!</p>
<p>In your Android build folder, in <code>twa-manifest.json</code>, there will be a field called <code>startUrl</code>. If not, add it and add the value  <code>"startUrl": "/?twa=true"</code>. The <code>startUrl</code> is the entry point. I have a query parameter of value <code>twa=true</code>.</p>
<p>Run the build again with <code>bubblewrap build --universalApk</code>.</p>
<p>Now, if you open your app, it will open the app with the entry URL as <code>yourwebsitedomain.com/?twa=true</code>.</p>
<p>In your frontend:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> twaParam = queryParams.get(<span class="hljs-string">"twa"</span>);

<span class="hljs-keyword">const</span> [isTwa, setIsTwa] = useState&lt;<span class="hljs-built_in">boolean</span>&gt;(<span class="hljs-function">() =&gt;</span> {
   <span class="hljs-keyword">return</span> <span class="hljs-built_in">localStorage</span>.getItem(<span class="hljs-string">"isTwa"</span>) === <span class="hljs-string">"true"</span>;
});

useEffect(<span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">if</span> (twaParam === <span class="hljs-string">"true"</span>) {
    <span class="hljs-built_in">localStorage</span>.setItem(<span class="hljs-string">"isTwa"</span>, <span class="hljs-string">"true"</span>); <span class="hljs-comment">// set the value to local storage</span>
    setIsTwa(<span class="hljs-literal">true</span>);
  }
}, [twaParam]);
</code></pre>
<pre><code class="lang-typescript"> {isTwa? (
    &lt;Link to=<span class="hljs-string">"/contact"</span> className=<span class="hljs-string">"underline hover:text-primary"</span>&gt;
       Contact
    &lt;/Link&gt; 
  ) : (
     &lt;Link to=<span class="hljs-string">"/download"</span> className=<span class="hljs-string">"underline hover:text-primary"</span>&gt;
       Download App
      &lt;/Link&gt;
  )}
</code></pre>
<p>In the code above, we check for the <code>twa=true</code> query parameter in the URL. If it's present, we save that information to local storage, and then we conditionally render the content for the user.</p>
<p>That's it. We have created an App.</p>
<p>If you want to change any name, colour, or splash screen, you can change it in <code>twa-manifest.json</code> and run the build again.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Bubblewrap is only for Android. If you want the app to support cross-platform, there are some other platforms, like Capacitor, which I’ll write about in another article.</p>
<p>By the way, you can check out the App that I made using Bubblewrap here: <a target="_blank" href="https://strangertalk.chat/download">Stranger Talk</a>.</p>
<p>If there are any mistakes or you have any questions, contact me on <a target="_blank" href="https://www.linkedin.com/in/sanjay-r-ab6064294/">LinkedIn</a> or <a target="_blank" href="https://www.instagram.com/heheheh_pet/profilecard/?igsh=eXh3MWw4ZzZ3NTRq">Instagram</a>.</p>
<p>Thank you for reading!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Audit Android Accessibility with the Accessibility Scanner App ]]>
                </title>
                <description>
                    <![CDATA[ The Web Content Accessibility Guidelines (WCAG 2.1 Level AA) is an internationally recognized standard for digital accessibility. Meeting these guidelines helps you make sure that your website is usable by people with visual, motor, hearing, and cogn... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-audit-android-accessibility-with-the-accessibility-scanner-app/</link>
                <guid isPermaLink="false">6862d146cc277a35bb68ec20</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Accessibility ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile app accessibility testing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ilknur Eren ]]>
                </dc:creator>
                <pubDate>Mon, 30 Jun 2025 18:02:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1751301060182/df4d483a-8dd6-45ce-a665-76cbf45ef945.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The Web Content Accessibility Guidelines (WCAG 2.1 Level AA) is an internationally recognized standard for digital accessibility. Meeting these guidelines helps you make sure that your website is usable by people with visual, motor, hearing, and cognitive impairments.</p>
<p>Google’s <a target="_blank" href="https://play.google.com/store/apps/details?id=com.google.android.apps.accessibility.auditor&amp;hl=en_US">Accessibility Scanner</a> on Google Play is a free app that offers developers, designers, and product leaders the ability to audit their app to find accessibility issues. The app is designed to highlight accessibility issues that might not meet the WCAG 2.1 Level AA standards. </p>
<p>Once installed, the Accessibility Scanner allows you to take screenshots or video recordings of your app, then highlights areas that may not meet accessibility requirements, like small touch targets, low color contrast, or missing content labels.</p>
<h3 id="heading-heres-what-well-cover">Here’s what we’ll cover:</h3>
<ol>
<li><p><a class="post-section-overview" href="#heading-how-to-download-and-enable-the-accessibility-scanner">How to Download and Enable the Accessibility Scanner</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-the-accessibility-scanner">How to Use the Accessibility Scanner</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-use-the-snapshot-feature">How to Use the Snapshot Feature</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-the-record-feature">How to Use the Record Feature</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-why-use-the-accessibility-scanner">Why Use the Accessibility Scanner?</a></p>
</li>
</ol>
<h2 id="heading-how-to-download-and-enable-the-accessibility-scanner"><strong>How to Download and Enable the Accessibility Scanner</strong></h2>
<p>In five quick steps, you can download the Accessibility App and enable it on your Android device.</p>
<ol>
<li><p>Search “Accessibility Scanner” on Google Play Store and download it.</p>
</li>
<li><p>Find the downloaded app on your device and open it.</p>
</li>
<li><p>Turn on the Accessibility scanner by clicking on the “Turn on” button on the bottom right side of the page. This will take you to your Accessibility Settings.</p>
</li>
<li><p>In the Accessibility Setting page, click on the Accessibility Scanner button. This will take you to the Accessibility Scanner Settings.</p>
</li>
<li><p>Find Accessibility Scanner toggle and turn it on. (This will open a modal that asks if you allow “Accessibility Scanner” to have full control of your device, click Allow.</p>
</li>
</ol>
<p>After step five, you will have a blue checkmark icon will appear on the right side of your screen (see image below). This floating icon gives you quick access to start scanning any screen for accessibility issues.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750821547116/75f49863-7f19-4db5-ada1-45483c0df70b.png" alt="Facebook Log in Page with Accessibility Scanner toggle on the right with arrow pointing to it" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-how-to-use-the-accessibility-scanner"><strong>How to Use the Accessibility Scanner</strong></h2>
<p>To scan or record your app to find accessibility issues, tap the blue checkmark icon. You’ll see a few options after clicking on the blue checkmark:</p>
<ul>
<li><p><strong>Record</strong>: Captures a short video of user interaction and generates a report of potential accessibility issues.</p>
</li>
<li><p><strong>Snapshot</strong>: Takes a static screenshot and flags issues found on that screen.</p>
</li>
<li><p><strong>Turn off:</strong> Turns the Accessibility Scanner off.</p>
</li>
<li><p><strong>Collapse:</strong> Collapses the options to show the initial blue checkmark.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750895121001/9673c7d5-5182-4c99-b36a-1b2a2e27986b.png" alt="Facebook Log in Page with Accessibility Scanner toggle opened on the right with arrow pointing to it" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>You can choose between taking a single <strong>Snapshot</strong> or recording user flow using <strong>Record</strong> to evaluate multiple screens.</p>
<h3 id="heading-how-to-use-the-snapshot-feature">How to Use the Snapshot Feature</h3>
<p>The snapshot button will take a snapshot of the page you are currently in and give you a result of accessibility issues that may be on the page. The accessibility issues will be highlighted in red boxes.</p>
<p>The image below is the result of taking a snapshot of the Facebook log in page. The accessibility scanner states that there are 10 accessibility suggestions on this page alone.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750898582440/76cc763c-e6db-46a9-b062-2e29a57e7022.jpeg" alt="Facebook log in page with red boxes around several elements, highlighting accessibility issues." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>You can click on the highlighted area in order to get more details of the potential accessibility issue. For example, you can click on the red box that is highlighting the “Mobile number or email” form that’s in the image above. Once you click on the highlighted area, you will get additional information.</p>
<p>The image below is the result of clicking on the “Mobile number or email” form element. Accessibility Scanner is highlighting errors it found on this email form.</p>
<p>The first suggestion it gives is to fix the item label, because the item may not have a label readable by screen readers. The second issue it highlights is the Touch Target and suggests that the target should be larger. The final suggestion is the Unexposed Text, possible text detected: Mobile number or email.</p>
<p>Snapshots allow us to take screenshots of our pages and highlight accessibility issues.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750898563142/ce93909e-b351-405c-8367-dd47d7d19c9f.jpeg" alt="Email form field is selected from Accessibility Scanner. Scanner shows three areas to fix." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-how-to-use-the-record-feature">How to Use the Record Feature</h3>
<p>If you select to record, the Accessibility Scanner will take snapshots at intervals as you go through your app’s pages. To end the recording, tap the blue pause button (which replaces the original checkmark during recording).</p>
<p>Once you stop recording, Accessibility Scanner will give you the several snapshots and highlighted errors. The image below is the result of recording the Facebook log in page in less than a minute.</p>
<p>While recording, I navigated to other pages within the app. The recording gave 5 snapshots of the pages I was going through. You can see the snapshots on top of the page. In the image below, I am on screen one of five,. I can click to the other snapshots underneath the words, “Screen 1 of 5” and see issues for different snapshots taken during my recording. Similar to the snapshot accessibility audit, you can click on the red boxes and get more information on the errors.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750898542344/a390f512-262d-40c1-87ad-35e36c31def4.jpeg" alt="Facebook Log in Page with Accessibility Scanner highlighting elements with accessibility issues." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-why-use-the-accessibility-scanner"><strong>Why Use the Accessibility Scanner?</strong></h2>
<p>The Accessibility Scanner is a valuable tool for teams throughout the app development lifecycle. Engineers can use it early in the process to scan the app locally, identify accessibility issues, and resolve them before release. During the QA phase, designers and product managers can use the scanner to audit user interfaces and flag potential accessibility concerns. Even after an app is in production, all teams can continue to use the scanner to monitor and improve accessibility.</p>
<p>But it’s important to note that the Accessibility Scanner is just one part of an accessibility strategy – it’s not a complete replacement for manual testing or audits. And it won’t catch all types of accessibility barriers – especially those that require keyboard navigation, screen reader testing, or cognitive usability reviews. But it is a simple and effective starting point for improving accessibility in Android apps.</p>
<p>You should use it alongside other tools, such as Android’s TalkBack for screen reader testing. Most importantly, real-world feedback from people who use assistive technologies is essential to identifying usability barriers that automated tools may miss.</p>
<p>With just a few taps, Accessibility Scanner helps surface issues that might otherwise be missed. It’s a free, lightweight, and essential tool for anyone building inclusive mobile experiences.</p>
<h2 id="heading-thanks-for-reading">Thanks for Reading!</h2>
<p>You should now know how to get started using the Accessibility Scanner to check your apps’ accessibility and make sure they’re usable by everyone.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Master Kotlin & Android 60-Hour Course ]]>
                </title>
                <description>
                    <![CDATA[ Do you want to create the next groundbreaking mobile app? Kotlin, a modern and powerful language officially backed by Google, not only makes Android development more efficient and enjoyable but also opens doors to diverse programming opportunities be... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/master-kotlin-and-android-60-hour-course/</link>
                <guid isPermaLink="false">6824aba7be2c002301d59188</guid>
                
                    <category>
                        <![CDATA[ Kotlin ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Wed, 14 May 2025 14:41:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1747233684976/019ffd11-b74c-437d-815f-857ab3465317.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Do you want to create the next groundbreaking mobile app? Kotlin, a modern and powerful language officially backed by Google, not only makes Android development more efficient and enjoyable but also opens doors to diverse programming opportunities beyond mobile. Whether you're looking to build innovative applications, solve real-world problems, or join a thriving global community of developers, learning Kotlin and Android is a great investment in your future.</p>
<p>We just posted a massive 60-hour Kotlin and Android Development course on the freeCodeCamp.org YouTube channel. This course will help you master modern Android practices. Alexandru Cristian developed this course. It’s packed with hands-on practice, ensuring you not only learn the theory but also apply it by building multiple real-world applications.</p>
<p>This is a thorough exploration of the Kotlin programming language and the Android development ecosystem. Here’s a glimpse of what you’ll learn:</p>
<h3 id="heading-kotlin-from-the-ground-up"><strong>Kotlin from the ground up</strong></h3>
<ul>
<li><p>Solidify your understanding of Kotlin syntax, variables, operators, control flow (loops, conditionals), and null safety.</p>
<ul>
<li><p>Dive deep into Object-Oriented Programming (OOP) with Kotlin, covering classes, inheritance, interfaces, abstract classes, and data classes.</p>
</li>
<li><p>Master Kotlin Collections (lists, sets, maps) and powerful functions to manipulate them.</p>
</li>
<li><p>Explore advanced concepts like Generics, Lambda functions, and Kotlin Coroutines for efficient asynchronous programming.</p>
</li>
<li><p>Even touch upon SQL basics to understand data persistence.</p>
</li>
</ul>
</li>
</ul>
<h3 id="heading-comprehensive-android-development"><strong>Comprehensive android development</strong></h3>
<ul>
<li><p>Get started with Android Studio and understand the Android project structure.</p>
<ul>
<li><p>Learn traditional UI development with XML, including various layouts (LinearLayout, RelativeLayout, ConstraintLayout) and UI widgets.</p>
</li>
<li><p>Master Android Activities and Fragments, their lifecycles, and how to navigate between screens using Intents and the modern Navigation Component.</p>
</li>
<li><p>Build dynamic lists with RecyclerView.</p>
</li>
<li><p>Understand and implement Material Design principles.</p>
</li>
<li><p>Work with data persistence using Room Database and connect to the cloud with Firebase Firestore.</p>
</li>
<li><p>Make network requests and handle APIs using Retrofit and parse JSON data.</p>
</li>
<li><p>Implement robust app architecture using MVVM (Model-View-ViewModel), LiveData, and potentially Dependency Injection.</p>
</li>
<li><p>Get an introduction to the future of Android UI with Jetpack Compose.</p>
</li>
</ul>
</li>
</ul>
<p>Theory is important, but practice is what makes a developer. Throughout this 60-hour course, you'll be building a portfolio of applications. The final project is an Uber clone that will have you implementing features like maps integration, user authentication, real-time location tracking, and more, demonstrating truly professional-grade development techniques.</p>
<h3 id="heading-start-learning-today">Start learning today</h3>
<p>Learning Kotlin and Android development opens doors to an exciting career in mobile technology. Android powers billions of devices worldwide, and skilled developers are in constant demand. Kotlin is a modern, concise, and powerful language officially supported by Google for Android development, making it an essential skill for today's app creators.</p>
<p>Watch the full course on <a target="_blank" href="https://youtu.be/blKkRoZPxLc">the freeCodeCamp.org YouTube channel</a> (60-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/blKkRoZPxLc" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Tooltips in Jetpack Compose ]]>
                </title>
                <description>
                    <![CDATA[ When I wrote my last article about Jetpack Compose, I stated there that Jetpack Compose is missing some (in my opinion) basic components, and one of them is the tooltip. At the time, there was no built-in composable to display tooltips and there were... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-tooltips-in-jetpack-compose/</link>
                <guid isPermaLink="false">66fd516514798d90f2228542</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Jetpack Compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tooltip ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tomer ]]>
                </dc:creator>
                <pubDate>Wed, 02 Oct 2024 13:57:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1727813989960/b0a7ab29-d87c-4d87-9847-70b7e1c341b1.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When I wrote my <a target="_blank" href="https://medium.com/better-programming/is-jetpack-compose-ready-for-you-eae6c93ad3f8">last article about Jetpack Compose</a>, I stated there that Jetpack Compose is missing some (in my opinion) basic components, and one of them is the tooltip.</p>
<p>At the time, there was no built-in composable to display tooltips and there were several alternative solutions circling online. The problem with those solutions was that once Jetpack Compose released newer versions, those solutions might break. So it wasn’t ideal and the community was left hoping that sometime in the future, support would be added for tooltips.</p>
<p>I’m glad to say that since <a target="_blank" href="https://developer.android.com/jetpack/androidx/releases/compose-material3#1.1.0">version 1.1.0 of Compose Material 3</a>, we now have built in tooltip support. 👏</p>
<p>While this in itself is great, more than a year has passed since that version was released. And with subsequent versions, the API related to tooltips changed drastically as well.</p>
<p>If you go over the changelog, you will see how the public and internal APIs have changed. So bear in mind, that when you read this article, things may have continued to change as everything related to Tooltips is still marked by the annotation <strong>ExperimentalMaterial3Api::class</strong>.</p>
<p>❗️ The version of material 3 used for this article is 1.2.1, which was released on March 6th, 2024</p>
<h2 id="heading-tooltip-types">Tooltip Types</h2>
<p>We now have support for two different types of tooltips:</p>
<ol>
<li><p>Plain tooltip</p>
</li>
<li><p>Rich media tooltip</p>
</li>
</ol>
<h3 id="heading-plain-tooltip">Plain Tooltip</h3>
<p>You can use the first kind to provide information about an icon button that wouldn’t be clear otherwise. For example, you can use a plain tooltip to indicate to a user what the icon button represents.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602449314/94cf84bf-dec0-462c-a8a0-6f878e0d5db3.gif" alt="Basic tooltip example" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>To add a tooltip to your application, you use the <strong>TooltipBox</strong> composable. This composable takes several arguments:</p>
<pre><code class="lang-kotlin"><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">TooltipBox</span><span class="hljs-params">(
    positionProvider: <span class="hljs-type">PopupPositionProvider</span>,
    tooltip: @<span class="hljs-type">Composable</span> <span class="hljs-type">TooltipScope</span>.() -&gt; <span class="hljs-type">Unit</span>,
    state: <span class="hljs-type">TooltipState</span>,
    modifier: <span class="hljs-type">Modifier</span> = Modifier,
    focusable: <span class="hljs-type">Boolean</span> = <span class="hljs-literal">true</span>,
    enableUserInput: <span class="hljs-type">Boolean</span> = <span class="hljs-literal">true</span>,
    content: @<span class="hljs-type">Composable</span> () -&gt; <span class="hljs-type">Unit</span>,
)</span></span>
</code></pre>
<p>Some of these should be familiar to you if you have used Composables before. I’ll highlight the ones that have a specific use case here:</p>
<ul>
<li><p>positionProvider - Of <strong>PopupPositionProvider</strong> type, and is used to calculate the position of the tooltip.</p>
</li>
<li><p>tooltip - This is where you can design the UI of how the tooltip will look like.</p>
</li>
<li><p>state - This holds the state that is associated with a specific Tooltip instance. It exposes methods like showing/dismissing the tooltip and when instantiating an instance of one, you can declare if the tooltip should be persistent or not (meaning if it should keep displaying on the screen until a user performs a click action outside the tooltip).</p>
</li>
<li><p>content - This is the UI that the tooltip will display above/below.</p>
</li>
</ul>
<p>Here is an example of instantiating a <strong>BasicTooltipBox</strong> with all the relevant arguments filled in:</p>
<pre><code class="lang-kotlin"><span class="hljs-meta">@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)</span>
<span class="hljs-meta">@Composable</span>
<span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">BasicTooltip</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">val</span> tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider()
    <span class="hljs-keyword">val</span> tooltipState = rememberBasicTooltipState(isPersistent = <span class="hljs-literal">false</span>)

    BasicTooltipBox(positionProvider = tooltipPosition,
        tooltip =  { Text(<span class="hljs-string">"Hello World"</span>) } ,
        state = tooltipState) {
        IconButton(onClick = { }) {
            Icon(imageVector = Icons.Filled.Favorite, 
                 contentDescription = <span class="hljs-string">"Your icon's description"</span>)
        }
    }
}
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602558759/e00e0bed-6a95-489e-af5c-a7d9dcc33fe6.gif" alt="A basic tooltip" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>Jetpack Compose has a built in class called TooltipDefaults. You can use this class to help you instantiate arguments that make up a TooltipBox. For instance, you could use <strong>TooltipDefaults.rememberPlainTooltipPositionProvider</strong> to correctly position the tooltip in relation to the anchor element.</p>
<h3 id="heading-rich-tooltip">Rich Tooltip</h3>
<p>A rich media tooltip takes more space than a plain tooltip and can be used to provide more context about the functionality of an icon button. When the tooltip is shown, you can add buttons and links to it to provide further explanation or definitions.</p>
<p>It is instantiated in a similar way as a plain tooltip, inside of a TooltipBox, but you use the RichTooltip composable.</p>
<pre><code class="lang-kotlin">TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text(<span class="hljs-string">"RichTooltip"</span>) },
                      caretSize = caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()
                              }
                          }) {
                              Text(<span class="hljs-string">"Dismiss"</span>)
                          }
                      }
                  ) {
                        Text(<span class="hljs-string">"This is where a description would go."</span>)
                  }
        },
        state = tooltipState) {
        IconButton(onClick = {
            <span class="hljs-comment">/* Icon button's click event */</span>
        }) {
            Icon(imageVector = tooltipIcon,
                contentDescription = <span class="hljs-string">"Your icon's description"</span>,
                tint = iconColor)
        }
    }
</code></pre>
<p>A few things to notice about a Rich tooltip:</p>
<ol>
<li><p>A Rich tooltip has support for a caret.</p>
</li>
<li><p>You can add an action (that is, a button) to the tooltip to give users an option to find out more information.</p>
</li>
<li><p>You can add logic to dismiss the tooltip.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602624042/40160d88-4e8a-4487-835d-1b74a9dd7c72.png" alt="Rich tooltip without a caret" class="image--center mx-auto" width="375" height="792" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602651265/f3e6f7fe-c4e1-4f98-972d-b20a273900b4.png" alt="Rich tooltip with a caret" class="image--center mx-auto" width="375" height="792" loading="lazy"></p>
<h3 id="heading-edge-cases">Edge Cases</h3>
<p>When you choose to mark your <strong>tooltip state as persistent</strong>, it means that once the user interacts with the UI that shows your tooltip, it will stay visible until the user presses anywhere else on the screen.</p>
<p>If you looked at the example of a Rich tooltip from above, you might have noticed that we have added a button to dismiss the tooltip once it’s clicked.</p>
<p>There is a problem that happens once a user presses that button. Since the dismiss action is performed on the tooltip, if a user wants to perform another long press on the UI item that invokes this tooltip, the tooltip won’t be shown again. This means that the state of the tooltip is persistent on it being dismissed. So, how do we go about and resolve this?</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602690256/a31b56bb-77c4-4444-bab6-7ffcca3f5207.gif" alt="Second long press does not trigger the tooltip" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>In order to “reset” the state of the tooltip, we have to call the <strong>onDispose</strong> method that is exposed through the tooltip state. Once we do that, the tooltip state is reset and the tooltip will be shown again when the user performs a long press on the UI item.</p>
<pre><code class="lang-kotlin"><span class="hljs-meta">@OptIn(ExperimentalMaterial3Api::class)</span>
<span class="hljs-meta">@Composable</span>
<span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">RichTooltip</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">val</span> tooltipPosition = TooltipDefaults.rememberRichTooltipPositionProvider()
    <span class="hljs-keyword">val</span> tooltipState = rememberTooltipState(isPersistent = <span class="hljs-literal">true</span>)
    <span class="hljs-keyword">val</span> scope = rememberCoroutineScope()

    TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text(<span class="hljs-string">"RichTooltip"</span>) },
                      caretSize = TooltipDefaults.caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()  <span class="hljs-comment">/// &lt;---- HERE</span>
                              }
                          }) {
                              Text(<span class="hljs-string">"Dismiss"</span>)
                          }
                      }
                  ) {

                  }
        },
        state = tooltipState) {
        IconButton(onClick = {  }) {
            Icon(imageVector = Icons.Filled.Call, contentDescription = <span class="hljs-string">"Your icon's description"</span>)
        }
    }
}
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602730404/60f31668-ea66-4127-b6fc-41f3aca952ae.gif" alt="onDispose solves the issue" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>Another scenario where the tooltip state does not reset is if instead of calling ourselves for the dismiss method per a user’s action, the user clicks outside of the tooltip, causing it to be dismissed. This calls the dismiss method behind the scenes and the tooltip state is set to dismissed. Long pressing on the UI element to see our tooltip again will result in nothing.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602758707/60387e08-72e6-45d4-bd47-ffb2708e0efe.gif" alt="The tooltip does not show again" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>Our logic that calls the tooltip’s onDispose method does not get triggered, so how can we reset the tooltip’s state?</p>
<p>Currently, I haven’t been able to figure this out. It might be related to the tooltip’s <a target="_blank" href="https://developer.android.com/reference/kotlin/androidx/compose/foundation/MutatorMutex">MutatorMutex</a>. Maybe with upcoming releases, there will be an API for this. I did notice that if other tooltips are present on the screen and they are pressed, this resets the previously clicked upon tooltip.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727602790121/25a81994-a508-4c71-8424-c45370a7999d.gif" alt="25a81994-a508-4c71-8424-c45370a7999d" class="image--center mx-auto" width="213" height="450" loading="lazy"></p>
<p>If you would like to see the code featured here, you can go to <a target="_blank" href="https://github.com/TomerPacific/MediumArticles/tree/master/TooltipExample">this GitHub repository</a></p>
<p>If you would like to see tooltips in an application, you can check it out <a target="_blank" href="https://play.google.com/store/apps/details?id=com.tomerpacific.laundry">here</a>.</p>
<h4 id="heading-references">References</h4>
<ul>
<li><p><a target="_blank" href="https://m3.material.io/components/tooltips/overview">Material3 Tooltip Overview</a></p>
</li>
<li><p><a target="_blank" href="https://developer.android.com/reference/kotlin/androidx/compose/material3/TooltipDefaults">Tooltip Defaults</a></p>
</li>
<li><p><a target="_blank" href="https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Tooltip.kt">Tooltip Source Code</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Migrate from Play Core Library ]]>
                </title>
                <description>
                    <![CDATA[ You may have recently received an email from Google Play Store stating the following: Update your Play Core Maven dependency to an Android 14 compatible version! Your current Play Core library is incompatible with targetSdkVersion 34 (Android 14), w... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/migrate-from-play-core-library/</link>
                <guid isPermaLink="false">66ba5031256e9dbeab31aa84</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ android app development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tomer ]]>
                </dc:creator>
                <pubDate>Wed, 26 Jun 2024 17:53:46 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/06/ben-hershey-fnRKVPx5_xY-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You may have recently received an email from Google Play Store stating the following:</p>
<blockquote>
<p><em>Update your Play Core Maven dependency to an Android 14 compatible version! Your current Play Core library is incompatible with targetSdkVersion 34 (Android 14), which introduces a backwards-incompatible change to broadcast receivers to improve user security. As a reminder, from August 31, Google Play requires all new app releases to target Android 14. Update to the latest Play Core library version dependency to avoid app crashes:</em> <a target="_blank" href="https://developer.android.com/guide/playcore#playcore-migration"><em>https://developer.android.com/guide/playcore#playcore-migration</em></a>  </p>
<p><em>You may not be able to release future versions of your app with this SDK version to production or open testing.</em></p>
</blockquote>
<p>Looks frightening, doesn’t it?</p>
<p>Don’t be so worried. It is actually easier than it looks.</p>
<h2 id="heading-what-the-change-is-actually-about">What the Change is Actually About</h2>
<p>Basically, Google stopped releasing new versions of the play core library back in early 2022.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/1.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>The last version of play core library released</em></p>
<p>And from April 2022, they have broken down the original play core library into four separate libraries:</p>
<ul>
<li>Play Assets Delivery Library</li>
<li>Play Feature Delivery Library</li>
<li>Play In-App Reviews Library</li>
<li>Play In-App Updates Library</li>
</ul>
<p>Each library has its own functionality and responsibility.</p>
<p>Since the older core play library only supports up to a certain API level, you need to migrate your application to use the newer libraries that have support for the most recent API levels.</p>
<p>In essence, you need to figure out which functionality of the original core play library you are using and then download the correct part. For example, if you had logic to notify users when a newer version of your application was available, you need to take the Play In-App-Updates library.</p>
<p>We will be presenting two uses cases here:</p>
<ul>
<li>Native Android application</li>
<li>Flutter application</li>
</ul>
<h2 id="heading-use-case-native-android-app">Use Case – Native Android App</h2>
<p>If you have a native Android application, whether it is written in Kotlin or Java, you need to do the following:</p>
<ol>
<li>Open your application level build.gradle file</li>
<li>Most probably you will see under the dependencies block, this line:</li>
</ol>
<pre><code class="lang-groovy">implementation 'com.google.android.play:core-ktx:1.8.1'
</code></pre>
<ol start="3">
<li><p>You will need to remove it and replace it according to what you used in the previous core library</p>
</li>
<li><p>If you need to take the Play In-App-Updates library, then you need to add these to the dependencies block:</p>
</li>
</ol>
<pre><code class="lang-groovy">implementation 'com.google.android.play:app-update:2.1.0'
//Add the dependency below if you are using Kotlin in your application
implementation 'com.google.android.play:app-update-ktx:2.1.0'
</code></pre>
<ol start="5">
<li>Rebuild your application and see that everything works as it should.</li>
</ol>
<p>✋ You might also need to change import statements from <strong>import com.google.android.play.core.tasks.*;</strong> to <strong>import com.google.android.gms.tasks.*;</strong>.</p>
<h2 id="heading-use-case-flutter-application">Use Case – Flutter Application</h2>
<p>Since Flutter is a framework that caters to both Android and iOS, this scenario is a bit different from the one above. If you receive the warning to upgrade the core play library in your Flutter application, you need to have a look at the libraries you are using in your pubspec.yaml file:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">dependencies:</span>
  <span class="hljs-attr">flutter:</span>
    <span class="hljs-attr">sdk:</span> <span class="hljs-string">flutter</span>
  <span class="hljs-string">...</span>
  <span class="hljs-attr">in_app_update:</span> <span class="hljs-string">^3.0.0</span>
</code></pre>
<p>As you can see above, the application depends on the <strong>in_app_update</strong> library, which has to do with notifying users when a newer version of the application is available. When we head over to in_app_update’s pub.dev <a target="_blank" href="https://pub.dev/packages/in_app_update/changelog">changelog page</a>, we can see that:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/06/1-1.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>version 4.1.0 added the required support</em></p>
<p>So we need to update our pubspec.yaml file to use that version (at the very least).</p>
<pre><code class="lang-yaml"><span class="hljs-attr">dependencies:</span>
  <span class="hljs-attr">flutter:</span>
    <span class="hljs-attr">sdk:</span> <span class="hljs-string">flutter</span>
  <span class="hljs-string">...</span>
  <span class="hljs-attr">in_app_update:</span> <span class="hljs-string">^4.1.0</span>
</code></pre>
<p>Run Pub get and you should be good to go.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Work on a Multi-Library Project in Android – Locally and Remotely ]]>
                </title>
                <description>
                    <![CDATA[ In this article, we're going to talk about multi-library projects in Android. It's not something ordinary, but not something out of the ordinary either.  You may have come across multi-library projects in your line of work, or you may be looking into... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/working-on-a-multiple-library-project-in-android/</link>
                <guid isPermaLink="false">66ba50548e44e0cdf1281256</guid>
                
                    <category>
                        <![CDATA[ Android ]]>
                    </category>
                
                    <category>
                        <![CDATA[ android app development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tomer ]]>
                </dc:creator>
                <pubDate>Sat, 27 Apr 2024 22:37:15 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/04/sandy-millar-5PCeHBkMCmk-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, we're going to talk about multi-library projects in Android. It's not something ordinary, but not something out of the ordinary either. </p>
<p>You may have come across multi-library projects in your line of work, or you may be looking into converting your library into sub-modules for better structure and organization. No matter the case, you should be well aware of what lies in front of you before diving in.</p>
<p>Writing your own library in Android is neat. You get a chance to write some code that can help other developers (or even yourself). </p>
<p>Since libraries can’t be a standalone project by themselves, they are usually always paired in a project with an application. This allows developing the library to be a simple process where you add a feature/fix a bug and then you can test it directly with the application you have in the project. Thus, simulating (in a local way) how a developer will integrate your library.</p>
<p>But, what if your library relies on another library you are developing?</p>
<p>If you are not aware of it, you should know that a library (read aar) cannot contain another local library within it. It can rely on libraries remotely (via dependencies), but not on something local. </p>
<p>This is not supported in Android, and while some solutions popped up during the years (<a target="_blank" href="https://github.com/kezong/fat-aar-android">FatAar</a>), these didn’t always solve the problem and are not up to date. There is even a <a target="_blank" href="https://issuetracker.google.com/issues/62121508?pli=1">Google Issue Tracker</a> requesting this feature that has been open for quite some time and is receiving plenty of attention from the community. But let’s identify which walls we can break and which we cannot.</p>
<p>Imagine your project hierarchy looks like this:</p>
<pre><code>-- App
|
 -- OuterLib
   |
    --- InnerLib
</code></pre><p>So, since InnerLib can’t be part of your original project, where can it reside? And also how would you be able to work locally while developing features inside InnerLib?</p>
<p>We are going to answer these questions in this article.</p>
<h2 id="heading-git-submodule">Git Submodule</h2>
<p>For most technical problems, there isn’t always just one solution. Usually, there are more, but each solution has its drawbacks. It's all a question of which drawbacks you are more comfortable living with at the end of the day.</p>
<p>To answer our first question, where can InnerLib reside, we have several options:</p>
<ol>
<li>Make InnerLib a submodule of our original project</li>
<li>Make InnerLib a remote dependency of its own</li>
</ol>
<p>If you are not aware of submodules in Git, <a target="_blank" href="https://git-scm.com/book/en/v2/Git-Tools-Submodules">Git’s documentation</a> is a good place to familiarize yourself with them. Quoting from it (the first paragraph):</p>
<blockquote>
<p>It often happens that while working on one project, you need to use another project from within it. 👉 Perhaps it’s a library that a third party developed or that you’re developing separately and using in multiple parent projects. 👈 A common issue arises in these scenarios: you want to be able to treat the two projects as separate yet still be able to use one from within the other.</p>
</blockquote>
<p>This paragraph shows us that this is exactly our use case. Using a submodule has its benefits. All your code is in one place, is easy to manage, and is easy to develop locally. </p>
<p>But submodules have some weaknesses. One is the fact that you must always be aware of which branch your submodule is pointing to. Imagine a scenario where you are on a release branch in your main repository and your sub-module is on a feature branch. If you don’t notice, you release a version of your code with something that is not ready for production. Whoops.</p>
<p>Now think about this within a team of developers. One careless mistake can be costly.</p>
<p>If the first option sounds problematic for you, then hosting your library in another repository is your second choice. Setting up the repository is pretty simple, but how do you work locally now?</p>
<h2 id="heading-working-locally">Working Locally</h2>
<p>Now that we've gotten our project set up properly, we will probably have a line similar to this in our OuterLib build.gradle file:</p>
<pre><code>dependencies {
  implementation <span class="hljs-string">'url_to_remote_inner_lib_repository'</span>
}
</code></pre><p>How can we make the development cycle efficient and easy to work with? If we develop some feature in InnerLib, how do we test things out in OuterLib? Or in our application?</p>
<p>One solution that might come up is to import our InnerLib locally to our OuterLib project, while having InnerLib .gitignored in our OuterLib project. You can do so easily by right clicking on the name of the project in the left hand side menu in Android Studio and going to New → Module.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/1.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>How to import a module (Step 1)</em></p>
<p>Then in the window that opens up, you can choose the Import option at the bottom left:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/1-1.jpg" alt="Image" width="600" height="400" loading="lazy">
<em>How to import a module (Step 2)</em></p>
<p>That sounds easy and simple so far, but what’s the catch?</p>
<p>Each time you modify a file that belongs to InnerLib, the changes won’t be reflected inside InnerLib since it is ignored. So, each change you want to make has to happen inside of InnerLib and then you have to import it again inside OuterLib to see the changes.</p>
<p>This doesn’t seem right. There must be a better way of doing this.</p>
<p>With just a few lines in our <strong>settings.gradle</strong> file, we can make sure our files stay in sync when we make changes in InnerLib. </p>
<p>When we imported InnerLib into our project, Android Studio made a copy of InnerLib and cached it. That is why we needed to re-import the library for every change we made inside of it. We can tell Android Studio where to reference the files from using the <strong>projectDir</strong> attribute. </p>
<p>Our settings.gradle might look something like this:</p>
<pre><code>include <span class="hljs-string">':outerLib'</span>, <span class="hljs-string">':innerLib'</span>, <span class="hljs-string">':app'</span>
</code></pre><p>To reference our InnerLib locally, we would have to change settings.gradle into this:</p>
<pre><code>include <span class="hljs-string">':outerLib'</span>, <span class="hljs-string">':innerLib'</span>, <span class="hljs-string">':app'</span>
project(<span class="hljs-string">'innerLib'</span>).projectDir = <span class="hljs-keyword">new</span> File(<span class="hljs-string">'PATH_TO_INNER_LIB'</span>)
</code></pre><p>Using this approach, our InnerLib files will be linked to our working directory, so every change we make will be reflected immediately. </p>
<p>But, we would like flexibility when working locally on OuterLib with a remote version of InnerLib. What we wrote above inside the settings.gradle file will only allow us to work locally and surely we don’t want to commit that as it is.</p>
<h2 id="heading-maven-local">Maven Local</h2>
<p>If the approach above doesn’t sit quite right with you, there is a different one you can take. Just like you would publish your library publicly with maven, you can do the same thing locally with maven local. Maven local is a set of repositories that sit locally on your machine.</p>
<p>Below are the paths for mavenLocal depending on the operating system of your machine:</p>
<ul>
<li>Mac → /Users/YOUR_USERNAME/.m2</li>
<li>Linux → /home/YOUR_USERNAME/.m2</li>
<li>Windows → C:\Users\YOUR_USERNAME.m2</li>
</ul>
<p>In essence you can publish your library locally and then link to it in your project. Doing it this way, we can link our project to InnerLib. </p>
<p>In order to allow this configuration in our project, we need to do the following things:</p>
<ol>
<li>Add <strong><em>mavenLocal()</em></strong> as a repository inside our repositories clause. This is to allow our project the ability to search for repositories locally</li>
</ol>
<pre><code>buildscript {
    repositories {
        mavenLocal()
    }
}

...

allprojects { 
    repositories { 
        mavenLocal() 
    }
}
</code></pre><ol start="2">
<li><p>Change our implementation line inside our dependencies clause to reference our InnerLib as if it we are referencing it remotely</p>
</li>
<li><p>To publish InnerLib locally, we will create a file called publishingLocally.gradle that will contain the following:</p>
</li>
</ol>
<pre><code>apply plugin: <span class="hljs-string">'maven-publish'</span> 

project.afterEvaluate {
    publishing { 
      publications {
            library(MavenPublication) { 
                    setGroupId groupId          <span class="hljs-comment">//your library package</span>
                    setArtifactId artifactId              
                    version versionName         <span class="hljs-comment">//I.E. 1.0</span>

                    artifact bundleDebugAar

                    pom.withXml { 
                        def dependenciesNode = asNode().appendNode(<span class="hljs-string">'dependencies'</span>)
                        def dependencyNode = dependenciesNode.appendNode(<span class="hljs-string">'dependency'</span>)
                        dependencyNode.appendNode(<span class="hljs-string">'groupId'</span>, <span class="hljs-string">'your_group_id'</span>)
                        dependencyNode.appendNode(<span class="hljs-string">'artifactId'</span>, <span class="hljs-string">'your_artificat_id'</span>)
                        dependencyNode.appendNode(<span class="hljs-string">'version'</span>, <span class="hljs-string">'your_version'</span>)
                    } 
                }
            }
        }
}
</code></pre><ol start="4">
<li>Inside your application level build.gradle file, add the line:</li>
</ol>
<pre><code>apply <span class="hljs-keyword">from</span>: <span class="hljs-string">'/.publishingLocally.gradle</span>
</code></pre><p>If this option seems a bit too good to be true, <strong>it is</strong>. While on one hand, we can develop things locally seamlessly just as if we were working with a remote library. On the other, if we make any change inside InnerLib while working locally, it is required to publish it locally again. While this isn’t a costly task, it does create a need to perform tedious tasks over and over.</p>
<h2 id="heading-a-solution-for-working-locally-and-remotely">A Solution for Working Locally and Remotely</h2>
<p>We want to avoid the constant need to re-publish our InnerLib package whenever we make a change locally. We need to figure out a way to make our project be aware of those changes. </p>
<p>In the Working Locally section, we found out how to do that, but we had an issue with committing the settings.gradle file. To solve this problem so we can work both locally and remotely with our InnerLib, we will use a parameter we will define in our <strong><em>gradle.properties</em></strong> file.</p>
<p>The gradle.properties file is a place where you can store project level settings that configure your development environment. This helps make sure that all the developers on a team have a consistent development environment. </p>
<p>Some settings you might be familiar with that are found inside this file are AndroidX support (android.useAndroidX=true) or the JVM arguments (org.gradle.jvmargs=-Xmx1536m). </p>
<p>To help us solve our situation, we can add a parameter here to indicate whether we want to work locally or not. Something along the lines of:</p>
<pre><code>workingLocally = <span class="hljs-literal">false</span>
</code></pre><p>This parameter will grant us the ability to distinguish between which settings we are working with, either locally or with production code. First, let’s alter what we have in our settings.gradle file by wrapping it in a condition that checks if our parameter is true:</p>
<pre><code>include <span class="hljs-string">':outerLib'</span>, <span class="hljs-string">':innerLib'</span>, <span class="hljs-string">':app'</span>
<span class="hljs-keyword">if</span> (workingLocally.booleanValue()) {
  project(<span class="hljs-string">'innerLib'</span>).projectDir = <span class="hljs-keyword">new</span> File(<span class="hljs-string">'PATH_TO_INNER_LIB'</span>)
}
</code></pre><p>This way, we indicate to the project to get the files for our InnerLib locally from our machine. </p>
<p>Another place where we need to change our logic is in our build.gradle file. Here, instead of getting the code to our library remotely in our dependencies block, we can indicate whether we are depending on it locally or not.</p>
<pre><code>dependencies {
   <span class="hljs-keyword">if</span> (workingLocally.booleanValue()) {
      implementation <span class="hljs-string">'innerLib'</span>
   } <span class="hljs-keyword">else</span> {
     implementation <span class="hljs-string">'url_to_remote_repository'</span>
  }
}
</code></pre><blockquote>
<p><em>⚠️ Word of warning: You should never commit the gradle.properties file when working locally.</em></p>
</blockquote>
<p>The journey was long and may have seemed quite exhausting. But now we have a full-proof setup for working locally and remotely on a multiple library project.</p>
<p>If you encounter any issues or would like to give your take on this, feel free to leave a comment.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
