<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged android</title>
<link>https://coderlegion.com/tag/android</link>
<description>Powered by Question2Answer</description>
<item>
<title>The invitation is an APK</title>
<link>https://coderlegion.com/33136/the-invitation-is-an-apk</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/insights/the-invitation-is-an-apk.html&quot;&gt;Dunime&lt;/a&gt; blog.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;How wedding cards and traffic challans became India's most personal malware, and what an investigation has to capture.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The file arrives from an uncle's number, in the family group, a week before a wedding everyone knows about. It ends in .apk, not .pdf. Nobody checks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Through 2025 and into 2026, the most effective mobile malware in India has not needed an exploit. It needs a reason to be opened. Wedding invitations, pending RTO challans, PM-Kisan instalments, courier delivery slips and KYC reminders have all been used as the wrapper, and police cyber cells from Himachal Pradesh to Telangana have issued warnings about the same pattern.&lt;/p&gt;
&lt;h3 id=&quot;why-it-works&quot;&gt;Why it works&lt;/h3&gt;
&lt;p&gt;Three things make this campaign different from the lottery SMS it replaced. It arrives from a contact the victim already trusts, because the previous victim's phone forwards it. It borrows an event the victim is expecting, so the name of the file is plausible. And it carries a deadline: a fine to be paid today, a ceremony next week, a parcel waiting at the depot.&lt;/p&gt;
&lt;p&gt;None of this is technically sophisticated. It is social engineering fitted to how Indian families and small businesses actually use WhatsApp: large groups, forwarded media, and phones shared between work and home.&lt;/p&gt;
&lt;h3 id=&quot;what-the-file-does-once-installed&quot;&gt;What the file does once installed&lt;/h3&gt;
&lt;p&gt;Published analysis of the challan variant describes a two-stage dropper: the first app is a thin installer that fetches the real payload, hides its own icon and asks for permissions one plausible prompt at a time. Other variants seen in the same period behave the same way.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Stage &lt;/th&gt;&lt;th&gt;What happens&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Lure&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;A familiar contact shares “Wedding_Invite.apk” or “RTO_Challan.apk”.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Install&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The victim allows installs from unknown sources.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Grant&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;SMS, calls, notifications, accessibility, battery exemption.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hide&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Second stage fetched; icon removed; traffic tunnelled.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Harvest&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;OTPs, SMS, contacts, identity documents.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Spread&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;The same file is forwarded to every chat.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;The common kill chain behind invitation, challan, courier and KYC APKs seen in 2025–26.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OTP interception&lt;/strong&gt; by reading SMS and notifications before the owner sees them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity harvesting&lt;/strong&gt; of Aadhaar, PAN, SIM and contact data from the device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility abuse&lt;/strong&gt; that lets the operator tap buttons inside a banking or UPI app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Battery-optimisation exemption&lt;/strong&gt; so the service survives restarts and cleaners.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A private VPN tunnel&lt;/strong&gt; that hides command-and-control traffic from on-device security tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-propagation&lt;/strong&gt; through the victim's own WhatsApp, which is also why many accounts end up banned.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;The victim's bank sends the OTP. The victim never sees it. That is the whole attack.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;what-an-investigation-should-preserve&quot;&gt;What an investigation should preserve&lt;/h3&gt;
&lt;p&gt;These cases are usually reported after money has moved, often hours later. By then the victim has uninstalled the app, factory-reset the phone, or both. The evidence that makes the case prosecutable is on the device and in the chat, and it is fragile.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The original message&lt;/strong&gt; with sender number, group name and timestamp, before it is deleted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The APK itself&lt;/strong&gt; hashed on collection, so the sample can be tied to other complaints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Installed package list and permissions&lt;/strong&gt; captured before uninstalling anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SMS and notification history&lt;/strong&gt; covering the window in which OTPs were intercepted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bank and UPI transaction references&lt;/strong&gt; requested early, while the money trail is still warm.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The infrastructure&lt;/strong&gt; domains and servers the sample talks to, which link one victim to a campaign.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Linking samples matters more than it looks. The same signing certificate or command server appearing across complaints turns a dozen small frauds into one organised operation, and changes how an agency and a court treat the case.&lt;/p&gt;
&lt;h3 id=&quot;the-legal-grounds&quot;&gt;The legal grounds&lt;/h3&gt;
&lt;p&gt;Under the Information Technology Act, 2000, installing the payload and taking data is covered by Section 43 read with Section 66; using the victim's identity or OTP falls under Section 66C; and the impersonation that makes the lure work is cheating by personation using a computer resource under Section 66D. The Bharatiya Nyaya Sanhita, 2023 adds cheating and cheating by personation. Electronic evidence now travels under the Bharatiya Sakshya Adhiniyam, which is why hashing and a documented chain of custody from the first collection are worth the trouble.&lt;/p&gt;
&lt;p&gt;Individuals should report on the national cybercrime helpline, 1930, or at cybercrime.gov.in, as early as possible; speed is what makes a freeze on the receiving accounts possible. Organisations have a separate clock: CERT-In directions require specified cyber incidents to be reported within six hours of being noticed, and a compromised employee phone with access to company systems can qualify.&lt;/p&gt;
&lt;h3 id=&quot;what-an-organisation-should-change&quot;&gt;What an organisation should change&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Block sideloading&lt;/strong&gt; on every managed device, with no exceptions for senior staff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vet the apps that are allowed&lt;/strong&gt; including vendor and partner apps, before they reach employees.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat a suspect phone as an incident&lt;/strong&gt; and examine it before anyone wipes it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tell staff plainly&lt;/strong&gt; that no government department sends a challan, subsidy or KYC update as an APK.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rehearse the first hour&lt;/strong&gt; who isolates the device, who calls the bank, who files the report.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The campaign will keep changing its wrapper: electricity bills, exam results, festival offers. The underlying behaviour does not change, and that is what detection and investigation should be built around.&lt;/p&gt;
&lt;h3 id=&quot;sources&quot;&gt;Sources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.cyfirma.com/research/rto-challan-fraud-a-technical-report-on-apk-based-financial-and-identity-theft/&quot;&gt;CYFIRMA: RTO Challan fraud: a technical report on APK-based financial and identity theft&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://the420.in/rto-e-challan-apk-scam-india-whatsapp-malware-cybercrime-alert/&quot;&gt;The420.in: RTO e-challan scam: fake APK files used to empty bank accounts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.deccanherald.com/technology/new-scam-alert-this-wedding-invitation-takes-over-your-phone-steals-data-and-money-3278051&quot;&gt;Deccan Herald: Himachal Pradesh police alert on wedding-invitation APKs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.tribuneindia.com/news/himachal/challan-scam-alert-fraudsters-use-whatsapp-apk-links-to-loot-money&quot;&gt;Tribune: Himachal cyber cell warning on challan APK links&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;This guide is part of Dunime's fraud-help hub — also in हिन्दी, বাংলা, मराठी, తెలుగు and தமிழ், each with a short video: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/protect&quot;&gt;dunime.com/protect&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Need a suspect APK taken apart? Dunime's &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com/shodhan.html&quot;&gt;Shodhan&lt;/a&gt; returns a verdict, indicators and a custody log.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Published by &lt;a rel=&quot;nofollow&quot; href=&quot;https://dunime.com&quot;&gt;Dunime Technologies&lt;/a&gt; — custom software, automation and cybersecurity, Delhi NCR, India.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33136/the-invitation-is-an-apk</guid>
<pubDate>Fri, 30 Oct 2026 19:55:57 +0000</pubDate>
</item>
<item>
<title>👨🏾💻🚀 Safe Woman Operational System: levando o Mulher Amparada para dentro do Android!</title>
<link>https://coderlegion.com/32858/safe-woman-operational-system-levando-o-mulher-amparada-para-dentro-do-android</link>
<description>&lt;p&gt;E se o Mulher Amparada não fosse apenas um aplicativo, mas também fizesse parte de um sistema operacional personalizado?&lt;/p&gt;
&lt;p&gt;Essa é a proposta do Safe Woman Operational System, um projeto experimental baseado no Android Open Source Project (AOSP), desenvolvido com o objetivo de integrar o Mulher Amparada diretamente à estrutura do sistema Android.&lt;/p&gt;
&lt;p&gt;🔐 Mais do que um aplicativo, uma nova possibilidade de integração!&lt;/p&gt;
&lt;p&gt;A ideia é explorar como a tecnologia do Mulher Amparada pode ir além da instalação convencional, tornando-se parte de uma imagem personalizada do sistema operacional.&lt;/p&gt;
&lt;p&gt;🛠️ O que está sendo desenvolvido?&lt;/p&gt;
&lt;p&gt;📱 Integração com o AOSP: preparação dos arquivos necessários para incorporar o Mulher Amparada à estrutura do Android. ⚙️ Configuração personalizada: definição do produto safe_woman-userdebug, com configurações específicas para o sistema experimental. 🔧 Build com Soong: configuração do aplicativo por meio do arquivo Android.bp. 🔐 Permissões privilegiadas: preparação da estrutura de allowlist para permissões de sistema compatíveis, respeitando o modelo de segurança do Android. 🖥️ Testes com Cuttlefish: planejamento da execução em um dispositivo Android virtual, sem depender inicialmente de um aparelho físico. &lt;/p&gt;
&lt;p&gt;🌐 Como essa integração funcionaria?&lt;/p&gt;
&lt;p&gt;A proposta é utilizar uma árvore completa do AOSP como base e adicionar os componentes específicos do Safe Woman OS, incluindo a integração planejada com o aplicativo Mulher Amparada.&lt;/p&gt;
&lt;p&gt;O resultado esperado é uma imagem personalizada do Android, preparada para execução experimental no Cuttlefish.&lt;/p&gt;
&lt;p&gt;⚠️ Transparência técnica: o repositório não contém o AOSP completo. Ele reúne apenas uma pequena parte da estrutura necessária para personalizar o sistema. O projeto ainda é experimental, e a compilação funcional depende da árvore completa do AOSP, das dependências e das configurações necessárias.&lt;/p&gt;
&lt;p&gt;💡 Por que isso importa?&lt;/p&gt;
&lt;p&gt;Porque inovar também significa explorar novas formas de integrar soluções tecnológicas aos sistemas que utilizamos diariamente. O Safe Woman Operational System representa um passo experimental nessa direção, aproximando o desenvolvimento de sistemas operacionais do propósito do Mulher Amparada: contribuir para a proteção, a segurança e a assistência em situações de emergência.&lt;/p&gt;
&lt;p&gt;🚀 O futuro que estamos explorando é um Android personalizado com o Mulher Amparada integrado à própria imagem do sistema.&lt;/p&gt;
&lt;p&gt;🔗 Conheça o projeto e acompanhe seu desenvolvimento:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Safe%20Woman%20Operational%20System&quot;&gt;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Safe Woman Operational System&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32858/safe-woman-operational-system-levando-o-mulher-amparada-para-dentro-do-android</guid>
<pubDate>Wed, 28 Oct 2026 14:52:31 +0000</pubDate>
</item>
<item>
<title>🖥️ Safe Woman Operational System: um experimento de AOSP feito por um garoto autista de 15 anos</title>
<link>https://coderlegion.com/31887/safe-woman-operational-system-um-experimento-de-aosp-feito-por-um-garoto-autista-de-15-anos</link>
<description>&lt;p&gt;Todo mundo já viu o Mulher Amparada (o app). Mas pouca gente sabe que o mesmo dev que compilou 6.117 commits num Samsung A16 5G decidiu dar um passo além: colocar o app dentro do próprio sistema operacional.&lt;/p&gt;
&lt;p&gt;É isso mesmo. Estamos falando de AOSP.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🤔 O que é o Safe Woman OS?&lt;/p&gt;
&lt;p&gt;O Safe Woman Operational System é um projeto experimental de sistema operacional baseado no Android Open Source Project (AOSP).&lt;/p&gt;
&lt;p&gt;O objetivo? Criar uma versão personalizada do Android que já venha com o Mulher Amparada integrado como app de sistema, em vez de ser apenas um APK que a usuária instala depois.&lt;/p&gt;
&lt;p&gt;⚠️ Importante desde já: este repositório NÃO contém o AOSP completo. Ele tem apenas os arquivos de personalização do Safe Woman OS — o suficiente para ser colocado dentro de uma árvore AOSP completa.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;📁 Estrutura do projeto&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Safe Woman Operational System/
├── device/
│   └── safe_woman/
│       ├── AndroidProducts.mk
│       ├── BoardConfig.mk
│       ├── safe_woman.mk
│       └── privapp-permissions-mulher-amparada.xml
│
└── packages/
    └── apps/
        └── MulherAmparada/
            ├── Android.bp
            └── app/
                └── src/
                    └── main/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tudo o que importa está em dois diretórios: device/safe_woman (configuração do produto) e packages/apps/MulherAmparada (integração do app).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;⚙️ Como cada peça funciona&lt;/p&gt;
&lt;p&gt;📦 AndroidProducts.mk&lt;/p&gt;
&lt;p&gt;Define o alvo do produto:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;safe_woman-userdebug
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;🧩 BoardConfig.mk&lt;/p&gt;
&lt;p&gt;Configura a plataforma base do sistema experimental — atualmente preparada para arquitetura x86_64 e execução em ambiente virtual.&lt;/p&gt;
&lt;p&gt;🏗️ safe_woman.mk&lt;/p&gt;
&lt;p&gt;Define o produto Safe Woman OS e integra componentes específicos, incluindo o app MulherAmparada.&lt;/p&gt;
&lt;p&gt;🔐 privapp-permissions-mulher-amparada.xml&lt;/p&gt;
&lt;p&gt;Allowlist de permissões privilegiadas para o pacote com.mulheres.&lt;/p&gt;
&lt;p&gt;⚠️ Detalhe técnico que muita gente erra: permissões de runtime (localização, microfone, contatos) não se tornam privilegiadas só porque estão nesse XML. Elas seguem o modelo normal de permissões do Android. O XML só vale para permissões compatíveis com o mecanismo de allowlist do priv-app.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;📱 Integração do Mulher Amparada&lt;/p&gt;
&lt;p&gt;O app é integrado em:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;packages/apps/MulherAmparada
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pacote Android:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;com.mulheres
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;E o Android.bp (arquivo do Soong, o build system do AOSP) declara:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;privileged: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Isso significa que o app pode ser tratado como privilegiado do sistema — mas não que todas as permissões serão concedidas automaticamente. Permissões perigosas continuam seguindo o modelo de runtime do Android.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🐟 Cuttlefish: o plano de teste&lt;/p&gt;
&lt;p&gt;O Safe Woman OS foi planejado inicialmente para rodar no Cuttlefish — o dispositivo Android virtual oficial do AOSP.&lt;/p&gt;
&lt;p&gt;A ideia é chegar a algo assim:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AOSP completo
      +
Safe Woman OS
      +
Mulher Amparada
      ↓
safe_woman-userdebug
      ↓
imagem do sistema
      ↓
Cuttlefish
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Assim dá pra testar a estrutura sem depender de um aparelho físico.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🧱 Relação com o AOSP (importante)&lt;/p&gt;
&lt;p&gt;O Safe Woman OS não substitui o AOSP. Ele funciona como uma camada de personalização por cima de uma árvore AOSP completa.&lt;/p&gt;
&lt;p&gt;Uma árvore AOSP completa tem centenas de componentes, incluindo:&lt;/p&gt;
&lt;p&gt;· Android Framework&lt;br&gt;
· Android Runtime (ART)&lt;br&gt;
· Bionic&lt;br&gt;
· System&lt;br&gt;
· Hardware Abstraction Layers (HALs)&lt;br&gt;
· Kernel&lt;br&gt;
· Cuttlefish&lt;br&gt;
· Bibliotecas&lt;br&gt;
· Ferramentas de build&lt;br&gt;
· Componentes externos&lt;br&gt;
· Aplicações do sistema&lt;br&gt;
· Configurações de dispositivos&lt;br&gt;
· Prebuilts&lt;br&gt;
· Ferramentas de desenvolvimento&lt;/p&gt;
&lt;p&gt;O Safe Woman OS contém só uma pequena fração disso — apenas os arquivos específicos necessários para definir a personalização.&lt;/p&gt;
&lt;p&gt;💡 Transparência do dev: &quot;A existência desses arquivos, por si só, não significa que o sistema completo já possa ser compilado.&quot;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🚦 Estado atual (com honestidade)&lt;/p&gt;
&lt;p&gt;O projeto é experimental. E o próprio repositório deixa claro:&lt;/p&gt;
&lt;p&gt;· A estrutura foi organizada para ser colocada dentro de uma árvore AOSP completa&lt;br&gt;
· Ainda são necessários todos os componentes do AOSP e as dependências do app&lt;br&gt;
· O sucesso da compilação do APK não comprova o funcionamento integral do sistema&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🤖 E tem automação? Tem sim.&lt;/p&gt;
&lt;p&gt;O workflow simular-os.yml faz toda a mágica no GitHub Actions:&lt;/p&gt;
&lt;p&gt;O que ele faz:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Compila o APK (./gradlew assembleDebug)&lt;/li&gt;
&lt;li&gt;Cria um emulador Android 15 (API 35) x86_64 com imagem aosp_atd&lt;/li&gt;
&lt;li&gt;Inicia o emulador com -writable-system&lt;/li&gt;
&lt;li&gt;Tenta injetar o APK em /system/priv-app/MulherAmparada/&lt;/li&gt;
&lt;li&gt;Cria o XML de permissões privilegiadas em /system/etc/permissions/&lt;/li&gt;
&lt;li&gt;Reinicia os serviços do Android (stop / start)&lt;/li&gt;
&lt;li&gt;Verifica com pm path com.mulheres se o pacote foi registrado no caminho correto&lt;/li&gt;
&lt;li&gt;Registra informações do sistema (partições, verity, build type, etc.)&lt;/li&gt;
&lt;li&gt;Publica os resultados na branch main e como artefato do GitHub Actions&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Arquivos gerados na simulação:&lt;/p&gt;
&lt;p&gt;· emulator.log — saída do emulador&lt;br&gt;
· mount.txt — sistemas de arquivos montados&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🎯 Objetivo futuro&lt;/p&gt;
&lt;p&gt;O plano é:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Usar uma árvore AOSP completa como base&lt;/li&gt;
&lt;li&gt;Adicionar o Safe Woman OS como configuração personalizada&lt;/li&gt;
&lt;li&gt;Integrar o Mulher Amparada diretamente ao sistema&lt;/li&gt;
&lt;li&gt;Rodar no Cuttlefish como ambiente virtual&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;O resultado esperado: um Android personalizado executável em ambiente virtual, com o app de proteção à mulher já embutido no sistema.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;💭 Por que isso importa?&lt;/p&gt;
&lt;p&gt;Colocar um app de proteção à mulher dentro do sistema operacional — e não apenas como um APK instalável — tem implicações reais:&lt;/p&gt;
&lt;p&gt;· Mais difícil de remover por terceiros (como um agressor que vasculha o celular)&lt;br&gt;
· Permissões de sistema podem ser gerenciadas de forma diferente&lt;br&gt;
· Integração mais profunda com o framework do Android&lt;br&gt;
· Mais controle sobre o que acontece quando o app é iniciado&lt;/p&gt;
&lt;p&gt;Claro, tudo isso ainda é experimental e o próprio dev é transparente sobre as limitações. Mas a ambição é o que chama atenção: um adolescente autista, programando num celular, tentando construir um Android customizado que já nasce com proteção embutida para mulheres.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🔗 Link do projeto&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Safe%20Woman%20Operational%20System&quot;&gt;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Safe Woman Operational System&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Resultados%20da%20compila%C3%A7%C3%A3o%20do%20Safe%20Woman%20OS&quot;&gt;https://github.com/mulher-amparada/mulher-amparada-app/tree/main/Resultados da compilação do Safe Woman OS&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;🧑💻 Considerações finais&lt;/p&gt;
&lt;p&gt;O Safe Woman OS é o tipo de projeto que não deveria existir — não porque é ruim, mas porque deveria haver mais gente tentando fazer isso. É um experimento honesto sobre o que é possível fazer quando você:&lt;/p&gt;
&lt;p&gt;· Não tem AOSP completo&lt;br&gt;
· Não tem um PC potente&lt;br&gt;
· Não tem curso formal&lt;br&gt;
· Tem um celular, GitHub Actions e vontade&lt;/p&gt;
&lt;p&gt;E o mais bonito: o dev documenta tudo — inclusive o que não funciona. O README, os workflows, as limitações... tudo está lá, aberto, para qualquer pessoa continuar.&lt;/p&gt;
&lt;p&gt;&quot;O projeto não pretende reproduzir o AOSP inteiro dentro deste repositório. Ele mantém somente os arquivos próprios do Safe Woman OS e a integração planejada com o aplicativo Mulher Amparada.&quot;&lt;/p&gt;
&lt;p&gt;Se você é dev Android, entusiasta de AOSP ou só curioso, dá uma olhada. E se quiser contribuir — o projeto é open-source.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31887/safe-woman-operational-system-um-experimento-de-aosp-feito-por-um-garoto-autista-de-15-anos</guid>
<pubDate>Tue, 20 Oct 2026 19:38:55 +0000</pubDate>
</item>
<item>
<title>Fix Gradle Dependency Verification Failures</title>
<link>https://coderlegion.com/31515/fix-gradle-dependency-verification-failures</link>
<description>&lt;p&gt;A Gradle dependency verification failure occurs immediately after upgrading a plugin, adding a module, bumping a library, or enabling a benchmark configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Quick solution:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Run &lt;code&gt;./gradlew --write-verification-metadata sha256&lt;/code&gt; to update your allowlist incrementally while preserving the security gate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gradle's &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.gradle.org/current/userguide/dependency_verification.html&quot;&gt;dependency verification guide&lt;/a&gt; covers checksums, signatures, metadata generation, and verification failures. The fastest-looking fix is also the most dangerous one: delete verification-metadata.xml or disable verification until CI turns green. Do not do that by reflex.&lt;/p&gt;
&lt;p&gt;Gradle's dependency verification exists to make unexpected artifact changes visible. A failure can mean a normal new dependency is missing from the allowlist, but a checksum mismatch can also mean the bytes are not the bytes you previously trusted. The right workflow starts by classifying the failure.&lt;/p&gt;
&lt;h3 id=&quot;first-identify-which-failure-you-actually-have&quot;&gt;First identify which failure you actually have&lt;/h3&gt;
&lt;p&gt;Two errors that look similar have very different risk profiles.&lt;/p&gt;
&lt;h4 id=&quot;missing-verification-metadata&quot;&gt;Missing verification metadata&lt;/h4&gt;
&lt;p&gt;A typical message says the artifact has no checksum in the verification metadata. That often happens after adding a new dependency, updating a version, introducing a new test or benchmark module, resolving a previously unused configuration, or changing a plugin that pulls new transitive artifacts. This is usually an expected maintenance task, but the new hashes still need review.&lt;/p&gt;
&lt;h4 id=&quot;incorrect-checksum&quot;&gt;Incorrect checksum&lt;/h4&gt;
&lt;p&gt;A more serious failure says Gradle expected one checksum but calculated another. That means the artifact identity changed. Possible explanations include a corrupted local cache, different repositories serving non-identical builds, a republished artifact, repository shadowing, or an actual compromised dependency. Gradle's current documentation explicitly treats incorrect checksums as a condition that requires manual investigation. Do not regenerate until green when the failure is a mismatch.&lt;/p&gt;
&lt;h3 id=&quot;read-the-verification-report-before-changing-metadata&quot;&gt;Read the verification report before changing metadata&lt;/h3&gt;
&lt;p&gt;Gradle produces a verification report that identifies the artifact and configuration involved. In CI, you can also enable more verbose console output when retrieving the HTML report is inconvenient.&lt;/p&gt;
&lt;p&gt;The useful debugging questions are which artifact failed, whether the checksum is missing or different, which configuration resolved it, whether that configuration was introduced by the change you expected, which repository supplied the artifact, whether the version is fixed or changing, and whether you can verify the expected bytes from an authoritative source. This prevents the common mistake of assuming the dependency declared in build.gradle is the one that failed. The failing item may be a plugin artifact, POM, metadata file, test runner, Jacoco agent, or transitive dependency.&lt;/p&gt;
&lt;h3 id=&quot;understand-why-one-change-can-produce-dozens-of-failures&quot;&gt;Understand why one change can produce dozens of failures&lt;/h3&gt;
&lt;p&gt;Dependency verification is global to the build. The same verification metadata applies across the root project, subprojects, buildSrc, plugins, and included builds. Adding one module can cause Gradle to resolve configurations that were never part of the previous baseline.&lt;/p&gt;
&lt;p&gt;For Android and Kotlin projects, these less-obvious configurations often include benchmark runtime classpaths, test fixtures, lint tooling, &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/android-code-coverage-ci-jacoco/&quot;&gt;code coverage&lt;/a&gt; agents, Kotlin compiler plugins, Gradle plugins and their metadata, and included builds. That is why adding a single module can become dozens of failed artifacts. It is not necessarily independent problems. It may be one new dependency graph that has never been recorded.&lt;/p&gt;
&lt;h3 id=&quot;generate-new-metadata-incrementally&quot;&gt;Generate new metadata incrementally&lt;/h3&gt;
&lt;p&gt;Gradle supports generating verification metadata directly. For projects using signature verification as well, use the combined flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256,pgp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Generation is incremental. Existing information can be preserved while Gradle adds newly resolved artifacts. That is better than deleting the file and rebuilding trust from scratch. A safer maintenance sequence is to keep the current verification file, reproduce the failing resolution, generate the missing metadata, inspect the Git diff, verify the new entries, rerun the build, and commit code and reviewed verification changes together.&lt;/p&gt;
&lt;h3 id=&quot;use-dry-run-output-to-inspect-broad-changes&quot;&gt;Use dry-run output to inspect broad changes&lt;/h3&gt;
&lt;p&gt;If an upgrade would add many entries, previewing the generated metadata can make review easier. Gradle documents a dry-run mode that writes a separate verification metadata file instead of replacing the main one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew --write-verification-metadata sha256 help --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Be aware of the limitation: a dry run does not execute tasks, so dependencies resolved only during task execution can be missing from the preview. That matters for Android pipelines where instrumentation, benchmark, code generation, or packaging tasks pull additional artifacts.&lt;/p&gt;
&lt;h3 id=&quot;review-generated-entries-carefully&quot;&gt;Review generated entries carefully&lt;/h3&gt;
&lt;p&gt;Generated metadata is not automatically verified truth. Gradle marks automatically produced trust information so maintainers can distinguish generated values from values they have reviewed.&lt;/p&gt;
&lt;p&gt;The review process should ask whether the group, module, and version are expected, whether it came from the repository you intended, whether the dependency change was present in the lockfile or build diff, whether the artifact is signed, whether the checksum can be compared with an authoritative distribution, and whether the update unexpectedly introduced another repository. For critical build tooling, take the time to verify the source independently before changing an entry's provenance.&lt;/p&gt;
&lt;h3 id=&quot;never-turn-a-mismatch-into-a-missing-checksum-workflow&quot;&gt;Never turn a mismatch into a missing-checksum workflow&lt;/h3&gt;
&lt;p&gt;Suppose CI reports an expected sha256 that differs from the actual sha256. Running write-verification-metadata immediately can normalize the new bytes into your trusted file and erase the warning you needed to investigate.&lt;/p&gt;
&lt;p&gt;Instead, clear only the suspect cached artifact and redownload it, confirm the repository endpoint, compare the artifact with an upstream checksum or signature when available, inspect whether the publisher legitimately republished the same version, and accept an additional checksum only when you understand why the variation exists.&lt;/p&gt;
&lt;h3 id=&quot;keep-verification-changes-reviewable-in-git&quot;&gt;Keep verification changes reviewable in Git&lt;/h3&gt;
&lt;p&gt;verification-metadata.xml belongs in version control. A good pull request makes the relationship obvious between the dependency version change, the verification metadata change for that dependency graph, and the CI verification run. Avoid bundling unrelated checksum churn with an unrelated feature.&lt;/p&gt;
&lt;h3 id=&quot;ci-needs-the-same-resolution-path&quot;&gt;CI needs the same resolution path&lt;/h3&gt;
&lt;p&gt;A local build can pass while CI still fails because the two environments resolve different tasks. Examples include CI running macrobenchmark tasks, downloading sources, enabling release variants, or resolving artifacts missing from a warm local cache. The fix is not to add random hashes until CI becomes quiet. Reproduce the exact CI task set and generate metadata through that resolution path.&lt;/p&gt;
&lt;h3 id=&quot;verification-and-execution-checklist&quot;&gt;Verification and execution checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Preserve the existing metadata file instead of deleting it entirely.&lt;/li&gt;
&lt;li&gt;Classify missing metadata versus mismatched checksum before running generators.&lt;/li&gt;
&lt;li&gt;Inspect the exact artifact and configuration from the failure report.&lt;/li&gt;
&lt;li&gt;Reproduce the failing resolution path locally using the exact build configuration.&lt;/li&gt;
&lt;li&gt;Generate missing data incrementally using the Gradle flags and review every new entry in the Git diff.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Dependency verification is supposed to interrupt you. The interruption is useful because dependencies are executable inputs to your build. When one of those inputs changes unexpectedly, a secure pipeline should make you explain why. The goal is not to make verification stop complaining, but to make the new dependency graph understood, reviewed, and reproducible.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31515/fix-gradle-dependency-verification-failures</guid>
<pubDate>Sat, 17 Oct 2026 14:45:06 +0000</pubDate>
</item>
<item>
<title>Two native apps into one React Native codebase in six months</title>
<link>https://coderlegion.com/30734/two-native-apps-into-one-react-native-codebase-in-six-months</link>
<description>&lt;p&gt;I owned mobile for Pulse, an offline-first inspection app for auditors, on Android and iOS, from an early prototype to 100+ B2B enterprise clients. For most of that time it was two native apps: Kotlin on Android, Swift on iOS, kept in feature parity. In 2025 I led a full rewrite of both into a single React Native codebase. I architected it and built it alongside the team, and we completed it in six months, before I left in May 2025.&lt;/p&gt;
&lt;p&gt;This isn’t a “native versus React Native” post. The native apps worked. The earlier posts onPulse’s sync engine and ona local-only app cover why they were built the way they were. This one is about the rewrite: why we did it, what we kept, and how it reached users.&lt;/p&gt;
&lt;p&gt;Why rewrite two apps that worked&lt;br&gt;
Keeping two native apps in feature parity has a simple cost: every feature is built twice, once in Kotlin and once in Swift, and every fix has to land in both. That was the reason for the rewrite, in two parts:&lt;/p&gt;
&lt;p&gt;One codebase to maintain instead of two.&lt;br&gt;
Shipping faster , because a feature or a fix reaches Android and iOS at the same time.&lt;br&gt;
Neither reason says the native apps were bad. They say that keeping two of them had a cost, and one codebase removes it.&lt;/p&gt;
&lt;p&gt;Replicate the behaviour, don’t redesign it&lt;br&gt;
The native apps’ behaviour had years of field use behind it: records that get a temporary local ID and are remapped to a server ID when they sync, photos uploaded before the form that references them, upload progress the auditor can see, and inspection forms built dynamically. So the React Native app replicated that behaviour rather than redesigning it.&lt;/p&gt;
&lt;p&gt;The alternative is harder than it looks. A rewrite that also changes how sync works is two projects at once, and every difference between the old app and the new one is something to explain to an auditor in the field. Replicating proven behaviour keeps the rewrite to one question: does the new app do what the old one did?&lt;/p&gt;
&lt;p&gt;I built the core, the team built on top&lt;br&gt;
I set the architecture and built the core myself: sync, offline storage and uploads. The team built the screens and features on top of it.&lt;/p&gt;
&lt;p&gt;In an offline-first app, that core is where the risk sits. Everything on screen depends on the core behaving the way the native apps did, and in an offline-first app the core is where data gets lost if something is wrong. Screens are easier to see, test and fix; a sync bug can hide until an auditor’s work goes missing.&lt;/p&gt;
&lt;p&gt;Rolling out gradually&lt;br&gt;
The new app didn’t replace the native ones for everyone at once. It rolled out gradually, with the native app still available while users moved over.&lt;/p&gt;
&lt;p&gt;For an offline-first app that isn’t caution for its own sake. Auditors work without signal, so at any moment someone may have work on the phone that hasn’t reached the server yet. A gradual rollout means a problem in the new app reaches a few users rather than all of them, and there is still a working app to fall back on while it’s fixed.&lt;/p&gt;
&lt;p&gt;What I’d tell another team&lt;br&gt;
If you’re rewriting an offline-first app, roll it out gradually. Keep the old app available and move users over in stages. A big-bang switch bets every auditor’s unsynced work on the new app being right on day one.&lt;/p&gt;
&lt;p&gt;More on Pulse is in the case study.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30734/two-native-apps-into-one-react-native-codebase-in-six-months</guid>
<pubDate>Sun, 11 Oct 2026 18:35:56 +0000</pubDate>
</item>
<item>
<title>VPN Configuration Permission: Check Consent Separately from Connection State</title>
<link>https://coderlegion.com/30691/vpn-configuration-permission-check-consent-separately-from-connection-state</link>
<description>&lt;p&gt;A VPN configuration prompt is a system permission decision. This guide separates that decision from profile import and connection state, including why switching VPN apps on Android can bring the prompt back.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Adapted with permission from &lt;a rel=&quot;nofollow&quot; href=&quot;https://lisar.io/articles/vpn-client-add-configuration-permission&quot;&gt;the original Lisar article&lt;/a&gt; by Mohammad Hesameddin Montazerilisar, published July 10, 2026 and updated September 25, 2026. AI assisted editing, platform framing, and fact-checking; the substantive source guidance is human-authored. No device test was performed for this article.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;AI-generated conceptual illustration: system permission and a working VPN connection are separate checks. This is not a product or operating-system screenshot.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A VPN client cannot establish a system-level VPN connection without permission from the operating system. That is why a phone, tablet, or computer may display a prompt asking whether the client can add a VPN configuration.&lt;/p&gt;
&lt;p&gt;The wording can sound more serious than the action feels. In ordinary use, the prompt is the operating system asking whether the client may create and manage the connection entry that you are trying to set up. It is not, by itself, confirmation that the profile is correct, that access is active, or that a connection has been established.&lt;/p&gt;
&lt;h2 id=&quot;what-the-permission-prompt-is-asking&quot;&gt;What the permission prompt is asking&lt;/h2&gt;
&lt;p&gt;A VPN connection affects how network traffic leaves the device. Because that happens at the operating-system level, the client needs explicit permission to create a VPN configuration.&lt;/p&gt;
&lt;p&gt;Depending on the device, the prompt may refer to adding a VPN configuration, allowing a VPN connection, or permitting the client to manage VPN settings. The exact words and screen design vary between operating systems and client versions.&lt;/p&gt;
&lt;p&gt;This is different from a normal website permission. A browser page cannot create the system connection on its own. The compatible VPN client handles the imported or saved profile, while the operating system controls whether that client may create the VPN connection.&lt;/p&gt;
&lt;h2 id=&quot;why-the-operating-system-shows-it&quot;&gt;Why the operating system shows it&lt;/h2&gt;
&lt;p&gt;The prompt creates a clear boundary between installing or opening a client and allowing that client to affect network routing on the device. It gives the device owner a chance to confirm that the action was intentional.&lt;/p&gt;
&lt;p&gt;You will commonly see it during first-time setup, after installing a client on a new device, or when the operating system needs fresh confirmation after a material software change. You may not see it for every later connection because the permission can remain associated with the approved client and configuration.&lt;/p&gt;
&lt;p&gt;The absence of a new prompt does not prove that the profile is current. It only means the operating system did not need to ask for that permission again at that moment.&lt;/p&gt;
&lt;h2 id=&quot;what-to-verify-before-approving&quot;&gt;What to verify before approving&lt;/h2&gt;
&lt;p&gt;Use a short check rather than treating the prompt as a routine button:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm the client.&lt;/strong&gt; The application named in the prompt should be the compatible VPN client you intentionally opened.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm the source.&lt;/strong&gt; The profile should have come from a trusted and expected source. Do not use a profile passed around casually.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm the device.&lt;/strong&gt; Personal devices and organization-managed devices may follow different rules.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm the action.&lt;/strong&gt; The prompt should follow a setup action or a connection attempt you intentionally started. A new import is not required every time permission is requested.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm ownership.&lt;/strong&gt; The profile should be assigned or otherwise authorized for your use.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There is no need to open sensitive parts of a profile file to make this decision. The useful checks are source, assignment, client, device, and the action you intentionally started.&lt;/p&gt;
&lt;h2 id=&quot;why-android-may-ask-again-after-another-vpn-app&quot;&gt;Why Android may ask again after another VPN app&lt;/h2&gt;
&lt;p&gt;Android allows one active VPN service per user or work profile. Starting a different VPN app can replace the current VPN connection. Allowing that other app can also revoke the previous app’s system approval, so returning to your usual client may bring up the permission request again. See the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.android.com/develop/connectivity/vpn&quot;&gt;Android VPN guide&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.android.com/reference/android/net/VpnService&quot;&gt;VpnService permission reference&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The request can appear when you tap Connect, even if the profile is already imported. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/android-installation-guide.html&quot;&gt;OpenVPN Connect Android guide&lt;/a&gt; describes granting permission at that step. A repeated request alone does not show that your profile is damaged; it is not a reason to re-import the file or reinstall the client.&lt;/p&gt;
&lt;p&gt;Confirm the app named in the prompt and the connection you intended to start. On a managed device, follow the administrator’s VPN policy. After granting permission, check the client’s connection status separately.&lt;/p&gt;
&lt;h2 id=&quot;what-happens-if-you-decline&quot;&gt;What happens if you decline&lt;/h2&gt;
&lt;p&gt;If you decline, the client normally cannot create the requested system VPN configuration. The profile might still appear in the client, or the setup may remain incomplete, depending on the operating system and client.&lt;/p&gt;
&lt;p&gt;If you are uncertain, pause or decline and verify the source before continuing. The requested configuration remains unapproved; network availability may depend on the device policy already in force.&lt;/p&gt;
&lt;p&gt;When you return to the setup flow after verifying everything, the operating system may show the prompt again. The exact behavior depends on the device and client.&lt;/p&gt;
&lt;h2 id=&quot;managed-devices-and-organization-policies&quot;&gt;Managed devices and organization policies&lt;/h2&gt;
&lt;p&gt;A company-managed phone or computer may restrict who can add VPN configurations. It may also require a specific client, profile, or approval process.&lt;/p&gt;
&lt;p&gt;Do not try to work around those controls. If the device blocks the action or the prompt differs from the documented setup, check with the person responsible for the device or network access. A valid profile does not override device-management policy.&lt;/p&gt;
&lt;p&gt;This is one reason setup can differ between a personal device and an organization-managed device even when both use the same general profile type.&lt;/p&gt;
&lt;h2 id=&quot;permission-granted-does-not-mean-connected&quot;&gt;Permission granted does not mean connected&lt;/h2&gt;
&lt;p&gt;Approving the system prompt completes only one part of setup. Several separate states are easy to confuse:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The client is installed.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The profile is imported or saved.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The operating system has allowed a VPN configuration.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The client has attempted to connect.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The connection is active.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A successful permission step does not prove the last two states. After setup, use the client status and the normal first-connection checks to confirm whether the connection is actually active.&lt;/p&gt;
&lt;h2 id=&quot;frequently-asked-questions&quot;&gt;Frequently asked questions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Why did I see the prompt on one device but not another?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Operating systems, client versions, prior permissions, and device-management rules can produce different setup flows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can the request appear when I tap Connect?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Yes. On Android, a client may ask for system VPN permission when you start a connection with an already imported profile. Granting permission does not by itself confirm an active connection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why does Android ask again after I use another VPN app?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Allowing another VPN app can revoke the previous app’s approval. When you return to the earlier client, Android may ask for permission again. This alone does not mean the saved profile is damaged.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Original author: Mohammad Hesameddin Montazerilisar, technical author for Lisar Connect and Manager of MONTAZERI COMPUTERS &amp;amp; REQUISITES TRADING CO. L.L.C, which develops and operates Lisar Connect.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30691/vpn-configuration-permission-check-consent-separately-from-connection-state</guid>
<pubDate>Sun, 11 Oct 2026 08:17:48 +0000</pubDate>
</item>
<item>
<title>A Post-Restart VPN Checklist: Network Readiness, Client State, and Android Startup Settings</title>
<link>https://coderlegion.com/30569/post-restart-vpn-checklist-network-readiness-client-state-and-android-startup-settings</link>
<description>&lt;p&gt;This checklist explains how to verify a VPN after a device restart, including network readiness and the difference between client launch settings and Android traffic-blocking policy.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Adapted with permission from &lt;a rel=&quot;nofollow&quot; href=&quot;https://lisar.io/articles/vpn-after-device-restart&quot;&gt;the original Lisar article&lt;/a&gt; by Mohammad Hesameddin Montazerilisar, published July 10, 2026 and updated September 26, 2026. AI assisted editing, platform framing, and fact-checking; the substantive source guidance is human-authored. No connection test was performed for this article.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Restarting a phone, tablet, or computer ends active processes and network sessions. A VPN profile may remain saved in its client, but the connection that existed before the restart should not be assumed to remain active afterward.&lt;/p&gt;
&lt;p&gt;The useful distinction is between &lt;strong&gt;stored configuration&lt;/strong&gt; and &lt;strong&gt;current connection state&lt;/strong&gt;. A restart can leave one intact while resetting the other.&lt;/p&gt;
&lt;h2 id=&quot;what-survives-a-restart-and-what-does-not&quot;&gt;What survives a restart and what does not&lt;/h2&gt;
&lt;p&gt;A saved profile is configuration information stored by the client. An active VPN session is a live network state. Most devices preserve the client and its profiles through a normal restart, but they stop the active session while the operating system shuts down.&lt;/p&gt;
&lt;p&gt;After startup, the client may:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;remain disconnected until you open it;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;reconnect automatically when the operating system and network are ready;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;display the saved profile but require a new Connect action;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ask for permission again after a major update or policy change;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;report an error because the underlying network is not ready yet.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These behaviors vary. The presence of a profile entry does not prove an active connection, and the absence of an automatic reconnection does not by itself mean the profile is damaged.&lt;/p&gt;
&lt;h2 id=&quot;wait-for-the-normal-network-to-become-usable&quot;&gt;Wait for the normal network to become usable&lt;/h2&gt;
&lt;p&gt;After startup, Wi-Fi and mobile services may take time to reconnect. A guest or public network may require a sign-in page again. A laptop can also reconnect to a different remembered network than the one used before shutdown.&lt;/p&gt;
&lt;p&gt;Check network readiness together with any VPN policy already applied:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the expected Wi-Fi or mobile connection is active;&lt;/li&gt;
&lt;li&gt;ordinary internet access works where device policy permits it; if non-VPN traffic is blocked, check the client status instead of treating this test as proof of a network failure;&lt;/li&gt;
&lt;li&gt;any permitted captive-portal sign-in is complete, or the network administrator has provided an approved way to finish it;&lt;/li&gt;
&lt;li&gt;the device is not still applying startup updates or network policy.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Starting several VPN attempts while the underlying network is changing can produce timeouts or duplicate status messages that disappear once the device is fully ready.&lt;/p&gt;
&lt;h2 id=&quot;android-startup-and-traffic-blocking-are-different-settings&quot;&gt;Android: startup and traffic blocking are different settings&lt;/h2&gt;
&lt;p&gt;On a compatible Android client, &lt;strong&gt;Always-on VPN&lt;/strong&gt; can start the VPN service after boot. The client still has to establish its connection. The separate &lt;strong&gt;Block connections without VPN&lt;/strong&gt; option can prevent ordinary internet access until that happens. A failed browsing test in this state does not by itself show that the saved profile is damaged. Availability depends on the client and device configuration; see the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.android.com/develop/connectivity/vpn&quot;&gt;Android VPN documentation&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;OpenVPN Connect has its own &lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/app-settings-android.html&quot;&gt;Android launch settings&lt;/a&gt;: &lt;strong&gt;Connect latest&lt;/strong&gt; tries the most recently connected profile, &lt;strong&gt;Restore connection&lt;/strong&gt; tries to restore the connection active before shutdown, and &lt;strong&gt;None&lt;/strong&gt; takes no action after reboot. Its &lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/tutorials/tutorial--enable-always-on-vpn-in-openvpn-connect-on-android.html&quot;&gt;Always-on tutorial&lt;/a&gt; covers the separate system option. These examples describe OpenVPN Connect; check the documentation for the client you actually use.&lt;/p&gt;
&lt;p&gt;Read the current client status and any system notification before deciding what failed. On a managed device, ask the administrator for an approved network test or sign-in path; do not disable a required VPN or security policy.&lt;/p&gt;
&lt;h2 id=&quot;confirm-time-and-note-what-changed&quot;&gt;Confirm time and note what changed&lt;/h2&gt;
&lt;p&gt;Check that the system's actual date and time are correct. A wrong system clock can interfere with connection validation; a different time-zone display alone does not establish that problem.&lt;/p&gt;
&lt;p&gt;Also record whether the restart followed an operating-system or client update, a reinstall, a device-management change, a restore, or a network reset. Those events can change permissions or client behavior, but the timing alone does not prove the cause.&lt;/p&gt;
&lt;h2 id=&quot;open-the-intended-client-and-profile&quot;&gt;Open the intended client and profile&lt;/h2&gt;
&lt;p&gt;Confirm that the expected client starts normally and that the intended current profile is visible. If several similarly named profiles exist, compare the approved label, assignment, or delivery date instead of choosing the first one.&lt;/p&gt;
&lt;p&gt;A common mistake is to download the file again after every restart. That is usually unnecessary when the current profile is already saved. Repeated import attempts can create duplicate client entries and make it harder to know which one was tested.&lt;/p&gt;
&lt;p&gt;Do not inspect, copy, or share sensitive profile contents to identify the file. Use the authorized source, visible name, revision label, user or device assignment, and service status.&lt;/p&gt;
&lt;h2 id=&quot;handle-permission-prompts-carefully&quot;&gt;Handle permission prompts carefully&lt;/h2&gt;
&lt;p&gt;A system may ask again for permission to add or use a VPN configuration after a major update, client reinstall, device migration, or policy change. Confirm that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;you intentionally opened the expected client;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the prompt names that client;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the profile came from the approved source;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;you are allowed to add the configuration on this device.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On an organization-managed device, do not work around a blocked or altered prompt. Follow the approved device policy and contact the responsible administrator.&lt;/p&gt;
&lt;h2 id=&quot;recheck-connection-status-instead-of-assuming&quot;&gt;Recheck connection status instead of assuming&lt;/h2&gt;
&lt;p&gt;After the client is ready, distinguish these states:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;profile is visible;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;profile is selected;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;client is Connecting;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;client reports Connected;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ordinary internet and expected services work while connected.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A familiar system icon from before the restart is not reliable evidence. Open the client or operating-system VPN status and confirm the current state.&lt;/p&gt;
&lt;p&gt;If the client reports Connected, use the normal first-connection verification steps documented for the service.&lt;/p&gt;
&lt;h2 id=&quot;avoid-duplicate-setup-attempts&quot;&gt;Avoid duplicate setup attempts&lt;/h2&gt;
&lt;p&gt;When the first attempt fails after startup, preserve the result before changing anything. Record the visible status, timing, network, client version, and whether the restart followed an update. Then compare one condition, such as waiting for the network to stabilize or trying another authorized network.&lt;/p&gt;
&lt;p&gt;Avoid:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;repeatedly importing the same file;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;deleting profiles without confirming which revision is current;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;manually editing &lt;code&gt;.ovpn&lt;/code&gt; file contents;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;changing router, firewall, or device-security settings;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;using another person’s profile;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;assuming a reinstall is always required.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;frequently-asked-questions-1&quot;&gt;Frequently asked questions&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Do I need to import the profile after every restart?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Normally not when the current profile remains saved in the client. Reimport only when the documented setup or access owner requires a new file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can the VPN reconnect automatically after startup?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It depends on the client and approved settings. For example, OpenVPN Connect on Android documents launch choices for the last-used connection and for restoring a previously active connection. An automatic attempt is not proof that the VPN connected.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why can internet access be unavailable until the VPN connects?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If Android is configured to block connections without VPN, ordinary traffic can remain unavailable while the client connects. Check the active policy and client status. On a managed device, ask the administrator for an approved test instead of disabling the policy.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Original author: Mohammad Hesameddin Montazerilisar, technical author for Lisar Connect and Manager of MONTAZERI COMPUTERS &amp;amp; REQUISITES TRADING CO. L.L.C, which develops and operates Lisar Connect.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30569/post-restart-vpn-checklist-network-readiness-client-state-and-android-startup-settings</guid>
<pubDate>Sat, 10 Oct 2026 08:14:32 +0000</pubDate>
</item>
<item>
<title>How AI Agents Control Smartphones</title>
<link>https://coderlegion.com/30503/how-ai-agents-control-smartphones</link>
<description>&lt;h2 id=&quot;observation-and-input-are-separate-channels-what-device-control-actually-requires&quot;&gt;Observation and input are separate channels: what device control actually requires&lt;/h2&gt;
&lt;p&gt;Fifth post from the Aiden team (physical AI agent device, USB-connected, open source firmware). This one is less about a specific implementation detail and more about a category of confusion I keep running into in discussions about agents that operate real devices.&lt;/p&gt;
&lt;h3 id=&quot;the-confusion&quot;&gt;The confusion&lt;/h3&gt;
&lt;p&gt;&quot;The agent controls the phone&quot; gets treated as one capability. It's at least two, and conflating them causes architectural problems that don't show up until you're debugging a failed task.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An input channel is not an observation channel.&lt;/strong&gt; A compatible external peripheral can deliver taps and keystrokes while providing zero screen visibility. A capture path can provide images while enabling no input at all. Sending a keystroke does not establish where it landed or whether the application accepted it.&lt;/p&gt;
&lt;p&gt;This sounds obvious written down. In practice a lot of designs assume that because the system can see a button and can send a click, it knows the click hit the button. It doesn't. Those are two independent facts and you need evidence for the second one.&lt;/p&gt;
&lt;h3 id=&quot;what-each-observation-method-actually-gives-you&quot;&gt;What each observation method actually gives you&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Method &lt;/th&gt;&lt;th&gt;Provides &lt;/th&gt;&lt;th&gt;Blind spot&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Screenshot &lt;/td&gt;&lt;td&gt;Rendered screen at a moment &lt;/td&gt;&lt;td&gt;Whether a control is enabled, selected, actionable&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OCR &lt;/td&gt;&lt;td&gt;Recognized text, sometimes position &lt;/td&gt;&lt;td&gt;Text doesn't establish control behavior&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;UI hierarchy &lt;/td&gt;&lt;td&gt;Elements, labels, bounds, states &lt;/td&gt;&lt;td&gt;Coverage varies hugely by app and access path&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;App-provided state &lt;/td&gt;&lt;td&gt;Structured integration data &lt;/td&gt;&lt;td&gt;Only what the developer chose to expose&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Custom-rendered controls are the recurring problem — they can look perfectly clear visually while exposing essentially nothing structurally. Hybrid designs use structured state where it exists and visual interpretation elsewhere, but then you inherit a new problem: what to do when the two sources disagree.&lt;/p&gt;
&lt;h3 id=&quot;grounding-is-a-separate-capability-from-perception&quot;&gt;Grounding is a separate capability from perception&lt;/h3&gt;
&lt;p&gt;Recognizing a button, selecting the &lt;em&gt;correct&lt;/em&gt; button, and activating it are three achievements, not one. &quot;Open the second result&quot; has to resolve against the screen as it exists now, not a frame captured two seconds ago before something reflowed. A coordinate can point precisely at the wrong control.&lt;/p&gt;
&lt;h3 id=&quot;the-verification-point-i-d-most-like-feedback-on&quot;&gt;The verification point I'd most like feedback on&lt;/h3&gt;
&lt;p&gt;The success criterion for a task should be a postcondition, not an issued command. Save an article into a note: success is &quot;exactly one note exists in the intended destination with the correct content,&quot; not &quot;the save button was tapped.&quot;&lt;/p&gt;
&lt;p&gt;This changes retry behavior in a way that matters. If an app appears to stall mid-save, tapping again is not safe recovery — the first operation may have completed, and you've now created a duplicate. Correct move is to inspect the destination before retrying.&lt;/p&gt;
&lt;p&gt;That works when there's a queryable end state. &lt;strong&gt;A lot of mobile workflows don't give you one&lt;/strong&gt;, and that's the case I'm least confident we've handled well. If you've built anything that operates interfaces you don't control, how do you deal with an action that may or may not have landed when there's no reliable way to check?&lt;/p&gt;
&lt;h3 id=&quot;one-thing-that-isn-t-an-implementation-detail&quot;&gt;One thing that isn't an implementation detail&lt;/h3&gt;
&lt;p&gt;An agent working through real interfaces reads text from webpages, documents, and app content. Some of that text will contain instructions. Screen content is task data, not permission to change the goal. Enforcing allowed actions outside the model adds a boundary but doesn't establish complete protection.&lt;/p&gt;
&lt;h3 id=&quot;platform-notes-since-they-come-up&quot;&gt;Platform notes, since they come up&lt;/h3&gt;
&lt;p&gt;Android exposes Accessibility services, MediaProjection (session-consent restrictions for apps targeting 14+), UI Automator, ADB, and Intents. Worth separating two questions that get merged: API capability and distribution policy. Google Play's AccessibilityService policy restricts autonomous planning and execution through that API while distinguishing deterministic automation and qualifying accessibility tools — user consent doesn't settle eligibility.&lt;/p&gt;
&lt;p&gt;iOS documents App Intents/Shortcuts, XCTest/XCUIAutomation, and AssistiveTouch. None of them is a general device-control grant.&lt;/p&gt;
&lt;p&gt;Full writeup with the evaluation framework and the Android/iPhone comparison table: &lt;a rel=&quot;nofollow&quot; href=&quot;https://aidenai.io/blog/how-ai-agents-control-smartphones/&quot;&gt;https://aidenai.io/blog/how-ai-agents-control-smartphones/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Firmware is open source if you want to look at how we've structured the capture/input split: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/AidenAI-IO/aiden-firmware&quot;&gt;https://github.com/AidenAI-IO/aiden-firmware&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Dev-board stage, not validated across every device and app combination, and we're honest about that in the article too.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30503/how-ai-agents-control-smartphones</guid>
<pubDate>Fri, 09 Oct 2026 17:51:27 +0000</pubDate>
</item>
<item>
<title>Safe Public Demo Environments for Android Apps</title>
<link>https://coderlegion.com/30332/safe-public-demo-environments-for-android-apps</link>
<description>&lt;p&gt;Offering a public demo of an Android application presents a unique architectural challenge. A demo build is exposed to strangers and needs frequent, destructive data resets, yet it must never be able to target or corrupt the real customer database by accident. Relying on a simple runtime UI flag or environment toggle inside the production codebase creates an unacceptable risk of accidental data leakage. This article examines how to build a robust isolation boundary using separate build variants, dedicated demo backends, and fail-closed identity validation.&lt;/p&gt;
&lt;h3 id=&quot;why-a-demo-toggle-fails-as-an-isolation-boundary&quot;&gt;Why a Demo Toggle Fails as an Isolation Boundary&lt;/h3&gt;
&lt;p&gt;Many development teams initially attempt to support public demonstrations by adding a toggle within the primary application and backend. The logic often checks whether a demo mode flag is active before skipping certain write operations or routing requests to mock data. This approach fails because a single code path or misconfigured deployment can easily bypass the flag. If a build script accidentally packages the production credentials or connects the demo backend to the primary database, a public reset operation can wipe out real customer records.&lt;/p&gt;
&lt;p&gt;True isolation requires separating the trust boundary at every layer of the architecture. The application package name, the backend service instance, and the database project reference must be distinct. If any component in the chain does not match the expected demo configuration, the service should refuse to start entirely rather than attempting to recover or fallback.&lt;/p&gt;
&lt;h3 id=&quot;separate-android-build-variants-and-app-identity&quot;&gt;Separate Android Build Variants and App Identity&lt;/h3&gt;
&lt;p&gt;The first layer of defense begins in the Android client. Using build variants allows developers to maintain different application IDs, source sets, and configuration values within the same repository. By modifying the application ID for the demo variant, the operating system treats the demo build and the production build as entirely separate apps on the test device.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;android {
    defaultConfig {
        applicationId &quot;com.example.app&quot;
    }
    buildTypes {
        release {
            minifyEnabled true
        }
    }
    flavorDimensions &quot;mode&quot;
    productFlavors {
        production {
            dimension &quot;mode&quot;
            applicationIdSuffix &quot;&quot;
        }
        demo {
            dimension &quot;mode&quot;
            applicationIdSuffix &quot;.demo&quot;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This configuration ensures that installation artifacts are distinct. More importantly, keeping the choice fixed in the build artifact prevents users or automated scripts from accidentally activating a demo mode inside a production binary.&lt;/p&gt;
&lt;h3 id=&quot;verify-backend-and-database-identity&quot;&gt;Verify Backend and Database Identity&lt;/h3&gt;
&lt;p&gt;Client-side separation alone is insufficient if the backend can connect to any database via environment variables. A dedicated demo backend must require explicit configuration flags and perform strict startup validation. During initialization, the backend should compare the database project reference derived from its active connection string against an expected demo project reference.&lt;/p&gt;
&lt;p&gt;If the strings do not match, the application must immediately throw an unhandled exception and shut down. This fail-closed mechanism ensures that a server misconfiguration becomes immediately visible during deployment rather than remaining a latent vulnerability.&lt;/p&gt;
&lt;h3 id=&quot;seed-and-reset-guarded-disposable-data&quot;&gt;Seed and Reset Guarded Disposable Data&lt;/h3&gt;
&lt;p&gt;Public demo environments require a mechanism to restore clean state after users alter or delete records. However, this reset mechanism must be guarded against accidental execution against live systems. Implementation evidence suggests a two-step validation model for data lifecycle management:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Initial seeding should only proceed if every demo table is entirely empty. If existing records are detected, the seed routine halts.&lt;/li&gt;
&lt;li&gt;Subsequent resets require a persisted security sentinel string to match the configured sentinel, and the database identity must be re-verified immediately before any deletion takes place.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Furthermore, developers should strip all non-essential integrations from the demo service. Production push notification credentials, third-party payment hooks, and external spreadsheet integrations should be omitted so that a compromised or heavily tested demo environment cannot trigger external side effects.&lt;/p&gt;
&lt;h3 id=&quot;what-process-local-locks-do-not-protect&quot;&gt;What Process-Local Locks Do Not Protect&lt;/h3&gt;
&lt;p&gt;When implementing automated reset endpoints, developers frequently reach for concurrency primitives such as a language-specific mutex to prevent overlapping reset requests. While a process-local mutex successfully serializes concurrent coroutines or threads within a single backend instance, it offers no protection in a horizontally scaled architecture. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/managing-concurrent-git-commits-during-automated-publishing/&quot;&gt;Managing Concurrent Git Commits During Automated&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If the demo backend is deployed behind a load balancer with multiple active instances, each instance maintains its own memory space and its own mutex. Two distinct server processes could execute a database reset simultaneously. Protecting a multi-instance demo environment requires database-level locking, a dedicated single-instance worker queue, or another distributed coordination mechanism. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/safe-multi-environment-database-orchestration/&quot;&gt;Safe Multi Environment Database Orchestration&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;release-checklist-for-a-public-demo&quot;&gt;Release Checklist for a Public Demo&lt;/h3&gt;
&lt;p&gt;Before launching a public demo environment, verify the following architectural controls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The demo application variant uses a distinct application ID suffix.&lt;/li&gt;
&lt;li&gt;The backend enforces explicit demo mode flags and fails startup if the database project reference does not match.&lt;/li&gt;
&lt;li&gt;Initial data seeding refuses to run unless all target tables are empty.&lt;/li&gt;
&lt;li&gt;Destructive reset operations require a valid persisted sentinel check and a pre-execution database identity re-validation.&lt;/li&gt;
&lt;li&gt;Session secrets and sentinels meet minimum length requirements.&lt;/li&gt;
&lt;li&gt;Non-essential production integrations and credentials are completely removed from the demo service deployment.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30332/safe-public-demo-environments-for-android-apps</guid>
<pubDate>Thu, 08 Oct 2026 23:37:04 +0000</pubDate>
</item>
<item>
<title>Preview Cross-System Data Changes Before Sync</title>
<link>https://coderlegion.com/30272/preview-cross-system-data-changes-before-sync</link>
<description>&lt;p&gt;A sync button can hide a destructive decision. When a spreadsheet and a backend database disagree because rows were added, removed, edited, duplicated, or marked for deletion, starting an automatic write without checking the shape of those differences risks corrupting data. Users need to know whether the selected source of truth is safe before any changes are applied.&lt;/p&gt;
&lt;p&gt;To solve this, a preflight check must calculate a read-only diff first, require one explicit source of truth, block ambiguous keys, and verify the sync by recalculating the diff afterward. This guide explores how to build a reliable preflight check for cross-system data integration using aggregate difference counts and directional guards.&lt;/p&gt;
&lt;h3 id=&quot;why-a-sync-button-needs-a-preflight&quot;&gt;Why a Sync Button Needs a Preflight&lt;/h3&gt;
&lt;p&gt;Automatic synchronization assumes that both sides agree on identifiers and state. In practice, networks fail, users edit spreadsheets offline, and backend records are updated concurrently. If an application blindly pushes or pulls without an inspection phase, it can overwrite valid updates or propagate duplicate keys.&lt;/p&gt;
&lt;p&gt;A preflight stage separates observation from mutation. By inspecting both sides without writing, the system computes an aggregate preview of the state gap. This gives operators visibility into what will change before they commit to an action.&lt;/p&gt;
&lt;h3 id=&quot;stable-keys-and-row-signatures&quot;&gt;Stable Keys and Row Signatures&lt;/h3&gt;
&lt;p&gt;Comparing data across systems requires reliable identification. Row positions in a spreadsheet are notoriously fragile because sorting or inserting a row shifts every subsequent index. Integrations must rely on stable business keys rather than physical row numbers.&lt;/p&gt;
&lt;p&gt;In addition to stable keys, systems need canonical signatures to detect when shared rows have changed. A signature can be a cryptographic hash or a concatenated string of critical fields. If the key matches between &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/google-sheets-android-backend/&quot;&gt;Google Sheets&lt;/a&gt; and the database, but the signature differs, the record has been modified.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;stable_key&quot;: &quot;user_10293&quot;,
  &quot;sheets_signature&quot;: &quot;a1b2c3d4&quot;,
  &quot;db_signature&quot;: &quot;e5f6g7h8&quot;,
  &quot;status&quot;: &quot;changed&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;classifying-missing-changed-duplicate-and-deleted-rows&quot;&gt;Classifying Missing, Changed, Duplicate, and Deleted Rows&lt;/h3&gt;
&lt;p&gt;A comprehensive preview categorizes discrepancies into distinct buckets so users can assess the scope of a sync operation. The underlying service should evaluate several entity groups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keys that exist only in the spreadsheet.&lt;/li&gt;
&lt;li&gt;Keys that exist only in the database.&lt;/li&gt;
&lt;li&gt;Shared keys whose canonical signatures differ.&lt;/li&gt;
&lt;li&gt;Duplicate keys appearing on either side.&lt;/li&gt;
&lt;li&gt;Pending deletions flagged by business logic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This implementation evidence relies on aggregate counts rather than a field-by-field diff. The preview informs the user how many records are affected, but it does not attempt to display every mutated attribute in a complex UI.&lt;/p&gt;
&lt;h3 id=&quot;choosing-a-single-source-of-truth&quot;&gt;Choosing a Single Source of Truth&lt;/h3&gt;
&lt;p&gt;When systems diverge, automatic two-way merging often fails because conflict resolution requires domain context that automated scripts lack. If duplicate keys or changed common rows are present, a two-way mode becomes ambiguous and should be rejected.&lt;/p&gt;
&lt;p&gt;Instead, the integration must force an explicit choice. The user or operator selects either the spreadsheet or the application database as the authoritative source of truth for that run. Once an authority is chosen, the sync operation flows in a single direction, eliminating merge conflicts.&lt;/p&gt;
&lt;h3 id=&quot;confirming-safely-and-detecting-stale-previews&quot;&gt;Confirming Safely and Detecting Stale Previews&lt;/h3&gt;
&lt;p&gt;A safe run requires an active preview and refuses concurrent execution. If another sync is already running, or if the preview has not been generated, the system blocks the write request.&lt;/p&gt;
&lt;p&gt;Process memory often holds these preview objects for simplicity. However, keeping previews in memory introduces a race condition if the underlying data changes between the time the preview is generated and the time the user clicks confirm. A production integration must expire previews after a short duration or bind them to source fingerprints, rejecting stale plans to prevent unintended overwrites.&lt;/p&gt;
&lt;h3 id=&quot;post-sync-convergence-checks&quot;&gt;Post-Sync Convergence Checks&lt;/h3&gt;
&lt;p&gt;Applying a sync direction is only half the workflow. To ensure the operation succeeded, the service must immediately recompute the differences after the write completes.&lt;/p&gt;
&lt;p&gt;If the new preview shows zero discrepancies, the system reports full convergence. If differences remain due to validation errors or partial failures, the service reports a partial result rather than assuming success.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Cross-system synchronization requires discipline to prevent data loss. By inspecting changes in a read-only preflight, relying on stable keys, blocking ambiguous states, and verifying convergence after execution, applications can bridge external tools like spreadsheets and internal databases safely.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30272/preview-cross-system-data-changes-before-sync</guid>
<pubDate>Thu, 08 Oct 2026 12:04:12 +0000</pubDate>
</item>
<item>
<title>Handling a Cold-Starting Android Backend</title>
<link>https://coderlegion.com/30209/handling-a-cold-starting-android-backend</link>
<description>&lt;p&gt;When an application backend sleeps between requests or takes time to start, an Android app can mistake a temporary cold start for a permanent outage. The opposite failure is also common: a loading spinner retries forever and gives the user no clear way forward. How should an Android app handle an unavailable or cold-starting backend without showing an infinite startup spinner? For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/android-viewmodel-partial-success-refresh-failure/&quot;&gt;Viewmodel Partial Success Refresh Failure&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The solution is to treat startup health checks as a finite state machine rather than an endless loop. By performing a single pass across configured endpoint candidates during launch, the application can quickly determine if the service is reachable. If the backend fails to respond, the app moves to an explicit unavailable state instead of locking the user into a permanent loading loop. When the user chooses to try again, an explicit retry action refreshes the remote configuration and checks again on a fixed interval, bounded by a visible deadline.&lt;/p&gt;
&lt;h3 id=&quot;the-startup-state-machine-and-endpoint-checks&quot;&gt;The Startup State Machine and Endpoint Checks&lt;/h3&gt;
&lt;p&gt;A robust mobile architecture separates initial startup discovery from active user sessions. During the first launch phase, the ViewModel checks configured base URLs once and selects the first healthy endpoint. If none of the candidates respond within the initial pass, the UI exposes a distinct unavailable state. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/how-to-build-robust-viewmodel-unit-tests/&quot;&gt;To Robust Viewmodel Unit Tests&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This design prevents wasted network traffic and gives the user immediate clarity. Instead of guessing whether the application is frozen or broken, the user sees that the backend is currently offline. The application can distinguish between ordinary unavailability, maintenance modes, and configuration mismatches by evaluating response codes and payload flags.&lt;/p&gt;
&lt;h3 id=&quot;bounded-retry-with-cancellation&quot;&gt;Bounded Retry with Cancellation&lt;/h3&gt;
&lt;p&gt;When a cold-starting backend requires a moment to wake up, a manual retry mechanism gives users control without risking runaway resource consumption. The implementation uses a fixed two-second interval and a 75-second total deadline. These specific values correspond to the backend cold-start profile and are not universal defaults.&lt;/p&gt;
&lt;p&gt;To prevent overlapping network calls, every new check cancels the previous coroutine job. This cancellation guarantee ensures that if a user triggers multiple retries or if configuration changes mid-check, obsolete requests do not continue running in the background.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class BackendRetryViewModel : ViewModel() {
    private var retryJob: Job? = null

    fun startBoundedRetry() {
        retryJob?.cancel()
        retryJob = viewModelScope.launch {
            val startTime = System.currentTimeMillis()
            while (System.currentTimeMillis() - startTime &amp;lt; 75_000) {
                if (checkHealth()) {
                    _uiState.value = UiState.Ready
                    return@launch
                }
                delay(2_000)
            }
            _uiState.value = UiState.Unavailable
        }
    }

    private suspend fun checkHealth(): Boolean {
        // Perform lightweight health endpoint check
        return false
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;testing-failure-and-recovery&quot;&gt;Testing Failure and Recovery&lt;/h3&gt;
&lt;p&gt;Verifying timeout and recovery logic requires deterministic test execution. Unit tests must cover the initial healthy endpoint selection, the transition to the unavailable state after all candidates fail, the forced configuration refresh on manual retry, and the successful recovery when a later attempt succeeds. Using virtual time control in coroutine tests allows developers to simulate the 75-second deadline and the two-second intervals instantly without slowing down the test suite.&lt;/p&gt;
&lt;h3 id=&quot;adapting-retry-strategies-for-production&quot;&gt;Adapting Retry Strategies for Production&lt;/h3&gt;
&lt;p&gt;While a fixed interval and a manual trigger work well for predictable cold starts, production systems often require more sophisticated patterns. Developers should consider exponential backoff, jitter, and provider Retry-After headers when dealing with public APIs or heavily loaded microservices. Evaluating connectivity classification helps determine whether a retry should be attempted at all when the device is offline or on a metered connection.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-for-resilient-startup-flows&quot;&gt;Conclusion for Resilient Startup Flows&lt;/h3&gt;
&lt;p&gt;Designing a resilient mobile startup flow means balancing persistence with user experience. By implementing a finite health-check pass, offering a bounded manual retry, and cancelling superseded requests, you protect both the client application and the backend service from unnecessary strain. Grounding your retry intervals in actual service behavior rather than guesswork ensures a predictable experience when services wake up.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30209/handling-a-cold-starting-android-backend</guid>
<pubDate>Thu, 08 Oct 2026 03:44:20 +0000</pubDate>
</item>
<item>
<title>Offline-first with no server: why HelperBook is local-only</title>
<link>https://coderlegion.com/29954/offline-first-with-no-server-why-helperbook-is-local-only</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Offline-first with no server: why HelperBook is local-only&lt;br&gt;
published: true&lt;br&gt;
date: 2026-09-24 00:00:00 UTC&lt;br&gt;
tags: offlinefirst,kotlinmultiplatform,privacy,android&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-abhishekgoyal-me-writing-offline-first-without-a-server&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/writing/offline-first-without-a-server&quot;&gt;https://abhishekgoyal.me/writing/offline-first-without-a-server&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;A lot of my work has been on offline-first apps that still, eventually, talk to a server. At Pulse, I designed the sync contract with the backend and web teams: payloads, upload semantics, failure behaviour. The app worked without signal, but it was always working towards a moment when it would have signal again and reconcile.&lt;/p&gt;
&lt;p&gt;HelperBook, the app I’m building now for Indian households to track staff attendance, advances and salary settlements, doesn’t have that moment. There is no signup and no server. Records live on the device, full stop. I want to explain why that’s a design decision and not a missing feature, and what it costs.&lt;/p&gt;
&lt;h3 id=&quot;the-data-is-the-reason-not-to-have-a-server&quot;&gt;The data is the reason not to have a server&lt;/h3&gt;
&lt;p&gt;HelperBook stores wage data: how many days someone worked, what they were advanced, what they’re owed at month end. That’s exactly the kind of data a household might hesitate to put into an app if it thought the numbers were going somewhere.&lt;/p&gt;
&lt;p&gt;Keeping wage data off any server keeps the app outside data-fiduciary scope under India’s DPDP Act, and it gives households a reason to enter real numbers. Those two things are connected. An app that can’t leak your payroll data because it never has it in the first place is a different trust proposition than one that promises to look after your payroll data on a server somewhere.&lt;/p&gt;
&lt;h3 id=&quot;no-sync-engine-to-design&quot;&gt;No sync engine to design&lt;/h3&gt;
&lt;p&gt;The technical consequence is that there’s no sync contract to design, no conflict resolution, no retry queue, no “what happens if the upload fails halfway through” states. Records live on-device via SQLDelight, inside a shared Kotlin Multiplatform module. Users own their data through export and restore.&lt;/p&gt;
&lt;p&gt;That’s a smaller surface area than Pulse’s sync engine, and it’s smaller on purpose. Every state a sync engine has to handle — offline, online, mid-sync, conflicting edits from two devices — is a state HelperBook doesn’t have. The trade-off is direct: no server means no multi-device. If a household member wants to check the ledger from a second phone, they can’t. And if the phone is lost or wiped without an export first, the data is gone. There’s no account to recover it from. That’s a real cost, and it’s one I chose deliberately rather than one I didn’t notice.&lt;/p&gt;
&lt;h3 id=&quot;building-the-honesty-into-the-data-model-not-the-ui&quot;&gt;Building the honesty into the data model, not the UI&lt;/h3&gt;
&lt;p&gt;A local-only app has one more responsibility than a server-backed one: the rules have to be right, because there’s no admin panel on the backend to fix a bad month for someone later. So the product rules that matter most live in the data model rather than as UI copy or a setting someone can miss.&lt;/p&gt;
&lt;p&gt;Unmarked days default to present, so a worker is never underpaid because someone forgot to tap. Half-days deduct exactly half, with no rounding surprises at month end. And settlement statements are generated bilingually, in English and Hindi, so the employer and the helper can both read every line without one of them having to trust the other’s translation.&lt;/p&gt;
&lt;p&gt;As a simplified illustration, the “unmarked day” rule looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// Simplified illustration of the rule, not HelperBook's actual code.
fun attendanceValue(mark: DayMark?): Double = when (mark) {
    DayMark.ABSENT -&amp;gt; 0.0
    DayMark.HALF_DAY -&amp;gt; 0.5
    DayMark.PRESENT, null -&amp;gt; 1.0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting part isn’t the code, it’s the default: a missing mark resolves to &lt;code&gt;1.0&lt;/code&gt;, not &lt;code&gt;0.0&lt;/code&gt;. In a local-only app, that decision is the whole safety net. There’s no support ticket, no server-side correction, no second system checking the math. Whatever the function returns is what the worker gets paid.&lt;/p&gt;
&lt;h3 id=&quot;release-engineering-without-a-backend-to-hide-behind&quot;&gt;Release engineering without a backend to hide behind&lt;/h3&gt;
&lt;p&gt;Going local-only doesn’t remove the need for release discipline, it just moves where it matters. HelperBook ships as a signed AAB with R8 rules written for SQLDelight and kotlinx-serialization, so obfuscation doesn’t break the database layer or the serialised export format. Crashlytics is wired in, but the logs carry no names or amounts. Play’s Data Safety declarations describe what the app actually does. And there’s a staged-rollout plan, so a wider release doesn’t reach everyone at once.&lt;/p&gt;
&lt;p&gt;None of that is specific to offline-first apps. But when there’s no backend team and no server logs to lean on for debugging, the discipline around what you ship and how you roll it out is the only safety net you have. It’s currently in closed beta on Google Play with 12–20 households, built with Jetpack Compose, min SDK 24, target API 35.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-tell-another-builder&quot;&gt;What I’d tell another builder&lt;/h3&gt;
&lt;p&gt;If you’re weighing whether a product needs a backend at all, the question I’d ask first isn’t “can we build the sync engine,” it’s “what does the user lose if there’s no sync engine.” For HelperBook, the loss is multi-device access and recovery-without-export, and I judged that a household tracking one home’s payroll would rather have neither of those than have their wage data sitting on a server. For a different product, that trade might go the other way.&lt;/p&gt;
&lt;p&gt;The second thing I’d say: local-only doesn’t mean less engineering. It means the engineering moves into the data model and the release pipeline instead of into a sync contract. You still have to get the rules right, you still have to obfuscate and log carefully, you still have to plan the rollout in stages. You just don’t get to blame a server for anything.&lt;/p&gt;
&lt;p&gt;More on how HelperBook is built is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/work/helperbook&quot;&gt;case study&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29954/offline-first-with-no-server-why-helperbook-is-local-only</guid>
<pubDate>Tue, 06 Oct 2026 08:24:11 +0000</pubDate>
</item>
<item>
<title>Native, Kotlin Multiplatform or React Native: how I'd choose</title>
<link>https://coderlegion.com/29953/native-kotlin-multiplatform-or-react-native-how-id-choose</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Native, Kotlin Multiplatform or React Native: how I'd choose&lt;br&gt;
published: true&lt;br&gt;
date: 2026-09-25 00:00:00 UTC&lt;br&gt;
tags: kotlinmultiplatform,reactnative,android,ios&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-abhishekgoyal-me-writing-native-kmp-or-react-native&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/writing/native-kmp-or-react-native&quot;&gt;https://abhishekgoyal.me/writing/native-kmp-or-react-native&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;“Should we build this in Kotlin Multiplatform or React Native?” is a question teams often ask before anyone has written down what the app has to do. That’s backwards. I’ve shipped all three approaches to production: native Android and iOS apps, a shared Kotlin Multiplatform data layer in HelperBook, and a React Native rewrite of two native apps at Pulse. The framework has never been the first decision. The features are.&lt;/p&gt;
&lt;h3 id=&quot;start-with-the-features-not-the-framework&quot;&gt;Start with the features, not the framework&lt;/h3&gt;
&lt;p&gt;Before choosing anything, I list what the app actually has to do, and which of those things lean on the platform. Camera capture, video playback, background work, offline data, sensors, OS integrations. Then I weigh each option against that list, along with the team that will maintain it and the time and money available.&lt;/p&gt;
&lt;p&gt;Most of the heat in “Kotlin Multiplatform vs React Native” debates comes from people comparing the tools in the abstract. For a specific app, the answer is usually much clearer than the debate suggests.&lt;/p&gt;
&lt;h3 id=&quot;when-i-d-stay-fully-native&quot;&gt;When I’d stay fully native&lt;/h3&gt;
&lt;p&gt;Sharing code is not free, so sometimes the right amount to share is none.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The market is on one platform.&lt;/strong&gt; HelperBook is for Indian households, where Android is the phone most people carry, so it shipped on Android first. Its records still live in a shared Kotlin Multiplatform module with SQLDelight, so an iOS app could reuse that data layer rather than rewrite it, but the app itself is native Android.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The app leans hard on platform APIs.&lt;/strong&gt; On a video-first hiring platform I built 100% of the Android client, and the core of it was CameraX capture, Media3 playback and on-device speech transcription. That is exactly the kind of work where you want the platform’s own APIs directly, not through a layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The two apps look and behave differently.&lt;/strong&gt; If the Android and iOS versions don’t share much UI or flow, a shared UI codebase saves less than it costs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-kotlin-multiplatform-fits&quot;&gt;When Kotlin Multiplatform fits&lt;/h3&gt;
&lt;p&gt;Kotlin Multiplatform shares business logic and data while each platform keeps a fully native UI. I reach for it when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Native apps already exist.&lt;/strong&gt; You can add a shared module to the apps you have and move code into it gradually, instead of rewriting both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The team writes Kotlin.&lt;/strong&gt; Android developers can own the shared code without learning a new stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The business logic is complex and must match on both platforms.&lt;/strong&gt; Rules, calculations and offline data that behave slightly differently on Android and iOS are a bug factory. Writing them once removes a whole class of problems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native feel matters.&lt;/strong&gt; The UI stays Jetpack Compose on Android and SwiftUI or UIKit on iOS.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-react-native-fits&quot;&gt;When React Native fits&lt;/h3&gt;
&lt;p&gt;React Native gives you one codebase for the UI as well as the logic. I recommend it when the timeline is short and the budget is small, and one team has to ship both platforms quickly. It also makes more sense when the screens are the same on both platforms, and when there’s already a React web team whose skills carry over.&lt;/p&gt;
&lt;h3 id=&quot;performance-honestly&quot;&gt;Performance, honestly&lt;/h3&gt;
&lt;p&gt;People search for “Kotlin Multiplatform vs React Native performance” a lot, so here’s my view. Kotlin Multiplatform keeps the UI fully native, and native UI is smoother. For most business apps (forms, lists, dashboards) React Native is fine, and the difference users feel comes far more from the architecture than from the framework.&lt;/p&gt;
&lt;p&gt;I haven’t shipped a Flutter app, so I won’t argue for or against it from experience. It’s a real option; it’s just not one I can speak to first-hand.&lt;/p&gt;
&lt;h3 id=&quot;what-pulse-taught-me&quot;&gt;What Pulse taught me&lt;/h3&gt;
&lt;p&gt;At Pulse I owned mobile, Android and iOS, from an early prototype to 100+ B2B enterprise clients. For years it ran as two native apps, Kotlin on Android and Swift on iOS, kept at feature parity. Keeping two native apps at parity meant building every feature twice.&lt;/p&gt;
&lt;p&gt;In 2025 I led the rewrite of both apps into one React Native codebase, architected and co-built with the team in six months, and it replaced both native apps in production. React Native fitted the situation: one codebase suited the timeline and budget, the web app was already built in React, and the screens were the same on both platforms.&lt;/p&gt;
&lt;p&gt;The hardest part of the port was reliable background uploads, which the offline engine depended on. The inspections could run to 500+ questions and 1,000+ photos, captured offline and uploaded in the background once a connection came back, so the upload behaviour had to be as dependable after the rewrite as before it.&lt;/p&gt;
&lt;p&gt;Looking back, React Native was a reasonable call for that timeline, budget and team. Knowing what I know now, I’d weigh Kotlin Multiplatform harder for an app with that much offline logic. The sync rules and the upload queue are exactly the kind of code you want written once and shared, while each platform keeps its own native background APIs.&lt;/p&gt;
&lt;h3 id=&quot;how-i-d-decide-for-your-app&quot;&gt;How I’d decide for your app&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;If your app… &lt;/th&gt;&lt;th&gt;I’d lean towards&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Targets one platform first, or leans hard on camera, video, sensors &lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Already has native apps, or complex logic that must match on both &lt;/td&gt;&lt;td&gt;Kotlin Multiplatform&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Needs both platforms fast, on a small budget, with the same screens &lt;/td&gt;&lt;td&gt;React Native&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;None of these is a rule. They’re where I start, and the feature list decides the rest. If you’re weighing this for a real app, &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/services/kmp&quot;&gt;here’s how I help teams adopt Kotlin Multiplatform&lt;/a&gt;, and every engagement starts with a paid discovery sprint that ends in a written recommendation you can use with anyone.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29953/native-kotlin-multiplatform-or-react-native-how-id-choose</guid>
<pubDate>Tue, 06 Oct 2026 08:23:00 +0000</pubDate>
</item>
<item>
<title>Benchmark Android User Journeys with Macrobenchmark</title>
<link>https://coderlegion.com/29723/benchmark-android-user-journeys-with-macrobenchmark</link>
<description>&lt;p&gt;Unit tests can prove that a ViewModel returns the right state, but they cannot measure the latency and frame behavior a user experiences across a sequence of screens and a real application process. To understand real-world responsiveness, you need to use Macrobenchmark with UI Automator to drive a complete interaction from process launch to the user-visible refreshed state, then analyze frame and milestone timings.&lt;/p&gt;
&lt;h3 id=&quot;understanding-macrobenchmark-scope&quot;&gt;Understanding Macrobenchmark Scope&lt;/h3&gt;
&lt;p&gt;The useful distinction in performance testing is scope. A unit test isolates a method or state transition in memory. A standard UI test checks user-visible behavior and assertions against functional correctness. A Macrobenchmark runs outside the target app process and measures a larger end-user interaction with controlled compilation and performance metrics.&lt;/p&gt;
&lt;p&gt;Measuring a complete journey offers visibility into performance issues that isolated tests miss. For example, a multi-screen order-entry flow involves several distinct phases:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Launching the application and waiting for the home screen.&lt;/li&gt;
&lt;li&gt;Opening an order-entry surface.&lt;/li&gt;
&lt;li&gt;Selecting a package and filling required fields.&lt;/li&gt;
&lt;li&gt;Submitting the form.&lt;/li&gt;
&lt;li&gt;Waiting for the success message.&lt;/li&gt;
&lt;li&gt;Waiting until the new item appears in the pending list.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Measuring these steps together reveals delays that occur after an API response, such as rendering a refreshed list or handling complex layout inflation during navigation. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/managing-concurrent-git-commits-during-automated-publishing/&quot;&gt;Managing Concurrent Git Commits During Automated&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;configuring-the-benchmark-target&quot;&gt;Configuring the Benchmark Target&lt;/h3&gt;
&lt;p&gt;To run a reliable macrobenchmark, your application must be configured as profileable rather than debuggable. A debuggable build includes performance overhead from the Java Virtual Machine debugger and JIT constraints that distort real-world timing. A profileable build disables debugging while still permitting the benchmarking tool to capture trace information and metric counters.&lt;/p&gt;
&lt;p&gt;In your app-level build file, you configure the profileable manifest property inside the release or benchmark build type. The snippet below shows how to configure a dedicated benchmark build variant:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;android {
    buildTypes {
        create(&quot;benchmark&quot;) {
            initWith(buildTypes.getByName(&quot;release&quot;))
            signingConfig = signingConfigs.getByName(&quot;debug&quot;)
            matchingFallbacks += listOf(&quot;release&quot;)
            applicationIdSuffix = &quot;.benchmark&quot;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alongside the build configuration, the instrumentation test must drive the application using UI Automator selectors. The test runner launches the process, executes the touch and input actions, and measures the resulting frame metrics without altering app bytecode.&lt;/p&gt;
&lt;h3 id=&quot;driving-the-flow-with-ui-automator&quot;&gt;Driving the Flow with UI Automator&lt;/h3&gt;
&lt;p&gt;The benchmark test structure coordinates between the host test process and the target application process. Below is an example of a Macrobenchmark test class that launches an order-entry journey and measures the transition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@RunWith(AndroidJUnit4::class)
@LargeTest
public class OrderJourneyBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    public fun benchmarkOrderSubmission() {
        benchmarkRule.measureRepeated(
            packageName = &quot;com.example.app.benchmark&quot;,
            metrics = listOf(FrameTimingMetric(), StartupTimingMetric()),
            compilationMode = CompilationMode.DEFAULT,
            iterations = 5
        ) {
            pressHome()
            startActivityAndWait()

            // Open order entry surface
            device.findObject(By.res(&quot;com.example.app&quot;, &quot;button_new_order&quot;))
                .click()
            device.wait(Until.hasObject(By.res(&quot;com.example.app&quot;, &quot;form_container&quot;)), 3000)

            // Fill fields and submit
            device.findObject(By.res(&quot;com.example.app&quot;, &quot;input_package_name&quot;))
                .text = &quot;Standard Package&quot;
            device.findObject(By.res(&quot;com.example.app&quot;, &quot;button_submit&quot;))
                .click()

            // Wait for success message and refreshed list
            device.wait(Until.hasObject(By.res(
                &quot;com.example.app&quot;, 
                &quot;text_success_banner&quot;
            )), 5000)
            device.wait(Until.hasObject(By.res(
                &quot;com.example.app&quot;, 
                &quot;list_pending_orders&quot;
            )), 5000)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;managing-side-effects-and-data-isolation&quot;&gt;Managing Side Effects and Data Isolation&lt;/h3&gt;
&lt;p&gt;Running realistic user flows introduces operational side effects that require careful management. A flow that submits forms or creates records will write durable data to your backend or local database. If left unmanaged, repeated benchmark runs accumulate test artifacts that bloat local storage or clutter backend environments.&lt;/p&gt;
&lt;p&gt;To maintain valid performance numbers, you should isolate test data using a dedicated staging environment, implement automated database cleanup routines after each iteration, or use disposable test fixtures. Never point an automated performance benchmark against a production database without strict isolation controls. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/safe-multi-environment-database-orchestration/&quot;&gt;Safe Multi Environment Database Orchestration&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Another common pitfall is relying on insufficient sample sizes. A single measured iteration does not yield a reliable distribution or regression baseline. Performance numbers vary due to thermal throttling, background system processes, and CPU frequency scaling. Increasing the iteration count and observing the median value helps filter out transient noise.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Measuring complete user journeys with Macrobenchmark bridges the gap between synthetic unit tests and real-world user experience. By combining profileable builds, UI Automator drivers, and proper data isolation, you can establish repeatable performance baselines that capture multi-screen rendering behavior and latency across your entire application flow.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29723/benchmark-android-user-journeys-with-macrobenchmark</guid>
<pubDate>Sun, 04 Oct 2026 12:02:47 +0000</pubDate>
</item>
<item>
<title>Why Android Wireless Debugging Keeps Turning Off</title>
<link>https://coderlegion.com/29628/why-android-wireless-debugging-keeps-turning-off</link>
<description>&lt;p&gt;You step away from your workstation for ten minutes, and Android silently switches off your wireless debugging toggle. It is a frustrating interruption during active development. When troubleshooting this issue, engineering teams often look at network layers, but the root cause frequently lies in operating system power management. For content creators and technical bloggers, a parallel challenge emerges in organic search analytics. When an existing guide ranks on the first page of Google with high impressions but zero clicks, the instinct is to immediately rewrite the page title. That instinct is dangerous. Mutating an established title to capture new queries often destroys existing search visibility through keyword dilution. This article examines the root cause of Android debugging toggle resets and demonstrates why expanding a content architecture through topic clustering protects rankings while capturing missed &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/preventing-search-intent-duplicates-in-automated-publishing/&quot;&gt;search intent&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-with-title-mutation&quot;&gt;The Problem with Title Mutation&lt;/h3&gt;
&lt;p&gt;When reviewing live search analytics for developer troubleshooting guides, a distinct pattern frequently appears. A URL ranks respectably around position seven, accumulating hundreds of impressions every month, yet records zero clicks. Superficial optimization advice suggests changing the page title and meta description to make the snippet more engaging. However, altering metadata on a high-ranking page introduces severe risks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Keyword Dilution: Search engines rank pages because the exact semantic structure matches a specific intent. Broadening a title to capture secondary phrases confuses the classification algorithm.&lt;/li&gt;
&lt;li&gt;Algorithmic Volatility: Modifying anchor text and headings triggers re-evaluation phases, which can drop a stable page deep into the search results.&lt;/li&gt;
&lt;li&gt;Intent Mismatch: If searchers are looking for an operating system power policy fix, but the existing page covers network socket configuration, rewriting the title only increases bounce rates and damages user trust.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Extracting exact query logs from search analytics often reveals a divergence in user intent. For wireless debugging, one group of users experiences network timeouts and mDNS discovery failures. Another distinct group experiences the operating system physically turning the developer toggle off upon entering doze mode or screen lock. These are entirely different technical problems requiring separate solutions.&lt;/p&gt;
&lt;h3 id=&quot;os-power-management-versus-network-sockets&quot;&gt;OS Power Management Versus Network Sockets&lt;/h3&gt;
&lt;p&gt;To solve the user problem correctly, you must separate system setting revocation from protocol-level disconnections. Operating system power management daemons aggressively terminate background background services to preserve battery life. On modern Android builds, specific OEM layers trigger daemon shutdowns based on distinct triggers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Aggressive OEM background killers in custom ROMs terminate developer settings upon screen lock.&lt;/li&gt;
&lt;li&gt;Doze mode idle watchers suspend non-streaming TCP listeners, breaking persistent ADB sockets.&lt;/li&gt;
&lt;li&gt;Wi-Fi network roaming or MAC address randomization resets local socket bindings, forcing a manual handshake.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When a developer searches for why wireless debugging turns off, they are usually dealing with the toggle reverting to the off position, not a dropped TCP connection. Addressing this requires actionable workarounds inside the device settings or lightweight workstation scripts.&lt;/p&gt;
&lt;h3 id=&quot;implementing-workarounds-and-verification&quot;&gt;Implementing Workarounds and Verification&lt;/h3&gt;
&lt;p&gt;Preventing the wireless debugging toggle from resetting requires configuring both system preferences and development workflows. You can maintain a persistent connection by enabling developer options that prevent sleep states while connected to a power source. For a related implementation, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://raylabs.app/articles/choosing-between-chatgpt-and-claude-for-developer-workflows/&quot;&gt;Choosing Between Chatgpt Claude Developer Workflows&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;adb shell settings put global stay_on_while_plugged_in 3

# Verify active TCP connection state and remote debugging port
adb devices -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Additionally, modern versions of Android allow you to expose hidden developer options directly inside the notification shade. Adding the Quick Settings Developer Tile lets you toggle wireless debugging back on with a single tap, eliminating the need to dig through nested settings menus.&lt;/p&gt;
&lt;h3 id=&quot;architectural-resolution-through-topic-clustering&quot;&gt;Architectural Resolution Through Topic Clustering&lt;/h3&gt;
&lt;p&gt;Rather than risking a stable ranking asset by mutating its title, the correct editorial and technical approach is to build a companion asset through a hub-and-spoke model. The original guide remains untouched, preserving its Page 1 authority for network troubleshooting. A new, highly specialized spoke article is published to address operating system power policies and toggle resets.&lt;/p&gt;
&lt;p&gt;These two documents cross-link naturally. The network guide points users experiencing toggle shutdowns to the power management spoke. The power management spoke points users whose toggles stay active but lose packets back to the network guide. Search engines recognize this reciprocal linking structure as a definitive topical cluster, establishing deep domain authority without risking existing organic traffic.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-3&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Troubleshooting developer tooling requires precise separation between software states, just as technical publishing requires careful handling of search intent. When high impressions yield zero clicks, resist the urge to perform reckless title rewrites that dilute your existing authority. Analyze the query logs, identify the distinct underlying technical problems, and expand your documentation through targeted topic clustering to capture every facet of user intent safely.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29628/why-android-wireless-debugging-keeps-turning-off</guid>
<pubDate>Sat, 03 Oct 2026 15:24:23 +0000</pubDate>
</item>
<item>
<title>Zhidun — Online APK Hardening</title>
<link>https://coderlegion.com/29566/zhidun-online-apk-hardening</link>
<description>&lt;p&gt;Disclosure: This is a promotional introduction to Zhidun.&lt;/p&gt;
&lt;h3 id=&quot;choosing-online-apk-hardening-and-fitting-it-into-your-release-workflow&quot;&gt;Choosing online APK hardening and fitting it into your release workflow&lt;/h3&gt;
&lt;p&gt;If you already have an Android APK ready for release and want to add protection, the useful questions when evaluating an online APK-hardening tool are: what does it protect, how does it fit into the existing release workflow, and is the processed package suitable for users?&lt;/p&gt;
&lt;h3 id=&quot;1-identify-what-you-want-to-protect&quot;&gt;1. Identify what you want to protect&lt;/h3&gt;
&lt;p&gt;Some applications put substantial business logic on the client and need to consider code analysis and reuse. Others are more concerned about their installation package being modified, re-signed, and redistributed. Some need to identify risky runtime environments. These needs involve different protection priorities and affect which configuration options deserve attention.&lt;/p&gt;
&lt;p&gt;Start by writing down the main problem you want this hardening step to address, then look at the available settings. This helps avoid judging a tool only by its feature count and makes it easier to check whether the selected protection matches your needs. Zhidun provides online Android APK hardening as one starting point for this evaluation: begin with an existing package, then review and select settings according to your protection goals.&lt;/p&gt;
&lt;h3 id=&quot;2-map-the-workflow-from-the-original-apk-to-the-hardened-package&quot;&gt;2. Map the workflow from the original APK to the hardened package&lt;/h3&gt;
&lt;p&gt;A tool’s capabilities matter, but so does how it fits into day-to-day work. What goes in, where do you configure it, and what do you receive afterward? These steps should be clear.&lt;/p&gt;
&lt;p&gt;Zhidun uses an online workflow: upload an APK through the website, select a configuration, and download the result after processing. For developers who already have a built APK, this provides a defined entry point: harden the existing package, then bring the downloaded output back into your own validation and release process.&lt;/p&gt;
&lt;p&gt;One easy-to-overlook question is whether you can repeat the same steps for the next release. During evaluation, record the original package version, the selected configuration, and the output file. These records help show how the process fits into your existing work. Part of a tool’s value is whether developers understand each step and know what to do with the result.&lt;/p&gt;
&lt;h3 id=&quot;3-validate-the-result-against-your-actual-application&quot;&gt;3. Validate the result against your actual application&lt;/h3&gt;
&lt;p&gt;A hardening approach suitable for release ultimately needs to be evaluated with the real application. Choose a version you expect to release soon, establish your protection priorities, process it with the corresponding configuration, and then test the application’s key user journeys.&lt;/p&gt;
&lt;p&gt;The evaluation can produce concrete findings: which settings were selected, whether the output fits back into the original release process, and what the key-function checks revealed. Compared with broadly labeling a tool “strong” or “weak,” these records are more useful for deciding whether it fits your project.&lt;/p&gt;
&lt;p&gt;Online hardening offers an accessible starting point for protecting an existing APK. Zhidun is currently free. If these needs match your application, visit &lt;a rel=&quot;nofollow&quot; href=&quot;https://zdcod.com&quot;&gt;https://zdcod.com&lt;/a&gt; to review the available features and configuration options, then begin your evaluation with a version awaiting release.&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/29566/zhidun-online-apk-hardening</guid>
<pubDate>Fri, 02 Oct 2026 20:39:22 +0000</pubDate>
</item>
<item>
<title>Perenio Peifc01 IP Camera and Android Application Analysis</title>
<link>https://coderlegion.com/28970/perenio-peifc01-ip-camera-and-android-application-analysis</link>
<description>&lt;h2 id=&quot;initial-discovery&quot;&gt;Initial Discovery&lt;/h2&gt;
&lt;p&gt;Using &lt;a rel=&quot;nofollow&quot; href=&quot;https://codeshare.frida.re/@KostasEreksonas/crypto-discovery/&quot;&gt;my custom Frida script&lt;/a&gt; for cryptographic method detection in Android applications, I captured Java Cipher class doing a decryption operation, along with it's stack trace:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;objectId&quot;: &quot;Cipher-30236645&quot;,
  &quot;timestamp&quot;: 1789412643454,
  &quot;lastSeen&quot;: 1789412643488,
  &quot;instanceOverload&quot;: &quot;1. [Cipher.getInstance(java.lang.String) -&amp;gt; static Cipher]&quot;,
  &quot;transformation&quot;: &quot;AES/CBC/PKCS5Padding&quot;,
  &quot;algorithm&quot;: &quot;AES/CBC/PKCS5Padding&quot;,
  &quot;runtimeClass&quot;: &quot;javax.crypto.Cipher&quot;,
  &quot;providerName&quot;: &quot;AndroidOpenSSL&quot;,
  &quot;providerVersion&quot;: 1,
  &quot;providerInfo&quot;: &quot;Android's OpenSSL-backed security provider&quot;,
  &quot;providerClass&quot;: &quot;com.android.org.conscrypt.OpenSSLProvider&quot;,
  &quot;updateInputs&quot;: [],
  &quot;updateInputsLen&quot;: [],
  &quot;updateOutputs&quot;: [],
  &quot;updateOutputsLen&quot;: [],
  &quot;initOverload&quot;: &quot;6. [Cipher.init(int opmode, Key key, AlgorithmParameterSpec params, SecureRandom random) -&amp;gt; void]&quot;,
  &quot;opmode&quot;: 2,
  &quot;opmodeString&quot;: &quot;DECRYPT&quot;,
  &quot;blockSize&quot;: 16,
  &quot;keyClass&quot;: &quot;javax.crypto.spec.SecretKeySpec&quot;,
  &quot;keyAlgorithm&quot;: &quot;AES&quot;,
  &quot;keyFormat&quot;: &quot;RAW&quot;,
  &quot;keyBytesHex&quot;: &quot;4bf14ea15d7bd3bbfcf9b7d9acc6db5c&quot;,
  &quot;keyBytesString&quot;: &quot;K.N.]{.........\\&quot;,
  &quot;keyBytesFingerprint&quot;: &quot;32862b80:16&quot;,
  &quot;parameterClass&quot;: &quot;javax.crypto.spec.IvParameterSpec&quot;,
  &quot;iv&quot;: &quot;000000000000000000000000000000&quot;,
  &quot;ivFingerprint&quot;: &quot;69691905:16&quot;,
  &quot;secureRandom&quot;: &quot;java.security.SecureRandom&quot;,
  &quot;finalOverload&quot;: &quot;2. [Cipher.doFinal(byte[] input) -&amp;gt; byte[]]&quot;,
  &quot;bytesWritten&quot;: 96,
  &quot;input&quot;: &quot;7c354a11ebcff3abe4b3c1c359cb16c14a1685851abaca3fd80b2cb8ba1b423f398944c108b2ba6096d43fb0317c5c17 ... [64 more bytes]&quot;,
  &quot;output&quot;: &quot;{\&quot;commonPrefs\&quot;:{\&quot;display_rate_us_after\&quot;:0,\&quot;permi ... [48 more bytes]&quot;,
  &quot;outputFingerprint&quot;: &quot;a29ad7a9:96&quot;,
  &quot;stackTrace&quot;: [
    &quot;java.lang.Throwable&quot;,
    &quot;at javax.crypto.Cipher.doFinal(Native Method)&quot;,
    &quot;at se.simbio.encryption.Encryption.decrypt(Encryption.java:179)&quot;,
    &quot;at se.simbio.encryption.Encryption.decryptOrNull(Encryption.java:193)&quot;,
    &quot;at com.perenio.data.util.EncryptionUtils.decrypt(EncryptionUtils.kt:21)&quot;,
    &quot;at com.perenio.data.local.sharedpreference.base.AbsEncryptPreferencesProvider.getStringDecrypt(AbsEncryptPreferencesProvider.kt:15)&quot;,
    &quot;at com.perenio.data.local.sharedpreference.base.AbsEncryptPreferencesProvider.getStringDecrypt$default(AbsEncryptPreferencesProvider.kt:13)&quot;,
    &quot;at com.perenio.data.local.sharedpreference.SharedPreferencesProvider.&amp;lt;init&amp;gt;(SharedPreferencesProvider.kt:149)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SharedPreferencesModule.provideSharedPreferencesProvider(SharedPreferencesModule.kt:25)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.provideSharedPreferencesProvider(SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.java:56)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.get(SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.java:44)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.get(SharedPreferencesModule_ProvideSharedPreferencesProviderFactory.java:14)&quot;,
    &quot;at dagger.internal.DoubleCheck.get(DoubleCheck.java:47)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SectionsModule_ProvideUserSectionFactory.get(SectionsModule_ProvideUserSectionFactory.java:68)&quot;,
    &quot;at com.perenio.smarthome.di.module.data.SectionsModule_ProvideUserSectionFactory.get(SectionsModule_ProvideUserSectionFactory.java:19)&quot;,
    &quot;at dagger.internal.DoubleCheck.get(DoubleCheck.java:47)&quot;,
    &quot;at com.perenio.smarthome.di.component.DaggerAppComponent$AppComponentImpl.synchronizeSubscriptionsUseCase(DaggerAppComponent.java:2416)&quot;,
    &quot;at com.perenio.smarthome.di.component.DaggerAppComponent$AppComponentImpl.injectPerenioApp(DaggerAppComponent.java:3815)&quot;,
    &quot;at com.perenio.smarthome.di.component.DaggerAppComponent$AppComponentImpl.inject(DaggerAppComponent.java:3036)&quot;,
    &quot;at com.perenio.smarthome.PerenioApp.onCreate(PerenioApp.kt:47)&quot;,
    &quot;at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1385)&quot;,
    &quot;at android.app.ActivityThread.handleBindApplication(ActivityThread.java:7830)&quot;,
    &quot;at android.app.ActivityThread.handleBindApplication(Native Method)&quot;,
    &quot;at android.app.ActivityThread.-$$Nest$mhandleBindApplication(Unknown Source:0)&quot;,
    &quot;at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2546)&quot;,
    &quot;at android.os.Handler.dispatchMessage(Handler.java:110)&quot;,
    &quot;at android.os.Looper.loopOnce(Looper.java:248)&quot;,
    &quot;at android.os.Looper.loop(Looper.java:338)&quot;,
    &quot;at android.app.ActivityThread.main(ActivityThread.java:9068)&quot;,
    &quot;at java.lang.reflect.Method.invoke(Native Method)&quot;,
    &quot;at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:596)&quot;,
    &quot;at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:932)\n&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;cryptography-implementation-analysis&quot;&gt;Cryptography Implementation Analysis&lt;/h2&gt;
&lt;h3 id=&quot;pulling-android-application-to-host&quot;&gt;Pulling Android Application to Host&lt;/h3&gt;
&lt;p&gt;Name of the Android application is &lt;code&gt;com.perenio.smarthome&lt;/code&gt;.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;List relevant packages:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;adb shell pm list packages | grep &amp;lt;package-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Get full path name for the package with &lt;code&gt;adb shell pm path &amp;lt;package-name&amp;gt;&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;package:/data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/base.apk
package:/data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/split_asset_pack.apk
package:/data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/split_config.arm64_v8a.apk
package:/data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/split_config.en.apk
package:/data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/split_config.xxhdpi.apk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Core application logic is stored in &lt;code&gt;base.apk&lt;/code&gt; with assets and configs stored in separate .apk files. For the purposes of current research, only the base application file was analyzed.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pull the &lt;code&gt;base.apk&lt;/code&gt; to host machine:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;adb pull /data/app/~~s-&amp;lt;base64-string&amp;gt;/&amp;lt;package-name&amp;gt;-n_K_-&amp;lt;base64-string&amp;gt;/base.apk
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/KostasEreksonas/android_analysis/blob/main/scripts/pull_application&quot;&gt;A shell script that automates package pulling process can be found by following this link&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;application-analysis-with-jadx-and-frida&quot;&gt;Application Analysis with JADX and Frida&lt;/h3&gt;
&lt;p&gt;This section covers analysis of Perenio application's data encryption and decryption methods. Both static analysis with JADX and dynamic analysis using Frida were performed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;One important thing to note:&lt;/em&gt;&lt;/strong&gt; the described encryption method is a local-only encryption method, i.e. it only protects data that is at rest in local storage. Data transmitted via network is protected by TLSv1.3 sessions and SSL certificates pinned into Perenio app.&lt;/p&gt;
&lt;p&gt;Searching for &lt;code&gt;se.simbio.encryption.Encryption&lt;/code&gt; in Jadx-gui leads to the decompiled code of the &lt;code&gt;Encryption&lt;/code&gt; class (whose scope is set to public).&lt;/p&gt;
&lt;p&gt;The class has &lt;code&gt;getDefault(String str, String str2, byte[] bArr) { ... }&lt;/code&gt; method that creates an object of &lt;code&gt;Builder&lt;/code&gt; class, responsible for providing information necessary to construct key and initialization vector (IV) for AES encryption:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5423388843531633055&quot; alt=&quot;Encryption.getDefault() method&quot;&gt;&lt;/p&gt;
&lt;p&gt;This method is called from &lt;code&gt;com.perenio.data.util.EncryptionUtils&lt;/code&gt; class and gets hardcoded key and salt values provided as arguments:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14475187547038655165&quot; alt=&quot;Encryption.getDefault() in EncryptionUtils&quot;&gt;&lt;/p&gt;
&lt;p&gt;The builder method itself builds a key from provided information and has setters and getters for every private variable:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=1239275231021999193&quot; alt=&quot;Builder method&quot;&gt;&lt;/p&gt;
&lt;p&gt;Overloading the &lt;code&gt;getDefaultBuilder&lt;/code&gt; method (along with &lt;code&gt;decrypt&lt;/code&gt; method) using Frida discloses that the following information is being received during Perenio application's initialization sequence:&lt;/p&gt;
&lt;p&gt;During the app initialization stage, Frida overloads for &lt;code&gt;getDefaultBuilder&lt;/code&gt; and &lt;code&gt;decrypt&lt;/code&gt; methods captured the following information about building a key and IV for AES decryption operation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;threadId&quot;: &quot;2&quot;,
  &quot;builder&quot;: {
    &quot;algorithm&quot;: &quot;AES/CBC/PKCS5Padding&quot;,
    &quot;modeBase64&quot;: 0,
    &quot;charsetName&quot;: &quot;UTF8&quot;,
    &quot;digestAlgorithm&quot;: &quot;SHA1&quot;,
    &quot;iterationCount&quot;: 1,
    &quot;iv&quot;: &quot;00000000000000000000000000000000&quot;,
    &quot;ivParameterSpec&quot;: null,
    &quot;key&quot;: &quot;A45BE7EF0543D36B4F68DvD3EF4013D4&quot;,
    &quot;keyAlgorithm&quot;: &quot;AES&quot;,
    &quot;keyLength&quot;: 128,
    &quot;salt&quot;: &quot;BDBA982FAF389694&quot;,
    &quot;secretKeyType&quot;: &quot;PBKDF2WithHmacSHA1&quot;,
    &quot;secureRandom&quot;: null,
    &quot;secureRandomAlgorithm&quot;: &quot;SHA1PRNG&quot;
  },
  &quot;getDefaultInfo&quot;: {
    &quot;key&quot;: &quot;A45BE7EF0543D36B4F68DvD3EF4013D4&quot;,
    &quot;salt&quot;: &quot;BDBA982FAF389694&quot;,
    &quot;defaultKey&quot;: &quot;00000000000000000000000000000000&quot;
  },
  &quot;encrypted&quot;: &quot;fDVKEevP86vks8HDWcsWwUoWhYUauso/2AssuLobQj85iUTBCLK6YJbUP7AxfFwXHjKn3F3URNgh\n/2SvLbPOPqS3wos0B9h8m/Q9jtfCVvEt3TqiLY0dSeff12q/2BmDRKpBQYHFeO+F8UKtMuNdoA==\n    &quot;,
  &quot;hashTheKeyInfo&quot;: {
    &quot;keyPlaintext&quot;: &quot;A45BE7EF0543D36B4F68DvD3EF4013D4&quot;,
    &quot;keyHashed&quot;: &quot;zb6XIjgHtuii5dEcQuiY/8QFluA\n&quot;
  },
  &quot;SKFGetInstance&quot;: {
    &quot;secretKeyType&quot;: &quot;PBKDF2WithHmacSHA1&quot;,
    &quot;providerName&quot;: &quot;BC&quot;,
    &quot;providerVersion&quot;: 1.77,
    &quot;providerInfo&quot;: &quot;BouncyCastle Security Provider v1.77&quot;
  },
  &quot;SKFGenerateSecret&quot;: {
    &quot;algorithm&quot;: &quot;PBKDF2WithHmacSHA1&quot;,
    &quot;PBEKeySpecInfo&quot;: {
      &quot;iterationCount&quot;: 1,
      &quot;keyLength&quot;: 128,
      &quot;password&quot;: &quot;zb6XIjgHtuii5dEcQuiY/8QFluA\n&quot;,
      &quot;salt&quot;: &quot;BDBA982FAF389694&quot;
    },
    &quot;secretKey&quot;: &quot;4bf14ea15d7bd3bbfcf9b7d9acc6db5c&quot;
  },
  &quot;SKSInit&quot;: {
    &quot;key&quot;: &quot;4bf14ea15d7bd3bbfcf9b7d9acc6db5c&quot;,
    &quot;Algorithm&quot;: &quot;AES&quot;
  },
  &quot;getSecretKeyInfo&quot;: {
    &quot;algorithm&quot;: &quot;AES&quot;,
    &quot;keyMaterial&quot;: &quot;4bf14ea15d7bd3bbfcf9b7d9acc6db5c&quot;
  },
  &quot;cryptoInitInfo&quot;: {
    &quot;opmode&quot;: 2,
    &quot;opmodeString&quot;: &quot;DECRYPT&quot;,
    &quot;key&quot;: {
      &quot;keyClass&quot;: &quot;javax.crypto.spec.SecretKeySpec&quot;,
      &quot;keyAlgorithm&quot;: &quot;AES&quot;,
      &quot;keyFormat&quot;: &quot;RAW&quot;,
      &quot;keyEncoded&quot;: &quot;4bf14ea15d7bd3bbfcf9b7d9acc6db5c&quot;
    },
    &quot;parameters&quot;: {
      &quot;parameterClass&quot;: &quot;javax.crypto.spec.IvParameterSpec&quot;,
      &quot;parameterValue&quot;: &quot;00000000000000000000000000000000&quot;
    },
    &quot;secureRandom&quot;: {
      &quot;secureRandomClass&quot;: &quot;java.security.SecureRandom&quot;,
      &quot;secureRandomAlgorithm&quot;: &quot;SHA1PRNG&quot;,
      &quot;secureRandomProvider&quot;: &quot;AndroidOpenSSL version 1.0&quot;
    }
  },
  &quot;decrypted&quot;: {
    &quot;commonPrefs&quot;: {
      &quot;display_rate_us_after&quot;: 0,
      &quot;permission_event_severity&quot;: &quot;event&quot;
    },
    &quot;devicePrefs&quot;: {}
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Secret key for both &lt;code&gt;encrypt&lt;/code&gt; and &lt;code&gt;decrypt&lt;/code&gt; operations is built upon a hardcoded key in &lt;code&gt;hashTheKey&lt;/code&gt; method:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=13566060183327577280&quot; alt=&quot;Hashing function of hardcoded key&quot;&gt;&lt;/p&gt;
&lt;p&gt;As an example, let's take &lt;code&gt;Encryption.encrypt()&lt;/code&gt; method:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7719426748073445138&quot; alt=&quot;Encryption.encrypt() method&quot;&gt;&lt;/p&gt;
&lt;p&gt;This method works roughly as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The key for &lt;code&gt;getSecretKey&lt;/code&gt; method is prepared by &lt;code&gt;hashTheKey&lt;/code&gt; method:&lt;ul&gt;
&lt;li&gt;MessageDigest object gets instantiated with a digest algorithm (SHA1 in collected samples).&lt;/li&gt;
&lt;li&gt;The digest object gets updated with hardcoded key (encoded with &lt;code&gt;utf-8&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Hash computation is finished with a call to &lt;code&gt;digest()&lt;/code&gt; method, the final hash being encoded to Base64 string (with NO_PADDING flag enabled) and converted to a char array.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;getSecretKey&lt;/code&gt; method instantiates &lt;code&gt;SecretKeyFactory&lt;/code&gt; object:&lt;ul&gt;
&lt;li&gt;Type of the final secret key is set as &lt;code&gt;PBKDF2WithHmacSHA1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PBEKeySpec&lt;/code&gt; is instantiated with the following parameters:&lt;ul&gt;
&lt;li&gt;Iteration count: 1.&lt;/li&gt;
&lt;li&gt;Key length: 128-bit.&lt;/li&gt;
&lt;li&gt;Password: output from &lt;code&gt;hashTheKey&lt;/code&gt; method (PBKDF2 password represented as a Java character array).&lt;/li&gt;
&lt;li&gt;Salt: a value that is also hardcoded into Perenio Android application.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;generateSecret&lt;/code&gt; method computes secret key from the previously listed parameters. &lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Java &lt;code&gt;Cipher&lt;/code&gt; object is instantiated with the transformation &lt;code&gt;AES/CBC/PKCS5Padding&lt;/code&gt; and is initialized with:&lt;ul&gt;
&lt;li&gt;Operation mode (opmode):&lt;ul&gt;
&lt;li&gt;1 = encryption&lt;/li&gt;
&lt;li&gt;2 = decryption.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Secret key.&lt;/li&gt;
&lt;li&gt;Initialization vector (IV).&lt;/li&gt;
&lt;li&gt;Secure random (this field is initialized but not used).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Message is encrypted using &lt;code&gt;Cipher.doFinal()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;Encryption.decrypt()&lt;/code&gt; method follows the same Cipher pattern, just with &lt;code&gt;opmode = 2&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11721607375419126935&quot; alt=&quot;Encryption.decrypt() method&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;./perenio.js&quot;&gt;Javascript code for this Frida overload is available in this repository as perenio.js.&lt;/a&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;./pythonImplementations/crypto.py&quot;&gt;Python reimplementation of the described encryption method is available at pythonImplementations/crypto.py.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Sample outputs of dynamic analysis script &lt;a rel=&quot;nofollow&quot; href=&quot;./sampleLogs/&quot;&gt;can be found in sampleLogs directory&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;security-findings&quot;&gt;Security Findings&lt;/h3&gt;
&lt;p&gt;A few notable considerations regarding the cryptographic methods used in the Perenio Android application:  &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Key and salt values are hardcoded into the Perenio Android application.&lt;/li&gt;
&lt;li&gt;Initialization vector (IV) is initialized as a 16 byte long value of all-zeros.&lt;/li&gt;
&lt;li&gt;Having a static key combined with all-zero IV makes AES-CBC deterministic - same plaintext inputs produce the same ciphertext outputs.&lt;/li&gt;
&lt;li&gt;SecureRandom is instantiated but is never used to derive IV.&lt;/li&gt;
&lt;li&gt;Password-Based Key Derivation Function 2 (PBKDF2) has an iteration count of &lt;strong&gt;&lt;em&gt;1&lt;/em&gt;&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;PBKDF2 iteration count determines how many times the hashing process of key material should be repeated.&lt;/li&gt;
&lt;li&gt;On the first iteration, input key is mixed with the salt and hashed.&lt;/li&gt;
&lt;li&gt;Subsequent iterations takes hash output of previous iteration and re-hashes it with the same method.&lt;/li&gt;
&lt;li&gt;As a final step, outputs of all iterations are combined (typically with a bitwise XOR) to produce the final encryption key.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html&quot;&gt;OWASP recommends 1,400,000 iterations for PBKDF2-HMAC-SHA1 (legacy) key derivation function&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Perenio app's crypto implementation repeats the hashing process exactly 1 time, which, combined with hardcoded key, salt and all-zero IV provide no real security on the local data.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Key, derived with the aforementioned parameters, is being reused for subsequent local cryptographic operations.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note:&lt;/em&gt;&lt;/strong&gt; This is an app-only encryption method. Data in-transit is still being guarded by TLSv1.3, as well as SSL certificates pinned within Perenio Android application.&lt;/p&gt;
&lt;p&gt;Given the points listed above, the in-app security layer reads more like an obfuscation layer rather than true random, non-deterministic encryption method.&lt;/p&gt;
&lt;h2 id=&quot;login-sequence&quot;&gt;Login Sequence&lt;/h2&gt;
&lt;p&gt;Login sequence was intercepted using Mitmproxy and captured with Wireshark for analysis. However, network communications between Perenio Android application and the vendor's remote servers have a couple of protections:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;TLSv1.3 session is negotiated before any data reaches the network.&lt;/li&gt;
&lt;li&gt;Perenio Android application has SSL pinning enabled, meaning that only devices having Perenio's own CA certificates are trusted with the plain text data.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Tricking Android device into trusting my proxy for establishing TLSv1.3 session with it is as simple as injecting Mitmproxy's CA certificates onto Android system trust store (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/KostasEreksonas/android_analysis/blob/main/scripts/inject_certificates&quot;&gt;Bash automation script for this process can be found here&lt;/a&gt;). For pinned certificate bypass, Frida objection with &lt;code&gt;android sslpinning disable&lt;/code&gt; script was used.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note:&lt;/em&gt;&lt;/strong&gt; For dynamic analysis with Frida, &lt;code&gt;frida-server&lt;/code&gt; must be loaded and run on a target device as a daemon, so that a Frida client on host machine could have a harness that can be attached to a process for inspection and modification purposes.&lt;/p&gt;
&lt;p&gt;A quick description of the algorithm used for capturing plain text communication between Perenio Android app and vendor's servers might be this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Injecting Mitmproxy's CA certificate onto Android system trust store.&lt;/li&gt;
&lt;li&gt;Push &lt;code&gt;frida-server-&amp;lt;version&amp;gt;-android-arm64&lt;/code&gt; onto a rooted Android device or an emulator via &lt;code&gt;adb&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;frida-server&lt;/code&gt; on Android device.&lt;/li&gt;
&lt;li&gt;Set up Wi-Fi proxy on Android device to point at : of actual proxy (for example, if a proxy runs on 192.168.X.X:8080, Android device's Wi-Fi proxy needs to target 192.168.X.X:8080).&lt;/li&gt;
&lt;li&gt;Open Perenio application on Android device.&lt;/li&gt;
&lt;li&gt;Grab the process ID (PID) of Perenio app with &lt;code&gt;uv run frida-ps -U | grep -i perenio&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attach Frida objection to Perenio application with &lt;code&gt;uv run objection -g &quot;${pid}&quot; explore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start &lt;code&gt;android sslpinning disable&lt;/code&gt; script.&lt;/li&gt;
&lt;li&gt;On host machine, start Mitmproxy with &lt;code&gt;SSLKEYLOGFILE=/path/to/key/log/file mitmproxy --listen-port &amp;lt;PORT&amp;gt; --ssl-insecure&lt;/code&gt;. Saving SSL keys onto a file is necessary, because, even if Wireshark and Mitmproxy run concurrently, Wireshark does not log these keys itself. And without these keys, Wireshark &lt;strong&gt;&lt;em&gt;can not&lt;/em&gt;&lt;/strong&gt; decrypt the captured TLS sessions.&lt;/li&gt;
&lt;li&gt;Start Wireshark (with root privileges) and start network traffic capture (capture same network interface on which the proxy runs).&lt;/li&gt;
&lt;li&gt;Proceed to log into Perenio account.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/meetcyber/analyzing-mobile-application-http-s-traffic-using-burp-suite-and-frida-e3e91c085e91&quot;&gt;A more comprehensive guide on HTTPS proxy setup and SSL pinning bypass can be found here.&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The method described above, the following plain text login request was captured:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:method: POST
:scheme: https
:path: /auth/realms/aaa.kaa/protocol/openid-connect/token
:authority: oauth.perenio.com
tenantid: perenio
content-type: application/x-www-form-urlencoded
content-length: 144
accept-encoding: gzip
user-agent: okhttp/4.10.0

username=&amp;lt;email&amp;gt;&amp;amp;password=&amp;lt;account-password&amp;gt;&amp;amp;grant_type=password&amp;amp;client_id=perenio-app&amp;amp;client_secret=&amp;lt;client-secret&amp;gt;
.............:status: 200
date: Tue, 15 Sep 2026 14:59:55 GMT
content-type: application/json
vary: Accept-Encoding
cache-control: no-store
set-cookie: KEYCLOAK_LOCALE=; Version=1; Comment=Expiring cookie; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Max-Age=0; Path=/auth/realms/aaa.kaa/; Secure; HttpOnly
set-cookie: KC_RESTART=; Version=1; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Max-Age=0; Path=/auth/realms/aaa.kaa/; Secure; HttpOnly
x-xss-protection: 1; mode=block
pragma: no-cache
x-frame-options: SAMEORIGIN
strict-transport-security: max-age=15724800; includeSubDomains
x-content-type-options: nosniff
content-encoding: gzip

{&quot;access_token&quot;:&quot;&amp;lt;access-token&amp;gt;&quot;,&quot;expires_in&quot;:864000,&quot;refresh_expires_in&quot;:51840000,&quot;refresh_token&quot;:&quot;&amp;lt;refresh-token&amp;gt;&quot;,&quot;token_type&quot;:&quot;bearer&quot;,&quot;not-before-policy&quot;:0,&quot;session_state&quot;:&quot;&amp;lt;session-state&amp;gt;&quot;,&quot;scope&quot;:&quot;profile email&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few insights from the login request:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Login request body includes a parameter list with:&lt;ul&gt;
&lt;li&gt;Username (email).&lt;/li&gt;
&lt;li&gt;Password.&lt;/li&gt;
&lt;li&gt;Grant type = password.&lt;/li&gt;
&lt;li&gt;Client ID - client is Perenio Android application.&lt;/li&gt;
&lt;li&gt;Client secret - (seemingly) random UUID.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The access token is valid for 10 days while refresh token expires after 600 days.&lt;/li&gt;
&lt;li&gt;Session state differs with each login request (tested with a Python reimplementation of login request).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Heading back to the decompiled Perenio application, the login function is implemented in &lt;code&gt;com.perenio.data.datasource.sections.auth.AuthRepository&lt;/code&gt; function:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=15127900756113541649&quot; alt=&quot;Auth repository function&quot;&gt;&lt;/p&gt;
&lt;p&gt;With this information obtained from Wireshark packet capture, I decided to write a proof-of-concept Python script that POSTs a login request to an endpoint controlled by Perenio, receives an authorization token and queries &lt;code&gt;hxxps[://]oauth[.]perenio[.com/auth/realms/aaa[.]kaa/users/me&lt;/code&gt; endpoint to get user information of my account. The script is called &lt;a rel=&quot;nofollow&quot; href=&quot;./pythonImplementations/sampleRequest.py&quot;&gt;sampleRequest.py and can be found in pythonImplementations folder&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Script also queries a third endpoint - &lt;code&gt;registrationStatus&lt;/code&gt; - to check whether an account with a given email address has been registered.&lt;/p&gt;
&lt;p&gt;Response if account exists:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;status&quot;: &quot;EXISTS&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Response if account does not exist:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;status&quot;: &quot;NOT_EXISTS&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I have &lt;strong&gt;&lt;em&gt;not&lt;/em&gt;&lt;/strong&gt; tested the rate limits on this endpoint, but it potentially could be used to enumerate existing Perenio accounts.&lt;/p&gt;
&lt;p&gt;Sample response to login request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;access_token&quot;: &quot;&amp;lt;access-token&amp;gt;&quot;,
    &quot;expires_in&quot;: 864000,
    &quot;refresh_expires_in&quot;: 51840000,
    &quot;refresh_token&quot;: &quot;&amp;lt;refresh-token&amp;gt;&quot;,
    &quot;token_type&quot;: &quot;bearer&quot;,
    &quot;not-before-policy&quot;: 0,
    &quot;session_state&quot;: &quot;&amp;lt;session-state&amp;gt;&quot;,
    &quot;scope&quot;: &quot;profile email&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sample response to user information query:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &quot;uid&quot;: &quot;&amp;lt;user-id&amp;gt;&quot;,
    &quot;countryCode&quot;: &quot;LT&quot;,
    &quot;email&quot;: &quot;&amp;lt;email&amp;gt;&quot;,
    &quot;language&quot;: &quot;EN&quot;,
    &quot;nickname&quot;: &quot;&amp;lt;nickname&amp;gt;&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This also further proves that encryption methods described previously are local-only as these HTTP requests and responses contain plain text body - the encryption aspect is handed to TLSv1.3 and pinned SSL certificates.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;./pythonImplementations/sampleRequest.py&quot;&gt;The script can also be found as sampleRequest.py in pythonImplementations folder&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;analysis-of-media-stream-audio-video-&quot;&gt;Analysis of Media Stream (Audio/Video)&lt;/h2&gt;
&lt;p&gt;Perenio Peifc01 IP camera captures and transmits both audio and video streams. A sample stream was collected using &lt;code&gt;Comfast CF-922AC&lt;/code&gt; USB Wi-Fi adapter in monitor mode.&lt;/p&gt;
&lt;p&gt;Packet collection process can be described as follows:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Figure out adapter's network interface:&lt;pre&gt;&lt;code&gt;ip a
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Figure out a wireless channel of relevant Wi-Fi network:&lt;pre&gt;&lt;code&gt;nmcli device wifi list
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Restart Comfast Wi-Fi adapter in monitor mode:&lt;pre&gt;&lt;code&gt;sudo airmon-ng start &amp;lt;comfast-interface&amp;gt; &amp;lt;channel-number&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Start Wireshark and start packet capture on &lt;code&gt;&amp;lt;comfast-interface&amp;gt;mon&lt;/code&gt; interface.&lt;/li&gt;
&lt;li&gt;Connect IP camera and smartphone to Wi-Fi (Important: connect camera and smartphone to Wi-Fi &lt;strong&gt;&lt;em&gt;after&lt;/em&gt;&lt;/strong&gt; starting packet capture - Wireshark needs to capture 4-way handshake to derive cryptographic keys for Wi-Fi network traffic decryption).&lt;/li&gt;
&lt;li&gt;Start Perenio application on Android device and access IP camera's video stream - Wireshark should capture this data as of now.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wireshark shows heavy UDP traffic on ports &lt;code&gt;50612&lt;/code&gt;, &lt;code&gt;54386&lt;/code&gt; and &lt;code&gt;54431&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=8545383309870854821&quot; alt=&quot;Heavy UDP traffic&quot;&gt;&lt;/p&gt;
&lt;p&gt;The datagrams themselves seem to contain highly structured data - decoding them as Real-time Transport Protocol (RTP) on Wireshark with &lt;code&gt;Right click on UDP packet -&amp;gt; Decode as -&amp;gt; RTP&lt;/code&gt; reveals that the captured UDP data is audio/video stream relay between Perenio Peifc01 IP camera and a smartphone via vendor controlled Amazon AWS EC2 instance. The following structure can be inferred from this packet capture:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;center&quot;&gt;Port number&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Protocol&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Purpose&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;50612&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;RTP over UDP&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Audio stream &lt;code&gt;IP camera -&amp;gt; EC2 instance&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;54386&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;RTP over UDP&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Video stream &lt;code&gt;IP camera -&amp;gt; EC2 instance&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;54431&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;SRTP over UDP&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Stream relay &lt;code&gt;EC2 instance -&amp;gt; smartphone&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;IP camera sends video/audio streams as plaintext RTP data over the network to an Amazon AWS EC2 instance, controlled by the vendor. EC2 instance then relays combined audio/video stream to Android application via SRTP (Secure RTP) channel.&lt;/p&gt;
&lt;h3 id=&quot;rtp-packet-structure&quot;&gt;RTP Packet Structure&lt;/h3&gt;
&lt;p&gt;RTP header, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc3550/#section-5.1&quot;&gt;as per RFC3550 specification&lt;/a&gt;, has the following structure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Version (V - 2 bits):&lt;/strong&gt; identifies RTP version.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Padding (P - 1 bit):&lt;/strong&gt; if set, the packet contains one or more padding octets at the end that are not part of payload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extension (X - 1 bit):&lt;/strong&gt; if set, fixed header must be followed by exactly one header extension.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRC count (CC - 4 bits):&lt;/strong&gt; number of CSRC identifiers that follow fixed header.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Marker (M - 1 bit):&lt;/strong&gt; intended to allow significant events (e.g. frame boundaries) to be marked in packet stream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payload type (PT - 7 bits):&lt;/strong&gt; format of RTP payload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sequence number (16 bits):&lt;/strong&gt; random number that increments by one for each RTP data packet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timestamp (32 bits):&lt;/strong&gt; sampling instant of the first octet in RTP data packet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synchronization source (SSRC) identifier (32bits):&lt;/strong&gt; identifies a synchronization source that &lt;strong&gt;&lt;em&gt;should&lt;/em&gt;&lt;/strong&gt; be selected randomly.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Visual RTP header representation from RFC3550 specification is presented below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=18202523034194486556&quot; alt=&quot;RTP header structure&quot;&gt;&lt;/p&gt;
&lt;p&gt;After a fixed header and CSRC identifiers (if present, RTP packet can have 0 to 15 32-bit long items as CSRC list entries right after SSRC), payload is added to the RTP packet. If padding is set to 1, a certain amount of padding bytes are appended to the packet after payload. Count of padding bytes is stored as a &lt;strong&gt;&lt;em&gt;last&lt;/em&gt;&lt;/strong&gt; byte of RTP packet (count includes this last byte as well).&lt;/p&gt;
&lt;h3 id=&quot;video-encoding&quot;&gt;Video Encoding&lt;/h3&gt;
&lt;p&gt;As a video stream codec, H.264 (also known as Advanced Video Coding - AVC) is used. When a video is encoded with a standard like H.264, the stream is sliced into Network Abstraction Layer (NAL) units for reliable data transmission over a network. However, RTP over UDP has a limit for how large a single network packet can be. This limit is called Maximum Transmission Unit (MTU) and for Ethernet/Wi-Fi networks it usually is 1500 bytes, &lt;strong&gt;&lt;em&gt;including&lt;/em&gt;&lt;/strong&gt; packet headers. Accounting for Ethernet/IPv4/UDP/RTP headers leaves 1460 bytes &lt;strong&gt;&lt;em&gt;at most&lt;/em&gt;&lt;/strong&gt; for the payload (additional header information is added to RTP packet if the packet has CSRC identifiers and/or is being sent via VPN/IPSec tunnel).&lt;/p&gt;
&lt;p&gt;Anyways, a large NAL unit can exceed single MTU, which means that such a NAL unit has to be split into multiple RTP packets for a successful transmission. For this purpose, H.264 has a defined Fragmentation Unit (FU), comprised of 1-byte FU Indicator and 1-byte FU Header.&lt;/p&gt;
&lt;h4 id=&quot;fu-indicator&quot;&gt;FU Indicator&lt;/h4&gt;
&lt;p&gt;FU Indicator is constructed against &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc6184/#section-1.3&quot;&gt;RFC 6184 standard, section 1.3&lt;/a&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;center&quot;&gt;Bits&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Name&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Description&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;0&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;F&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;forbidden_zero_bit&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;1-2&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;NRI&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;nal_ref_idc&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;3-7&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Type&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;nal_unit_type&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Forbidden zero bit:&lt;/strong&gt; normally equals 0. If this bit is set to 1, it indicates that a syntax violation / bit error was detected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NAL reference IDC (NRI):&lt;/strong&gt; when it equals 0, it means that this NAL unit is not used for reference picture reconstruction. Greater values indicate that NAL unit is required to maintain integrity of reference pictures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NAL unit type.&lt;/strong&gt; For example, Fragmentation Unit A (FU-A) has an unit type of &lt;code&gt;28&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Visual FU Indicator structure from &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc6184/#section-1.3&quot;&gt;RFC 6184 standard, section 1.3&lt;/a&gt; is presented below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=9202270763581905266&quot; alt=&quot;FU Indicator byte structure&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;fu-header&quot;&gt;FU Header&lt;/h4&gt;
&lt;p&gt;FU Header byte is present only on NAL Fragmentation Units and is constructed against &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc6184/#section-5.8&quot;&gt;RFC 6184 standard, section 5.8&lt;/a&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;center&quot;&gt;Bits&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Name&lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Description&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;0&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;S&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Start bit of fragmented NAL unit&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;1&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;E&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;End bit of fragmented NAL unit&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;2&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;R&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Reserved bit - must equal 0 and ignored by the receiver&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;center&quot;&gt;3-7&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Type&lt;/td&gt;&lt;td align=&quot;center&quot;&gt;NAL unit type&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Visual FU Header structure from &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc6184/#section-5.8&quot;&gt;RFC 6184 standard, section 5.8&lt;/a&gt; is presented below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7482509382101285050&quot; alt=&quot;FU Header byte structure&quot;&gt;&lt;/p&gt;
&lt;p&gt;When a NAL unit is fragmented into FU-A (and FU-B) units, the original one-byte NAL header is reconstructed by preserving the F and NRI bits from the FU indicator and taking the NAL-unit type from the FU header:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(FU Identifier &amp;amp; 0xE0) | (FU Header &amp;amp; 0x1F)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This operation swaps fragmentation unit type (28 for FU-A and 29 for FU-B) with the type of actual video data in NALU.&lt;/p&gt;
&lt;h3 id=&quot;stream-reconstruction-script&quot;&gt;Stream Reconstruction Script&lt;/h3&gt;
&lt;p&gt;Stream reconstruction script is &lt;a rel=&quot;nofollow&quot; href=&quot;./pythonImplementations/streamExtraction.py&quot;&gt;available as streamExtraction.py script in pythonImplementations folder&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;As of now, only the relevant features for Perenio Peifc01 IP camera's stream reconstruction are included in this script:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fragmentation Unit A (FU-A) reassembly into Network Abstraction Layer (NAL) unit.&lt;/li&gt;
&lt;li&gt;Reassembling RTP L16 (&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.rfc-editor.org/info/rfc3551/#section-4.5.11&quot;&gt;as defined in RFC 3551, section 4.5.11&lt;/a&gt;) raw uncompressed audio data samples into a &lt;code&gt;.wav&lt;/code&gt; audio file.&lt;/li&gt;
&lt;li&gt;Parsing fixed header fields of RTP packet.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Features that are not included in the script:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fragmentation Unit B (FU-B) reassembly into Network Abstraction Layer (NAL) unit.&lt;/li&gt;
&lt;li&gt;RTP padding removal, when it exists.&lt;/li&gt;
&lt;li&gt;Parsing (optional) CSRC fields of a RTP header.&lt;/li&gt;
&lt;li&gt;Encoding separate audio/video streams into a single media file.&lt;/li&gt;
&lt;li&gt;Audio / video synchronization and frame timing for muxing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The script, however, produces playable audio and video files, with a reconstructed video frame presented below:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=967454877087382659&quot; alt=&quot;Reconstructed video frame&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;conclusion-4&quot;&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Key points to summarize the security research of Perenio application:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Cryptographic method analysis of &lt;code&gt;Perenio Smart: Home and Office&lt;/code&gt; Android application revealed a few issues with how local data encryption is implemented.&lt;/li&gt;
&lt;li&gt;After successful login to Perenio account, &lt;code&gt;accessToken&lt;/code&gt; that is valid for 10 days is issued while the validity period of &lt;code&gt;refreshToken&lt;/code&gt; is 600 days.&lt;/li&gt;
&lt;li&gt;Perenio Peifc01 IP camera sends RTP packets over UDP with unencrypted audio and video streams to an Amazon AWS EC2 instance, meaning that the network traffic could be captured and audio / video data from Peifc01 IP camera be reconstructed by someone who has:&lt;ul&gt;
&lt;li&gt;Wi-Fi Pre-Shared Key (PSK) for the Wireless Local Area Network (WLAN), where Peifc01 IP camera resides.&lt;/li&gt;
&lt;li&gt;Wi-Fi adapter in monitor mode.&lt;/li&gt;
&lt;li&gt;Captures 4-way handshake as Peifc01 IP camera connects to the network.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;AWS EC2 instance relays the encoded file with combined audio/video to the Perenio Android application via Secure RTP (SRTP).&lt;/li&gt;
&lt;/ol&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28970/perenio-peifc01-ip-camera-and-android-application-analysis</guid>
<pubDate>Mon, 28 Sep 2026 20:00:58 +0000</pubDate>
</item>
<item>
<title>Building Payment Flows in Android: Lessons from Real Fintech Apps</title>
<link>https://coderlegion.com/28306/building-payment-flows-in-android-lessons-from-real-fintech-apps</link>
<description>&lt;h3 id=&quot;building-payment-flows-in-android-production-patterns-from-fintech-applications&quot;&gt;Building Payment Flows in Android: Production Patterns from Fintech Applications&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Subtitle:&lt;/strong&gt; Architectural patterns and state management strategies for reliable payment flows at scale&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Over the past months, I've been building and refining payment systems at scale. Every architectural decision compounds over time, and payment flows demand particular attention to state management, error handling, and offline resilience.&lt;/p&gt;
&lt;p&gt;This article documents the patterns that have proven effective in production fintech applications. These aren't theoretical approaches—they represent lessons learned from handling millions of transactions and the edge cases that accompany them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-payment-flows-require-specialized-architecture&quot;&gt;Why Payment Flows Require Specialized Architecture&lt;/h3&gt;
&lt;p&gt;Most mobile applications tolerate a certain degree of architectural looseness. A user can restart the app if something fails. Recovery is straightforward.&lt;/p&gt;
&lt;p&gt;Payment flows operate under different constraints:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;State Ambiguity is Costly&lt;/strong&gt;&lt;br&gt;
When a user initiates a payment, your application must maintain absolute clarity about its status: submitted, pending, failed, queued offline. If this state is unclear, the user is equally uncertain, leading to duplicate submissions or lost transactions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Race Conditions Have Financial Consequences&lt;/strong&gt;&lt;br&gt;
A user confirms payment and immediately navigates away. Your retry logic must account for this without resubmitting the payment or losing the transaction record.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Network Reliability Cannot Be Assumed&lt;/strong&gt;&lt;br&gt;
In many markets, network interruptions occur mid-transaction. Your system must handle incomplete requests gracefully—queuing the transaction locally and retrying when connectivity returns.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offline-First Is Essential&lt;/strong&gt;&lt;br&gt;
Users may initiate transactions without active connectivity. The application must queue payments locally, survive process termination, and submit reliably when network becomes available.&lt;/p&gt;
&lt;p&gt;This is why Clean Architecture and MVI pattern implementation is not optional for payment flows—it becomes a structural guarantee that state remains traceable and predictable.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architectural-foundation&quot;&gt;Architectural Foundation&lt;/h3&gt;
&lt;p&gt;For context on the underlying architecture, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@nerojust4/clean-architecture-in-android-a-real-world-guide-2025-edition-e5b4e950674c&quot;&gt;Clean Architecture in Android: A Real-World Guide&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The structure we use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;:domain&lt;/strong&gt; — Pure Kotlin business logic without Android dependencies&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;:data&lt;/strong&gt; — Repository layer managing offline queues, network calls, and persistence&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;:feature:payment&lt;/strong&gt; — MVI implementation containing Intent, UiState, ViewModel, and UI layers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This separation ensures business logic can be tested independently of Android runtime, catching state-related bugs before deployment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-1-payment-confirmation-flow-with-explicit-state&quot;&gt;Pattern 1: Payment Confirmation Flow with Explicit State&lt;/h3&gt;
&lt;h4 id=&quot;the-mvi-contract&quot;&gt;The MVI Contract&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;sealed class PaymentConfirmationIntent {
    data class LoadPaymentDetails(val paymentId: String) : PaymentConfirmationIntent()
    object ConfirmPayment : PaymentConfirmationIntent()
    object RetryPayment : PaymentConfirmationIntent()
    object CancelPayment : PaymentConfirmationIntent()
    object DismissError : PaymentConfirmationIntent()
}

data class PaymentConfirmationUiState(
    val paymentDetails: PaymentDetails? = null,
    val isLoading: Boolean = false,
    val isConfirming: Boolean = false,
    val error: PaymentError? = null,
    val isOffline: Boolean = false,
    val confirmationStatus: ConfirmationStatus = ConfirmationStatus.Idle,
) {
    enum class ConfirmationStatus {
        Idle,
        AwaitingUserConfirmation,
        Processing,
        Success,
        Failed,
        OfflineQueued,
    }
}

sealed class PaymentConfirmationEffect {
    object NavigateToSuccess : PaymentConfirmationEffect()
    object NavigateToFailure : PaymentConfirmationEffect()
    object NavigateBack : PaymentConfirmationEffect()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Structural Benefits:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ConfirmationStatus.OfflineQueued&lt;/code&gt; state explicitly represents the condition where payment has been submitted locally and awaits network availability. This eliminates ambiguity about whether a transaction was successfully queued.&lt;/p&gt;
&lt;p&gt;Each &lt;code&gt;Intent&lt;/code&gt; represents a discrete user action with no overlap, preventing state collisions. The &lt;code&gt;isOffline&lt;/code&gt; flag as part of UiState ensures the UI layer can respond to network conditions with appropriate messaging.&lt;/p&gt;
&lt;h4 id=&quot;viewmodel-implementation&quot;&gt;ViewModel Implementation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@HiltViewModel
class PaymentConfirmationViewModel @Inject constructor(
    private val paymentRepository: PaymentRepository,
    private val offlinePaymentQueue: OfflinePaymentQueue,
    private val networkManager: NetworkManager,
) : ViewModel() {

    private val _state = MutableStateFlow&amp;lt;PaymentConfirmationUiState&amp;gt;(
        PaymentConfirmationUiState()
    )
    val state: StateFlow&amp;lt;PaymentConfirmationUiState&amp;gt; = _state.asStateFlow()

    fun processIntent(intent: PaymentConfirmationIntent) {
        when (intent) {
            is PaymentConfirmationIntent.LoadPaymentDetails -&amp;gt; 
                loadPaymentDetails(intent.paymentId)
            is PaymentConfirmationIntent.ConfirmPayment -&amp;gt; 
                confirmPayment()
            is PaymentConfirmationIntent.RetryPayment -&amp;gt; 
                retryPayment()
            is PaymentConfirmationIntent.CancelPayment -&amp;gt; 
                cancelPayment()
            is PaymentConfirmationIntent.DismissError -&amp;gt; 
                dismissError()
        }
    }

    private fun confirmPayment() {
        viewModelScope.launch {
            _state.update { it.copy(isConfirming = true) }

            try {
                if (!networkManager.isConnected()) {
                    val queuedPayment = offlinePaymentQueue.enqueue(
                        _state.value.paymentDetails ?: return@launch
                    )
                    _state.update {
                        it.copy(
                            isConfirming = false,
                            confirmationStatus = 
                                PaymentConfirmationUiState.ConfirmationStatus.OfflineQueued,
                        )
                    }
                    return@launch
                }

                val result = paymentRepository.submitPayment(
                    _state.value.paymentDetails!!
                )

                when (result) {
                    is PaymentResult.Success -&amp;gt; {
                        _state.update {
                            it.copy(
                                isConfirming = false,
                                confirmationStatus = 
                                    PaymentConfirmationUiState.ConfirmationStatus.Success,
                            )
                        }
                    }
                    is PaymentResult.Failure -&amp;gt; {
                        _state.update {
                            it.copy(
                                isConfirming = false,
                                error = result.error,
                                confirmationStatus = 
                                    PaymentConfirmationUiState.ConfirmationStatus.Failed,
                            )
                        }
                    }
                }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        isConfirming = false,
                        error = PaymentError.Unknown(e.message ?: &quot;Unknown error&quot;),
                        confirmationStatus = 
                            PaymentConfirmationUiState.ConfirmationStatus.Failed,
                    )
                }
            }
        }
    }

    private fun retryPayment() {
        confirmPayment()
    }

    private fun dismissError() {
        _state.update { it.copy(error = null) }
    }

    private fun loadPaymentDetails(paymentId: String) {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            try {
                val details = paymentRepository.getPaymentDetails(paymentId)
                _state.update {
                    it.copy(
                        isLoading = false,
                        paymentDetails = details,
                        isOffline = !networkManager.isConnected(),
                    )
                }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        isLoading = false,
                        error = PaymentError.LoadFailed(
                            e.message ?: &quot;Failed to load&quot;
                        ),
                    )
                }
            }
        }
    }

    private fun cancelPayment() {
        // Navigate back without submission
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Key Implementation Details:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Network connectivity is checked before submission&lt;/li&gt;
&lt;li&gt;Offline state triggers local queueing with explicit status update&lt;/li&gt;
&lt;li&gt;Exception handling updates UI state rather than propagating errors&lt;/li&gt;
&lt;li&gt;All state transitions are explicit and traceable&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;ui-layer&quot;&gt;UI Layer&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Composable
fun PaymentConfirmationScreen(
    viewModel: PaymentConfirmationViewModel,
    paymentId: String,
    onSuccess: () -&amp;gt; Unit,
    onFailure: () -&amp;gt; Unit,
    onBack: () -&amp;gt; Unit,
) {
    val state by viewModel.state.collectAsState()

    LaunchedEffect(Unit) {
        viewModel.processIntent(
            PaymentConfirmationIntent.LoadPaymentDetails(paymentId)
        )
    }

    LaunchedEffect(state.confirmationStatus) {
        when (state.confirmationStatus) {
            PaymentConfirmationUiState.ConfirmationStatus.Success -&amp;gt; 
                onSuccess()
            PaymentConfirmationUiState.ConfirmationStatus.Failed -&amp;gt; 
                onFailure()
            else -&amp;gt; {}
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        when {
            state.isLoading -&amp;gt; {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            state.paymentDetails != null -&amp;gt; {
                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(16.dp),
                ) {
                    PaymentDetailsCard(state.paymentDetails)

                    if (state.isOffline) {
                        OfflineWarningBanner()
                    }

                    state.error?.let { error -&amp;gt;
                        ErrorBanner(
                            message = error.message,
                            onDismiss = {
                                viewModel.processIntent(
                                    PaymentConfirmationIntent.DismissError
                                )
                            }
                        )
                    }

                    Spacer(modifier = Modifier.weight(1f))

                    when (state.confirmationStatus) {
                        PaymentConfirmationUiState.ConfirmationStatus
                            .AwaitingUserConfirmation -&amp;gt; {
                            ConfirmationButtons(viewModel, onBack)
                        }
                        PaymentConfirmationUiState.ConfirmationStatus
                            .OfflineQueued -&amp;gt; {
                            Button(
                                onClick = { onBack() },
                                modifier = Modifier.fillMaxWidth(),
                            ) {
                                Text(&quot;Done&quot;)
                            }
                        }
                        PaymentConfirmationUiState.ConfirmationStatus.Failed -&amp;gt; {
                            Button(
                                onClick = {
                                    viewModel.processIntent(
                                        PaymentConfirmationIntent.RetryPayment
                                    )
                                },
                                modifier = Modifier.fillMaxWidth(),
                            ) {
                                Text(&quot;Retry&quot;)
                            }
                        }
                        else -&amp;gt; {}
                    }
                }
            }
            state.error != null -&amp;gt; {
                ErrorScreen(
                    error = state.error,
                    onRetry = {
                        viewModel.processIntent(
                            PaymentConfirmationIntent
                                .LoadPaymentDetails(paymentId)
                        )
                    },
                    onBack = onBack,
                )
            }
        }
    }
}

@Composable
private fun OfflineWarningBanner() {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = Color(0xFFFFF3CD)
        ),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Default.Warning,
                contentDescription = null,
                tint = Color(0xFFFF9800),
            )
            Text(
                text = &quot;You are offline. Your payment will be submitted when connectivity is restored.&quot;,
                fontSize = 14.sp,
                modifier = Modifier.weight(1f),
            )
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-2-offline-payment-queue-with-persistence&quot;&gt;Pattern 2: Offline Payment Queue with Persistence&lt;/h3&gt;
&lt;p&gt;Local queueing with durable storage is essential for offline-first applications.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// domain/payment/OfflinePaymentQueue.kt

data class QueuedPayment(
    val id: String,
    val payment: PaymentDetails,
    val timestamp: Long,
    val retryCount: Int = 0,
    val status: QueueStatus = QueueStatus.Pending,
) {
    enum class QueueStatus {
        Pending,
        Retrying,
        Submitted,
        Failed,
    }
}

interface OfflinePaymentQueue {
    suspend fun enqueue(payment: PaymentDetails): QueuedPayment
    suspend fun getAll(): List&amp;lt;QueuedPayment&amp;gt;
    suspend fun getById(id: String): QueuedPayment?
    suspend fun remove(id: String)
    suspend fun updateStatus(id: String, status: QueueStatus)
    suspend fun incrementRetry(id: String)
}

// data/payment/OfflinePaymentQueueImpl.kt

@Singleton
class OfflinePaymentQueueImpl @Inject constructor(
    private val paymentDao: PaymentQueueDao,
) : OfflinePaymentQueue {

    override suspend fun enqueue(payment: PaymentDetails): QueuedPayment {
        val queued = QueuedPayment(
            id = UUID.randomUUID().toString(),
            payment = payment,
            timestamp = System.currentTimeMillis(),
        )
        paymentDao.insert(queued.toEntity())
        return queued
    }

    override suspend fun getAll(): List&amp;lt;QueuedPayment&amp;gt; {
        return paymentDao.getAll().map { it.toDomain() }
    }

    override suspend fun updateStatus(
        id: String,
        status: QueueStatus
    ) {
        paymentDao.updateStatus(id, status.name)
    }

    override suspend fun remove(id: String) {
        paymentDao.delete(id)
    }

    override suspend fun incrementRetry(id: String) {
        paymentDao.incrementRetry(id)
    }

    override suspend fun getById(id: String): QueuedPayment? {
        return paymentDao.getById(id)?.toDomain()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Background synchronization handles submission when connectivity is restored:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// data/payment/PaymentSyncWorker.kt

class PaymentSyncWorker @Inject constructor(
    context: Context,
    params: WorkerParameters,
    private val offlineQueue: OfflinePaymentQueue,
    private val paymentRepository: PaymentRepository,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            val queuedPayments = offlineQueue.getAll()
                .filter { it.status == QueueStatus.Pending }

            queuedPayments.forEach { queued -&amp;gt;
                try {
                    offlineQueue.updateStatus(
                        queued.id,
                        QueueStatus.Retrying
                    )

                    val result = paymentRepository.submitPayment(
                        queued.payment
                    )

                    when (result) {
                        is PaymentResult.Success -&amp;gt; {
                            offlineQueue.updateStatus(
                                queued.id,
                                QueueStatus.Submitted
                            )
                            offlineQueue.remove(queued.id)
                        }
                        is PaymentResult.Failure -&amp;gt; {
                            if (queued.retryCount &amp;lt; MAX_RETRIES) {
                                offlineQueue.incrementRetry(queued.id)
                            } else {
                                offlineQueue.updateStatus(
                                    queued.id,
                                    QueueStatus.Failed
                                )
                            }
                        }
                    }
                } catch (e: Exception) {
                    if (queued.retryCount &amp;lt; MAX_RETRIES) {
                        offlineQueue.incrementRetry(queued.id)
                    } else {
                        offlineQueue.updateStatus(
                            queued.id,
                            QueueStatus.Failed
                        )
                    }
                }
            }

            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }

    companion object {
        private const val MAX_RETRIES = 5
        const val WORK_NAME = &quot;payment_sync&quot;
    }
}

// Schedule in application startup:
// WorkManager.getInstance(context).enqueueUniquePeriodicWork(
//     PaymentSyncWorker.WORK_NAME,
//     ExistingPeriodicWorkPolicy.KEEP,
//     PeriodicWorkRequestBuilder&amp;lt;PaymentSyncWorker&amp;gt;(
//         15, TimeUnit.MINUTES
//     ).build()
// )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Benefits of This Approach:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Payment data persists across process termination&lt;/li&gt;
&lt;li&gt;Retry logic respects maximum attempt limits&lt;/li&gt;
&lt;li&gt;Each payment has a unique identifier preventing duplicate submissions&lt;/li&gt;
&lt;li&gt;Status transitions are durable and traceable&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;pattern-3-biometric-authentication-for-payment-confirmation&quot;&gt;Pattern 3: Biometric Authentication for Payment Confirmation&lt;/h3&gt;
&lt;p&gt;Biometric authentication integrates as a discrete state within the payment flow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;sealed class BiometricAuthIntent {
    object AuthenticateWithBiometric : BiometricAuthIntent()
    object CancelBiometric : BiometricAuthIntent()
    object RetryBiometric : BiometricAuthIntent()
}

data class BiometricAuthUiState(
    val isAuthenticating: Boolean = false,
    val authResult: BiometricAuthResult? = null,
    val error: BiometricError? = null,
) {
    sealed class BiometricAuthResult {
        object Success : BiometricAuthResult()
        object Failure : BiometricAuthResult()
        object Cancelled : BiometricAuthResult()
    }
}

@HiltViewModel
class BiometricAuthViewModel @Inject constructor(
    private val biometricManager: BiometricManager,
) : ViewModel() {

    private val _state = MutableStateFlow&amp;lt;BiometricAuthUiState&amp;gt;(
        BiometricAuthUiState()
    )
    val state: StateFlow&amp;lt;BiometricAuthUiState&amp;gt; = _state.asStateFlow()

    fun processIntent(intent: BiometricAuthIntent) {
        when (intent) {
            BiometricAuthIntent.AuthenticateWithBiometric -&amp;gt; authenticate()
            BiometricAuthIntent.CancelBiometric -&amp;gt; cancel()
            BiometricAuthIntent.RetryBiometric -&amp;gt; authenticate()
        }
    }

    private fun authenticate() {
        viewModelScope.launch {
            _state.update { it.copy(isAuthenticating = true) }

            try {
                val result = biometricManager.authenticate()
                _state.update {
                    it.copy(
                        isAuthenticating = false,
                        authResult = result,
                    )
                }
            } catch (e: BiometricException) {
                _state.update {
                    it.copy(
                        isAuthenticating = false,
                        error = BiometricError.AuthenticationFailed(
                            e.message ?: &quot;Authentication failed&quot;
                        ),
                    )
                }
            }
        }
    }

    private fun cancel() {
        biometricManager.cancel()
        _state.update {
            it.copy(
                isAuthenticating = false,
                authResult = BiometricAuthUiState.BiometricAuthResult
                    .Cancelled,
            )
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Biometric authentication state remains consistent with the MVI pattern rather than introducing separate concerns.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;transaction-flow-example&quot;&gt;Transaction Flow Example&lt;/h3&gt;
&lt;p&gt;Consider a complete transaction lifecycle:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User initiates payment confirmation&lt;/li&gt;
&lt;li&gt;App checks network connectivity&lt;/li&gt;
&lt;li&gt;If offline: payment queued locally with &lt;code&gt;OfflineQueued&lt;/code&gt; status&lt;/li&gt;
&lt;li&gt;If online: payment submitted to server&lt;/li&gt;
&lt;li&gt;On network restoration: background worker processes offline queue&lt;/li&gt;
&lt;li&gt;Each queued payment retried up to 5 times before marking failed&lt;/li&gt;
&lt;li&gt;User notified of final status through UI state updates&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This approach ensures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No state ambiguity at any point&lt;/li&gt;
&lt;li&gt;Network interruptions don't result in lost transactions&lt;/li&gt;
&lt;li&gt;Duplicate submissions are prevented&lt;/li&gt;
&lt;li&gt;Process termination doesn't destroy payment records&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;open-source-implementation&quot;&gt;Open-Source Implementation&lt;/h3&gt;
&lt;p&gt;These patterns are available in production-ready form:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nerojust/clean-architecture-android&quot;&gt;clean-architecture-android&lt;/a&gt;&lt;/strong&gt; — Foundation template with Clean Architecture, MVI, and Jetpack Compose&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nerojust/fintech-mobile-patterns-android&quot;&gt;fintech-mobile-patterns-android&lt;/a&gt;&lt;/strong&gt; — Payment flow implementations with offline queuing, error handling, and authentication patterns&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both repositories include complete implementations with testing examples.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-5&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Payment flow architecture demands explicit state management and thoughtful error handling. The patterns discussed—explicit state enums, offline queuing with persistence, and MVI-based architecture—provide structural guarantees that reduce the likelihood of state-related bugs.&lt;/p&gt;
&lt;p&gt;These approaches have proven effective at production scale. They prioritize clarity of state over brevity of code, understanding that payment systems benefit more from predictability than elegance.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;additional-resources&quot;&gt;Additional Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@nerojust4/clean-architecture-in-android-a-real-world-guide-2025-edition-e5b4e950674c&quot;&gt;Clean Architecture in Android: A Real-World Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@nerojust4/mastering-dependency-injection-with-hilt-in-android-81b3d221da9a&quot;&gt;Mastering Dependency Injection with Hilt in Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/nyerhovwo-adjekughene-97508674&quot;&gt;LinkedIn&lt;/a&gt; &lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nerojust&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28306/building-payment-flows-in-android-lessons-from-real-fintech-apps</guid>
<pubDate>Wed, 23 Sep 2026 09:35:18 +0000</pubDate>
</item>
<item>
<title>Android CI/CD for Beginners — Part 1: Introduction</title>
<link>https://coderlegion.com/28201/android-ci-cd-for-beginners-part-1-introduction</link>
<description>&lt;p&gt;Stop building everything manually. Learn how modern Android teams automate their development workflow&lt;/p&gt;
&lt;p&gt;This series is designed to provide a complete understanding of CI/CD for Android development. In Part 1, we’ll introduce the fundamentals of CI/CD, explore what it is, why it matters, and how it’s used in Android app development.&lt;/p&gt;
&lt;p&gt;CI/CD (Continuous Integration and Continuous Delivery/Deployment) is a set of practices that automate repetitive tasks in the software development process, such as building, testing, and releasing applications.&lt;/p&gt;
&lt;p&gt;Instead of manually performing these tasks every time you make a change, a CI/CD pipeline executes them automatically whenever predefined events occur, such as pushing code to a Git repository.&lt;/p&gt;
&lt;p&gt;How can CI/CD automate tasks in Android development?&lt;br&gt;
CI/CD automates many of the repetitive tasks involved in the Android development lifecycle. Instead of running these steps manually every time you make a change, they can be executed automatically whenever code is pushed to your repository.&lt;/p&gt;
&lt;p&gt;With a CI/CD pipeline, your Android app can be automatically:&lt;/p&gt;
&lt;p&gt;Built using Gradle.&lt;br&gt;
Analyzed for code quality with Android Lint.&lt;br&gt;
Tested by running unit and instrumentation tests.&lt;br&gt;
Packaged into a signed or unsigned APK or Android App Bundle (AAB).&lt;br&gt;
Distributed to testers or prepared for release to the Google Play Store.&lt;br&gt;
You might be wondering: Why do we need to automate these tasks?&lt;/p&gt;
&lt;p&gt;In real-world Android projects, multiple developers often work on the same codebase. Every new feature, bug fix, or improvement is usually submitted as a pull request (PR).&lt;/p&gt;
&lt;p&gt;Before those changes are merged, the team needs to ensure they meet the project’s quality standards and don’t introduce bugs or break existing functionality.&lt;/p&gt;
&lt;p&gt;Manually building the app, running lint checks, executing tests, and verifying every pull request can quickly become repetitive and time-consuming, especially as the project grows.&lt;/p&gt;
&lt;p&gt;This is where CI/CD becomes valuable. Whenever a pull request is opened or updated, a CI pipeline can automatically build the app, run lint checks, execute unit and instrumentation tests, and report whether all checks have passed.&lt;/p&gt;
&lt;p&gt;If any step fails, developers are notified immediately, allowing issues to be fixed before the changes are merged.&lt;/p&gt;
&lt;p&gt;Once all the automated checks succeed, the CD pipeline can take over by generating an APK or App Bundle and distributing it to testers or preparing it for release.&lt;/p&gt;
&lt;p&gt;This eliminates many manual steps, speeds up the development process, and helps maintain a stable and reliable codebase.&lt;/p&gt;
&lt;p&gt;Common CI/CD Terminology&lt;br&gt;
Before we start building our first CI/CD pipeline, it’s important to understand some common terms you’ll encounter throughout this series. These concepts are not exclusive to Android development, they’re used across many CI/CD platforms. We’ll explore how these ideas come together in practice in the upcoming sections.&lt;/p&gt;
&lt;p&gt;Pipeline&lt;br&gt;
A pipeline is the complete automated process that runs after a specific event, such as pushing code to a Git repository or opening a pull request. A pipeline can build your app, run tests, generate an APK, and even distribute it to testers — all without manual intervention.&lt;/p&gt;
&lt;p&gt;Think of a pipeline as a sequence of automated tasks executed in a predefined order.&lt;/p&gt;
&lt;p&gt;Workflow&lt;br&gt;
In GitHub Actions, a pipeline is defined using a workflow. A workflow is a YAML (.yml or .yaml) file stored inside the .github/workflows directory of your project.&lt;/p&gt;
&lt;p&gt;It describes what should happen, when it should happen, and how it should be executed.&lt;/p&gt;
&lt;p&gt;A repository can contain multiple workflows, each responsible for different tasks. For example, one workflow may run tests for every pull request, while another creates a release build whenever a new version is published.&lt;/p&gt;
&lt;p&gt;Event (Trigger)&lt;br&gt;
A workflow doesn’t run on its own — it needs an event, also known as a trigger.&lt;/p&gt;
&lt;p&gt;Common triggers include:&lt;/p&gt;
&lt;p&gt;Pushing code to a branch (push)&lt;br&gt;
Opening or updating a pull request (pull_request)&lt;br&gt;
Manually starting a workflow (workflow_dispatch)&lt;br&gt;
Creating a release (release)&lt;br&gt;
Whenever one of these events occurs, GitHub Actions starts the corresponding workflow.&lt;/p&gt;
&lt;p&gt;Runner&lt;br&gt;
A runner is the machine responsible for executing your workflow.&lt;/p&gt;
&lt;p&gt;GitHub provides hosted runners running Linux, Windows, and macOS. For Android projects, Linux runners are commonly used because they provide a fast and cost-effective environment for building Android applications.&lt;/p&gt;
&lt;p&gt;You can also configure your own self-hosted runner if you need custom hardware or software.&lt;/p&gt;
&lt;p&gt;Job&lt;br&gt;
A job is a collection of related tasks executed on the same runner.&lt;/p&gt;
&lt;p&gt;For example, one job might build the Android app, while another runs unit tests. Multiple jobs can run sequentially or in parallel, depending on how the workflow is configured.&lt;/p&gt;
&lt;p&gt;Step&lt;br&gt;
A step is a single task inside a job.&lt;/p&gt;
&lt;p&gt;Examples of steps include:&lt;/p&gt;
&lt;p&gt;Checking out the repository&lt;br&gt;
Setting up Java&lt;br&gt;
Installing Android dependencies&lt;br&gt;
Running Gradle commands&lt;br&gt;
Uploading the generated APK&lt;br&gt;
A job is simply a sequence of steps executed one after another.&lt;/p&gt;
&lt;p&gt;Action&lt;br&gt;
An Action is a reusable piece of automation that performs a specific task.&lt;/p&gt;
&lt;p&gt;Instead of writing everything from scratch, you can use actions created by GitHub or the community.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;p&gt;actions/checkout downloads your repository onto the runner.&lt;br&gt;
actions/setup-java installs and configures Java.&lt;br&gt;
actions/upload-artifact uploads build outputs such as APKs or test reports.&lt;br&gt;
Actions make workflows shorter, cleaner, and easier to maintain.&lt;/p&gt;
&lt;p&gt;Artifact&lt;br&gt;
An artifact is a file generated during a workflow that you want to keep after the workflow finishes.&lt;/p&gt;
&lt;p&gt;In Android projects, common artifacts include:&lt;/p&gt;
&lt;p&gt;APK files&lt;br&gt;
Android App Bundles (AAB)&lt;br&gt;
Lint reports&lt;br&gt;
Unit test reports&lt;br&gt;
Code coverage reports&lt;br&gt;
Artifacts can be downloaded later for testing, debugging, or distribution.&lt;/p&gt;
&lt;p&gt;GitHub Actions&lt;br&gt;
Throughout this series, we’ll use GitHub Actions as our CI/CD platform.&lt;/p&gt;
&lt;p&gt;GitHub Actions is GitHub’s built-in automation service that allows you to create workflows using YAML files. It automatically responds to events in your repository, runs your workflows on runners, and reports the results all directly within GitHub.&lt;/p&gt;
&lt;p&gt;Now that you’re familiar with these core concepts, we’ll start creating our first Android CI workflow in the next part of this series.&lt;/p&gt;
&lt;p&gt;That all it for now…&lt;br&gt;
Lets stop here for now as this is already too much if your just starting out&lt;/p&gt;
&lt;p&gt;Now that you understand the purpose of CI/CD and the terminology behind it, it’s time to build your first pipeline.&lt;/p&gt;
&lt;p&gt;In Part 2, we’ll set up GitHub Actions, create our first workflow, and automatically build an Android application whenever code is pushed to GitHub.&lt;/p&gt;
&lt;p&gt;👋Bye!!!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28201/android-ci-cd-for-beginners-part-1-introduction</guid>
<pubDate>Tue, 22 Sep 2026 17:12:07 +0000</pubDate>
</item>
<item>
<title>Android System Back Button Closes the App After Upgrading to Target SDK 36 — React Native Fix</title>
<link>https://coderlegion.com/28094/android-system-back-button-closes-the-app-after-upgrading-to-target-sdk-36-react-native-fix</link>
<description>&lt;p&gt;If you're maintaining a React Native Android application and recently upgraded your project to &lt;strong&gt;target SDK 36 (Android 16)&lt;/strong&gt;, you may encounter an unexpected navigation issue:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Pressing the Android system back button closes the app instead of navigating to the previous screen.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This can be particularly confusing when React Navigation is already configured correctly and the same navigation flow works perfectly on older Android target SDK versions.&lt;/p&gt;
&lt;p&gt;This article explains what changed, how to identify the issue, the workaround I used, and what you should consider for a proper Android 16 migration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;
&lt;p&gt;After upgrading the Android project from an older target SDK to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;targetSdkVersion = 36
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the app started behaving differently when using the &lt;strong&gt;Android system back button&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;For example, suppose the navigation stack is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
  ↓
Products
  ↓
Product Details
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the user presses the Android system back button from &lt;strong&gt;Product Details&lt;/strong&gt;, the expected behavior is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Product Details
        ↓
    Products
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, the application could close.&lt;/p&gt;
&lt;p&gt;Interestingly, navigation using React Navigation itself continued to work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;navigation.goBack();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The issue was specifically related to the &lt;strong&gt;Android system back event&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;environment&quot;&gt;Environment&lt;/h2&gt;
&lt;p&gt;The issue was observed in a React Native application with a setup similar to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;React Native
React Navigation
Android SDK 36
Target SDK 36
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important change was upgrading the Android target SDK to &lt;strong&gt;API level 36&lt;/strong&gt;, corresponding to Android 16.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-does-this-happen-&quot;&gt;Why Does This Happen?&lt;/h2&gt;
&lt;p&gt;The important part of the migration is the Android back-navigation change.&lt;/p&gt;
&lt;p&gt;Android has been moving away from the older:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;onBackPressed()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mechanism toward the newer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OnBackInvokedDispatcher
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and predictive back navigation APIs.&lt;/p&gt;
&lt;p&gt;With newer Android versions and target SDK requirements, applications need to properly participate in the newer back-navigation system.&lt;/p&gt;
&lt;p&gt;This can expose compatibility issues in applications or libraries that still rely on the older back handling mechanism.&lt;/p&gt;
&lt;p&gt;In a React Native application, the Android system back event eventually needs to be correctly propagated into the React Native / React Navigation layer.&lt;/p&gt;
&lt;p&gt;If that doesn't happen, Android may interpret the back action as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;No navigation handler
        ↓
Finish current Activity
        ↓
App closes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;System Back
     ↓
React Native Back Handler
     ↓
React Navigation
     ↓
Previous screen
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-i-identified-the-problem&quot;&gt;How I Identified the Problem&lt;/h2&gt;
&lt;p&gt;The first thing I checked was whether React Navigation itself was working.&lt;/p&gt;
&lt;p&gt;Navigation from the application was working correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;navigation.navigate(&quot;ProductDetails&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and programmatic back navigation also worked:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;navigation.goBack();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem only occurred when pressing the &lt;strong&gt;physical/system Android back button&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I also checked whether the application was closing directly from the Home screen.&lt;/p&gt;
&lt;p&gt;That behavior is expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
 ↓
Android Back
 ↓
App exits
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The actual problem was:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
 ↓
Product List
 ↓
Product Details
 ↓
Android Back
 ↓
App exits ❌
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
 ↓
Product List
 ↓
Product Details
 ↓
Android Back
 ↓
Product List ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This distinction is important when debugging Android back navigation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-androidmanifest-configuration&quot;&gt;The AndroidManifest Configuration&lt;/h2&gt;
&lt;p&gt;The Android application can control the behavior of the newer back system using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;application
    android:name=&quot;.MainApplication&quot;
    android:label=&quot;@string/app_name&quot;
    android:icon=&quot;@mipmap/ic_launcher&quot;
    android:enableOnBackInvokedCallback=&quot;false&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my case, setting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback=&quot;false&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;worked as a temporary workaround.&lt;/p&gt;
&lt;p&gt;After adding this configuration, the Android system back button once again started navigating to the previous React Navigation screen instead of immediately closing the application.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-does-false-work-&quot;&gt;Why Does &lt;code&gt;false&lt;/code&gt; Work?&lt;/h2&gt;
&lt;p&gt;Setting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback=&quot;false&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;essentially disables the application's participation in the newer &lt;code&gt;OnBackInvokedDispatcher&lt;/code&gt; behavior.&lt;/p&gt;
&lt;p&gt;This allows the application to continue using the older back handling path.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;h4 id=&quot;without-the-workaround&quot;&gt;Without the workaround&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Android System Back
        ↓
OnBackInvokedDispatcher
        ↓
React Native / Navigation handler
        ↓
❌ Handler not properly triggered
        ↓
Activity finishes
        ↓
App closes
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;with-the-temporary-workaround&quot;&gt;With the temporary workaround&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Android System Back
        ↓
Legacy back handling
        ↓
React Native BackHandler
        ↓
React Navigation
        ↓
Previous screen
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the workaround doesn't really &quot;fix&quot; predictive back support.&lt;/p&gt;
&lt;p&gt;It tells Android to avoid the newer callback behavior for this application.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;is-enableonbackinvokedcallback-false-the-permanent-fix-&quot;&gt;Is &lt;code&gt;enableOnBackInvokedCallback=&quot;false&quot;&lt;/code&gt; the Permanent Fix?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Not necessarily.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is an important distinction.&lt;/p&gt;
&lt;p&gt;If your application is targeting Android 16, you should ideally investigate whether your React Native version and navigation dependencies properly support Android's newer back-navigation behavior.&lt;/p&gt;
&lt;p&gt;Using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback=&quot;false&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;can be useful as a compatibility workaround while migrating, but it should not automatically be considered the final Android 16 solution.&lt;/p&gt;
&lt;p&gt;The long-term goal should be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Android 16
      ↓
Predictive Back
      ↓
OnBackInvokedDispatcher
      ↓
React Native
      ↓
React Navigation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;working correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;check-your-react-native-version&quot;&gt;Check Your React Native Version&lt;/h2&gt;
&lt;p&gt;Before adding application-level workarounds, check the React Native version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm list react-native
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yarn why react-native
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also check React Navigation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm list @react-navigation/native
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;react-native
@react-navigation/native
@react-navigation/native-stack
@react-navigation/bottom-tabs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Older combinations may have different levels of Android predictive-back support.&lt;/p&gt;
&lt;p&gt;It's important to evaluate the complete dependency chain rather than changing only the AndroidManifest.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;check-your-mainactivity&quot;&gt;Check Your &lt;code&gt;MainActivity&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Also inspect your Android &lt;code&gt;MainActivity&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A typical React Native application may have something similar to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public class MainActivity extends ReactActivity {

    @Override
    protected String getMainComponentName() {
        return &quot;MyApp&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your application has custom back handling, search the Android project for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;onBackPressed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -R &quot;onBackPressed&quot; android/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also search for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OnBackInvokedDispatcher
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OnBackInvokedCallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This helps identify whether custom native code is interfering with the new Android back-navigation mechanism.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;check-react-native-backhandler&quot;&gt;Check React Native &lt;code&gt;BackHandler&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;React Native applications can listen for Android back events using:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import { BackHandler } from &quot;react-native&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  const subscription = BackHandler.addEventListener(
    &quot;hardwareBackPress&quot;,
    () =&amp;gt; {
      navigation.goBack();
      return true;
    }
  );

  return () =&amp;gt; subscription.remove();
}, [navigation]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, &lt;strong&gt;don't add this globally just to hide the problem&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;React Navigation normally handles Android back navigation for its navigation stack.&lt;/p&gt;
&lt;p&gt;Adding multiple global &lt;code&gt;BackHandler&lt;/code&gt; listeners can introduce new problems such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Back event being consumed too early&lt;/li&gt;
&lt;li&gt;Incorrect navigation&lt;/li&gt;
&lt;li&gt;Screens being popped multiple times&lt;/li&gt;
&lt;li&gt;Modals not closing correctly&lt;/li&gt;
&lt;li&gt;Navigation stack becoming inconsistent&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So first determine whether the underlying Android back event is reaching React Native correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-useful-debugging-test&quot;&gt;A Useful Debugging Test&lt;/h2&gt;
&lt;p&gt;You can temporarily add a &lt;code&gt;BackHandler&lt;/code&gt; listener to determine whether React Native receives the system back event:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useEffect(() =&amp;gt; {
  const subscription = BackHandler.addEventListener(
    &quot;hardwareBackPress&quot;,
    () =&amp;gt; {
      console.log(&quot;ANDROID BACK EVENT RECEIVED&quot;);

      return false;
    }
  );

  return () =&amp;gt; subscription.remove();
}, []);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then navigate to a screen and press the Android system back button.&lt;/p&gt;
&lt;p&gt;If you see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ANDROID BACK EVENT RECEIVED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;then the event is reaching React Native.&lt;/p&gt;
&lt;p&gt;If the app closes without the event reaching your JavaScript handler, the problem is likely further down in the Android back-dispatching path.&lt;/p&gt;
&lt;p&gt;This is a useful way to separate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Android back event problem
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;React Navigation configuration problem
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id=&quot;don-t-confuse-home-screen-behavior-with-a-bug&quot;&gt;Don't Confuse Home Screen Behavior With a Bug&lt;/h2&gt;
&lt;p&gt;One important testing detail:&lt;/p&gt;
&lt;p&gt;If the user is already on the root screen:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pressing Android Back is normally expected to exit the application.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
 ↓
Android Back
 ↓
App closes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That alone isn't evidence of a problem.&lt;/p&gt;
&lt;p&gt;The important test is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
 ↓
Screen A
 ↓
Screen B
 ↓
Android Back
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Screen B
 ↓
Screen A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Screen B
 ↓
App closes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;then back navigation is not being handled correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;testing-checklist-after-target-sdk-36-upgrade&quot;&gt;Testing Checklist After Target SDK 36 Upgrade&lt;/h2&gt;
&lt;p&gt;After upgrading to API 36, I recommend testing all of these scenarios.&lt;/p&gt;
&lt;h4 id=&quot;1-normal-navigation&quot;&gt;1. Normal navigation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home → List → Details
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Press system Back.&lt;/p&gt;
&lt;p&gt;Expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Details → List
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-multiple-navigation-levels&quot;&gt;2. Multiple navigation levels&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home → A → B → C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Press Back repeatedly.&lt;/p&gt;
&lt;p&gt;Expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;C → B → A → Home
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-root-screen&quot;&gt;3. Root screen&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Home
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Press Back.&lt;/p&gt;
&lt;p&gt;Expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;App exits
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;4-modal&quot;&gt;4. Modal&lt;/h4&gt;
&lt;p&gt;Open a modal and press Back.&lt;/p&gt;
&lt;p&gt;Expected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Modal closes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;instead of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;App exits
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;5-bottom-tabs&quot;&gt;5. Bottom tabs&lt;/h4&gt;
&lt;p&gt;Test:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Tab A → Tab B → Screen
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and verify that system Back behaves consistently with your navigation design.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;6-authentication-flow&quot;&gt;6. Authentication flow&lt;/h4&gt;
&lt;p&gt;Test:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Login → OTP → Home
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and make sure Back doesn't allow users to incorrectly return to authentication screens.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;7-deep-links&quot;&gt;7. Deep links&lt;/h4&gt;
&lt;p&gt;Test:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Deep Link → Details
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and verify the back stack behaves as expected.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;8-android-gesture-navigation&quot;&gt;8. Android gesture navigation&lt;/h4&gt;
&lt;p&gt;Test using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3-button navigation&lt;/li&gt;
&lt;li&gt;Gesture navigation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Predictive back behavior can differ from traditional button-based testing, so both should be tested.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;temporary-workaround&quot;&gt;Temporary Workaround&lt;/h2&gt;
&lt;p&gt;If your application is currently affected and you need to stabilize the release, one possible temporary configuration is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;application
    ...
    android:enableOnBackInvokedCallback=&quot;false&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After rebuilding the application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
cd ..
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native run-android
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or use your normal release build process.&lt;/p&gt;
&lt;p&gt;For an Expo bare/prebuild project, make sure the generated Android configuration is also correctly reflected in the source configuration you use for builds, rather than relying on a manual change that can be overwritten.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;recommended-migration-approach&quot;&gt;Recommended Migration Approach&lt;/h2&gt;
&lt;p&gt;Instead of treating the manifest flag as the final solution, I recommend approaching the migration in this order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. Upgrade target SDK → 36
          ↓
2. Test system Back
          ↓
3. Check React Native version
          ↓
4. Check React Navigation versions
          ↓
5. Search for custom Android back handling
          ↓
6. Search for BackHandler overrides
          ↓
7. Test predictive back
          ↓
8. Update incompatible dependencies
          ↓
9. Use manifest workaround if required
          ↓
10. Remove workaround after proper migration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach makes it easier to identify the actual compatibility problem.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;important-takeaway&quot;&gt;Important Takeaway&lt;/h2&gt;
&lt;p&gt;Upgrading:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;targetSdkVersion
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not always just a build configuration change.&lt;/p&gt;
&lt;p&gt;A target SDK upgrade can activate or expose new Android platform behavior.&lt;/p&gt;
&lt;p&gt;For Android 16 / API 36, &lt;strong&gt;back navigation and predictive back behavior are especially important areas to test&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;If your React Native app suddenly starts closing when the Android system back button is pressed after moving to target SDK 36, don't immediately assume that your React Navigation stack is broken.&lt;/p&gt;
&lt;p&gt;First determine whether:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Android
   ↓
Back Dispatcher
   ↓
React Native
   ↓
React Navigation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is working correctly.&lt;/p&gt;
&lt;p&gt;The following configuration can serve as a temporary compatibility workaround:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback=&quot;false&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but the preferred long-term solution is to ensure that the React Native and navigation stack properly supports Android's modern back-navigation APIs.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-checklist&quot;&gt;Final Checklist&lt;/h2&gt;
&lt;p&gt;Before releasing a target SDK 36 build, verify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Android system Back navigates correctly&lt;/li&gt;
&lt;li&gt;[ ] Root screen exits correctly&lt;/li&gt;
&lt;li&gt;[ ] Nested navigation works&lt;/li&gt;
&lt;li&gt;[ ] Modals handle Back correctly&lt;/li&gt;
&lt;li&gt;[ ] Bottom tabs behave correctly&lt;/li&gt;
&lt;li&gt;[ ] Authentication screens behave correctly&lt;/li&gt;
&lt;li&gt;[ ] Deep links create the expected back stack&lt;/li&gt;
&lt;li&gt;[ ] Gesture navigation works&lt;/li&gt;
&lt;li&gt;[ ] 3-button navigation works&lt;/li&gt;
&lt;li&gt;[ ] Predictive back behavior has been tested&lt;/li&gt;
&lt;li&gt;[ ] React Native version is compatible&lt;/li&gt;
&lt;li&gt;[ ] React Navigation dependencies are compatible&lt;/li&gt;
&lt;li&gt;[ ] Custom &lt;code&gt;BackHandler&lt;/code&gt; logic has been reviewed&lt;/li&gt;
&lt;li&gt;[ ] Native &lt;code&gt;onBackPressed()&lt;/code&gt; implementations have been reviewed&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;OnBackInvokedDispatcher&lt;/code&gt; usage has been reviewed&lt;/li&gt;
&lt;li&gt;[ ] Any temporary &lt;code&gt;enableOnBackInvokedCallback&lt;/code&gt; workaround is documented&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-6&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Android 16 introduces another step in the evolution of Android back navigation. For React Native applications, upgrading to &lt;strong&gt;target SDK 36&lt;/strong&gt; can expose problems where the system Back button no longer reaches the JavaScript navigation layer as expected.&lt;/p&gt;
&lt;p&gt;If your app starts closing instead of navigating backward, investigate the new Android back-dispatching behavior before modifying your navigation stack.&lt;/p&gt;
&lt;p&gt;A manifest-level workaround such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;android:enableOnBackInvokedCallback=&quot;false&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;may restore the previous behavior temporarily, but dependency compatibility and proper predictive-back support should be addressed as part of the complete migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Target SDK upgrades should always be followed by dedicated navigation regression testing—not just build and installation testing.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;✍️ Written by &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/dainyjose&quot;&gt;&lt;strong&gt;Dainy Jose&lt;/strong&gt;&lt;/a&gt; — React Native Mobile Application Developer with 3+ years of experience building cross-platform mobile apps using &lt;strong&gt;React Native (Expo, TypeScript, Redux)&lt;/strong&gt;. &lt;br&gt;
Currently expanding backend knowledge through the &lt;strong&gt;MERN Stack (MongoDB, Express.js, React.js, Node.js)&lt;/strong&gt; to create more efficient, full-stack mobile experiences.&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;Tech Stack:&lt;/strong&gt; React Native · TypeScript · Redux · Expo · Firebase · Node.js · Express.js · MongoDB · REST API · JWT · Jest · Google Maps · Razorpay · PayU · Agile · SDLC · Git · Bitbucket · Jira &lt;/p&gt;
&lt;h2 id=&quot;section&quot;&gt;&lt;/h2&gt;
&lt;p&gt; &lt;strong&gt;Connect with me&lt;/strong&gt;:&lt;br&gt;
 &lt;a rel=&quot;nofollow&quot; href=&quot;https://dainyjose.github.io/my-portfolio/&quot;&gt;Portfolio&lt;/a&gt;&lt;br&gt;
 &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/dainyjose&quot;&gt;LinkedIn&lt;/a&gt;&lt;br&gt;
 &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dainyjose&quot;&gt;GitHub&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28094/android-system-back-button-closes-the-app-after-upgrading-to-target-sdk-36-react-native-fix</guid>
<pubDate>Tue, 22 Sep 2026 04:30:24 +0000</pubDate>
</item>
<item>
<title>Google Play’s 2027 app quality requirements</title>
<link>https://coderlegion.com/27177/google-plays-2027-app-quality-requirements</link>
<description>&lt;p&gt;Google Play is introducing new requirements for memory usage, code optimization, and automatic login restoration when users change Android devices. Announced on August 26, 2026, the changes take effect in February and April 2027.&lt;/p&gt;
&lt;h3 id=&quot;memory-requirements-from-february-2027&quot;&gt;Memory requirements from February 2027&lt;/h3&gt;
&lt;p&gt;Play will assess dynamic memory usage, measured as anonymous RSS plus swap, and memory held by bitmaps. The thresholds vary by process state, with separate dynamic memory limits for apps and games across device RAM tiers. The Android vitals tables list the applicable limits.&lt;/p&gt;
&lt;p&gt;For phones and tablets, Play uses P90 measurements and the last 28 days of data. Evaluation includes available data from Android 13 onward. These requirements therefore reach beyond apps running on Android 17. Measurement scope.&lt;/p&gt;
&lt;p&gt;Android 17 also introduces limits enforced by the operating system. Excessive memory use can trigger additional compression work and eventually process termination. Teams should examine memory use across foreground, background, and cached states.&lt;/p&gt;
&lt;h3 id=&quot;dex-optimization-from-february-2027&quot;&gt;DEX optimization from February 2027&lt;/h3&gt;
&lt;p&gt;Qualifying uploads must meet a minimum of 25% coverage in each of three areas: optimization, shrinking, and obfuscation. The requirement applies to apps with more than 10 MB of DEX code and games with more than 50 MB. These are DEX code thresholds, not total download sizes. DEX requirements.&lt;/p&gt;
&lt;p&gt;Google recommends R8, although another optimizer can meet the requirement. Review optimization settings and keep rules, then inspect the percentages reported for the actual bundle uploaded to Play Console.&lt;/p&gt;
&lt;h3 id=&quot;login-restoration-from-april-2027&quot;&gt;Login restoration from April 2027&lt;/h3&gt;
&lt;p&gt;Apps offering optional or mandatory login must support automatic restoration of the user’s authenticated state when they transfer to another Android device. Google calls this Zero-Tap Sign-In. Games are currently exempt. Migration requirement.&lt;/p&gt;
&lt;p&gt;The requirement covers phones and tablets. Apps without accounts are unaffected, and guests should remain guests. Block Store integrations qualify only if they successfully restore login and are in production by September 30, 2026. Other exceptions and exemption criteria are detailed in the technical requirements.&lt;/p&gt;
&lt;p&gt;The recommended implementation uses Restore Credentials through Credential Manager. It includes creating a restore key for an authenticated user, handling restoration with the app’s backend, and deleting the key on logout. Plan for both app and backend changes.&lt;/p&gt;
&lt;h3 id=&quot;checks-to-schedule-now&quot;&gt;Checks to schedule now&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Inspect Android vitals memory metrics by device RAM tier and process state, including behavior after leaving the app.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Review the uploaded release bundle’s DEX size and optimization results. Test critical flows after changing R8 rules.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Audit the device migration flow with the authentication team and test both device transfer and cloud restoration.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Google says apps that miss the requirements may face reduced store visibility and publishing capabilities. Further enforcement details are still to come. Check the &lt;a rel=&quot;nofollow&quot; href=&quot;https://android-developers.googleblog.com/2026/08/app-quality-memory-optimization-secure-onboarding.html&quot;&gt;official announcement&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://support.google.com/googleplay/android-developer/answer/17492799?hl=en&quot;&gt;technical requirements&lt;/a&gt; when planning releases.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27177/google-plays-2027-app-quality-requirements</guid>
<pubDate>Mon, 14 Sep 2026 15:41:55 +0000</pubDate>
</item>
<item>
<title>Building the LBH Protocol from Android/Termux: A Sovereign Edge Architecture</title>
<link>https://coderlegion.com/27017/building-the-lbh-protocol-from-android-termux-a-sovereign-edge-architecture</link>
<description>&lt;h3 id=&quot;section-1&quot;&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/jbwxzrgvz0u3is3u0j2a.webp&quot; alt=&quot;Image description&quot;&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;By Cristhiam Leonardo Hernández Quiñonez (CLHQ)&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;Founder – HormigasAIS | Sovereign edge computing ecosystem – San Miguel, El Salvador&lt;/em&gt;&lt;br&gt;
&lt;em&gt;github.com/Thrumanshow/HormigasAIS&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-problem-depending-on-the-cloud-to-validate-what-s-real&quot;&gt;1. The Problem: Depending on the Cloud to Validate What's Real&lt;/h3&gt;
&lt;p&gt;Most content-verification and edge-computing stacks today assume a baseline dependency: a cloud provider, a centralized API, or a third-party service sitting between your data and the claim that it's authentic. That dependency is convenient, but it's also a single point of failure and a single point of control.&lt;/p&gt;
&lt;p&gt;The question I kept returning to while building HormigasAIS was simple: &lt;strong&gt;can a verification system be sovereign?&lt;/strong&gt; Not &quot;self-hosted on someone else's cloud,&quot; but genuinely independent — running on infrastructure you own, from a device as unconventional as a phone.&lt;/p&gt;
&lt;h3 id=&quot;2-the-solution-a-node-architecture-built-on-sealing-not-trust&quot;&gt;2. The Solution: A Node Architecture Built on Sealing, Not Trust&lt;/h3&gt;
&lt;p&gt;HormigasAIS approaches this with a node-based architecture rather than a service-based one. Three pieces anchor it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LBH cryptographic sealing&lt;/strong&gt; — content is sealed using SHA-256 + HMAC-SHA256, with signed verification that doesn't require calling out to a third party to confirm integrity.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;barrera.js&lt;/code&gt; — an ethical/logical filter layer (informally, &quot;decromatiza&quot;) that sits between raw input and the system's accepted state.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;humano.js&lt;/code&gt; — a small symbolic module declaring the human-language layer of the project: &lt;code&gt;HormigasAIS = {{lenguaje-humano}}&lt;/code&gt;. It's less a functional API and more a positioning statement embedded directly in code — a reminder that the system is meant to stay legible to a human reader, not just to a machine.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this requires a data center. The full development loop — writing, testing, sealing, deploying — happens from an Android device running Termux.&lt;/p&gt;
&lt;h3 id=&quot;3-the-implementation-from-a-404-to-a-working-node&quot;&gt;3. The Implementation: From a 404 to a Working Node&lt;/h3&gt;
&lt;p&gt;The most concrete proof point is small on purpose. The repository &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Thrumanshow/HormigasAIS&quot;&gt;&lt;code&gt;Thrumanshow/HormigasAIS&lt;/code&gt;&lt;/a&gt; started as a set of governance and symbolic files with no public-facing page — visiting its GitHub Pages URL returned a plain 404.&lt;/p&gt;
&lt;p&gt;Getting it live took: enabling GitHub Pages from the &lt;code&gt;main&lt;/code&gt; branch root, writing a minimal &lt;code&gt;index.html&lt;/code&gt;, and documenting the repo properly — &lt;code&gt;README.md&lt;/code&gt;, &lt;code&gt;LICENSE&lt;/code&gt; (MIT), &lt;code&gt;GOVERNANCE.md&lt;/code&gt;, &lt;code&gt;STATUS.md&lt;/code&gt;, and &lt;code&gt;MANIFIESTO.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The entire sequence — clone, edit, verify with &lt;code&gt;curl&lt;/code&gt;, commit, push, confirm &lt;code&gt;200&lt;/code&gt; — ran from Termux on a Samsung A16, no laptop involved.&lt;/p&gt;
&lt;h3 id=&quot;4-the-ecosystem-what-s-actually-public&quot;&gt;4. The Ecosystem: What's Actually Public&lt;/h3&gt;
&lt;p&gt;To be precise about what's verifiable versus conceptual:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live and checkable:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://hormigasais.com&quot;&gt;hormigasais.com&lt;/a&gt; — main site&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://api.hormigasais.com&quot;&gt;api.hormigasais.com&lt;/a&gt; — Worker API&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.hormigasais.com&quot;&gt;docs.hormigasais.com&lt;/a&gt; — documentation&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.hormigasais.com&quot;&gt;blog.hormigasais.com&lt;/a&gt; — blog&lt;/li&gt;
&lt;li&gt;The formal LBH specification is deposited on Zenodo: &lt;strong&gt;DOI &lt;a rel=&quot;nofollow&quot; href=&quot;https://doi.org/10.5281/zenodo.17767205&quot;&gt;10.5281/zenodo.17767205&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Governance:&lt;/strong&gt;&lt;br&gt;
The project operates under an explicit &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Thrumanshow/HormigasAIS/blob/main/GOVERNANCE.md&quot;&gt;GOVERNANCE.md&lt;/a&gt;, which is worth flagging precisely because it's unusual for a solo project: it draws a hard line between forking/using the code (permitted under license) and any claim of official representation or node status (which requires explicit, written recognition from the maintainer of record). Forks are welcome. Authority isn't inherited by default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Still experimental:&lt;/strong&gt;&lt;br&gt;
The broader multi-agent decision system and node-validation layer are active work — not yet at the same level of external verifiability as the sealing protocol or the published spec, and I want to be upfront about that distinction rather than blur it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you're building anything edge-first, protocol-level, or just skeptical of cloud dependency by default — I'd welcome the conversation.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27017/building-the-lbh-protocol-from-android-termux-a-sovereign-edge-architecture</guid>
<pubDate>Sun, 13 Sep 2026 02:04:44 +0000</pubDate>
</item>
<item>
<title>Getting Started with Native Android: Kotlin &amp; Jetpack Compose</title>
<link>https://coderlegion.com/26446/getting-started-with-native-android-kotlin-jetpack-compose</link>
<description>&lt;p&gt;Hey devs! For anyone looking to get into native mobile development, I put together a starter guide on building Android apps using Kotlin and Jetpack Compose.&lt;/p&gt;
&lt;p&gt;Key takeaways:&lt;/p&gt;
&lt;p&gt;Transitioning from traditional imperative UI to modern declarative composables&lt;/p&gt;
&lt;p&gt;Managing UI state with remember and mutableStateOf&lt;/p&gt;
&lt;p&gt;Quick design cycles using live previews in Android Studio&lt;/p&gt;
&lt;p&gt;Read the full post on SunnyWriteUps! Are you currently using Jetpack Compose, KMP, or cross-platform frameworks for your mobile builds?&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://sunnyblog.apexmansunny.workers.dev/posts/get-started-with-kotlin/&quot;&gt;https://sunnyblog.apexmansunny.workers.dev/posts/get-started-with-kotlin/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26446/getting-started-with-native-android-kotlin-jetpack-compose</guid>
<pubDate>Tue, 08 Sep 2026 04:14:28 +0000</pubDate>
</item>
<item>
<title>How to Fix Expo Doctor Errors in Expo</title>
<link>https://coderlegion.com/26393/how-to-fix-expo-doctor-errors-in-expo</link>
<description>&lt;p&gt;If you work with Expo long enough, you will probably run into &lt;code&gt;expo-doctor&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and instead of everything being green, you get something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;✖ Check Expo config
✖ Check dependencies
✖ Check package versions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sometimes there is one problem.&lt;/p&gt;
&lt;p&gt;Sometimes there are several.&lt;/p&gt;
&lt;p&gt;And sometimes the output looks much worse than the actual problem.&lt;/p&gt;
&lt;p&gt;The good thing is that &lt;code&gt;expo-doctor&lt;/code&gt; is usually pretty clear about what it does not like. The tricky part is knowing what the warnings actually mean and which ones you need to fix.&lt;/p&gt;
&lt;p&gt;Here is how I usually work through Expo Doctor errors without immediately changing half of the project.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-run-expo-doctor-by-itself&quot;&gt;First, run Expo Doctor by itself&lt;/h3&gt;
&lt;p&gt;Start with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let it finish and read the full output.&lt;/p&gt;
&lt;p&gt;Do not immediately start changing dependencies just because you see a red &lt;code&gt;✖&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Expo Doctor runs several checks, and a failure in one check does not necessarily mean your entire project is broken.&lt;/p&gt;
&lt;p&gt;For example, you might see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;✖ Check that packages match versions required by installed Expo SDK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is very different from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;✖ Check for common project configuration problems
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first one points toward dependency versions.&lt;/p&gt;
&lt;p&gt;The second could require looking at your Expo configuration.&lt;/p&gt;
&lt;p&gt;So the first step is simply figuring out &lt;strong&gt;which check actually failed&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-your-expo-sdk-version&quot;&gt;Check your Expo SDK version&lt;/h3&gt;
&lt;p&gt;A lot of Expo Doctor problems come down to package versions not matching the Expo SDK you are using.&lt;/p&gt;
&lt;p&gt;You can check your Expo version in &lt;code&gt;package.json&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;dependencies&quot;: {
    &quot;expo&quot;: &quot;~54.0.0&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exact version will depend on your project.&lt;/p&gt;
&lt;p&gt;You can also run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo config --type public
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important thing is to know which Expo SDK your project is actually using before changing dependencies.&lt;/p&gt;
&lt;p&gt;This matters because Expo packages are not completely independent of the SDK.&lt;/p&gt;
&lt;p&gt;You cannot always just install the newest version of every package and expect everything to work together.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fix-packages-that-are-on-the-wrong-version&quot;&gt;Fix packages that are on the wrong version&lt;/h3&gt;
&lt;p&gt;One of the most common Expo Doctor messages is related to package versions.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;✖ Check that packages match versions required by installed Expo SDK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expo may tell you that some packages are expected to be on different versions.&lt;/p&gt;
&lt;p&gt;If that happens, do not manually guess which version to install.&lt;/p&gt;
&lt;p&gt;For Expo-managed projects, use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expo can use your current SDK to install compatible package versions.&lt;/p&gt;
&lt;p&gt;You can also check the project with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install --check
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it reports packages that are out of date, you can usually let Expo correct them with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install --fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is generally safer than doing something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install package-name@latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;because the latest package version is not necessarily the version your current Expo SDK expects.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;do-not-blindly-update-everything&quot;&gt;Do not blindly update everything&lt;/h3&gt;
&lt;p&gt;This is worth mentioning because it is an easy mistake to make.&lt;/p&gt;
&lt;p&gt;Suppose Expo Doctor tells you that three packages have version mismatches.&lt;/p&gt;
&lt;p&gt;You might think:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Fine, I'll just update everything.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then you run something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and suddenly you have five new errors.&lt;/p&gt;
&lt;p&gt;You have now changed many things at once, which makes it harder to figure out what actually caused the problem.&lt;/p&gt;
&lt;p&gt;If Expo is telling you that a package should be on a specific version, start with that package.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install react-native-screens
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rather than manually searching for the newest version and installing it.&lt;/p&gt;
&lt;p&gt;Make one meaningful change at a time when possible.&lt;/p&gt;
&lt;p&gt;It makes debugging much easier.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-package-json-for-old-dependencies&quot;&gt;Check &lt;code&gt;package.json&lt;/code&gt; for old dependencies&lt;/h3&gt;
&lt;p&gt;Another common situation is an old dependency that you no longer use.&lt;/p&gt;
&lt;p&gt;Over time, a project can collect packages from old features, experiments, tutorials, or libraries you tried and later removed.&lt;/p&gt;
&lt;p&gt;Your &lt;code&gt;package.json&lt;/code&gt; might contain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;dependencies&quot;: {
    &quot;some-old-package&quot;: &quot;^1.2.3&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;even though nothing in your application imports it anymore.&lt;/p&gt;
&lt;p&gt;If Expo Doctor complains about that package, first check whether you still need it.&lt;/p&gt;
&lt;p&gt;Search your project for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;some-old-package
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If there are no imports or references and you know you do not need it anymore, removing the dependency may be the correct fix.&lt;/p&gt;
&lt;p&gt;For npm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm uninstall some-old-package
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Yarn:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yarn remove some-old-package
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run Expo Doctor again.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-for-packages-that-are-not-compatible-with-your-project&quot;&gt;Check for packages that are not compatible with your project&lt;/h3&gt;
&lt;p&gt;Sometimes the problem is not simply that a package is old.&lt;/p&gt;
&lt;p&gt;A package may require native code or a setup that does not fit the way your Expo project is configured.&lt;/p&gt;
&lt;p&gt;This becomes especially important when adding React Native libraries that were designed with bare React Native projects in mind.&lt;/p&gt;
&lt;p&gt;You may install a package successfully with npm, but that does not automatically mean your Expo project is configured to use it correctly.&lt;/p&gt;
&lt;p&gt;If Expo Doctor starts complaining immediately after adding a new library, that timing is useful information.&lt;/p&gt;
&lt;p&gt;Try to remember:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What changed right before the error appeared?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you just installed a package and Expo Doctor started failing, investigate that package first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-your-expo-configuration&quot;&gt;Check your Expo configuration&lt;/h3&gt;
&lt;p&gt;Expo Doctor can also complain about your project configuration.&lt;/p&gt;
&lt;p&gt;Depending on your setup, this information may live in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app.config.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app.config.ts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example, your configuration might contain something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;expo&quot;: {
    &quot;name&quot;: &quot;My App&quot;,
    &quot;slug&quot;: &quot;my-app&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you recently changed your configuration, added a plugin, changed an Android or iOS setting, or installed a package that requires an Expo config plugin, check those changes carefully.&lt;/p&gt;
&lt;p&gt;A configuration problem can sometimes look like a dependency problem because several parts of the Expo toolchain depend on the same project configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-expo-config-plugins&quot;&gt;Check Expo config plugins&lt;/h3&gt;
&lt;p&gt;Config plugins are another area that can cause confusion.&lt;/p&gt;
&lt;p&gt;If you have something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;expo&quot;: {
    &quot;plugins&quot;: [
      &quot;some-package&quot;
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;make sure that package is actually installed and that the plugin is valid for your current setup.&lt;/p&gt;
&lt;p&gt;A common situation is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You install a package.&lt;/li&gt;
&lt;li&gt;You add its plugin to the Expo configuration.&lt;/li&gt;
&lt;li&gt;Later, you remove the package.&lt;/li&gt;
&lt;li&gt;The plugin entry stays behind.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Now Expo may still try to process something that no longer exists.&lt;/p&gt;
&lt;p&gt;If you recently removed a native package, check &lt;code&gt;app.json&lt;/code&gt; or your app config for leftover plugin entries.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-for-duplicate-dependencies&quot;&gt;Check for duplicate dependencies&lt;/h3&gt;
&lt;p&gt;Another useful thing to investigate is duplicate versions of the same package.&lt;/p&gt;
&lt;p&gt;For example, your project might end up with different parts of the dependency tree using different versions of a React Native package.&lt;/p&gt;
&lt;p&gt;You can inspect the dependency tree with npm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm ls
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or check a specific package:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm ls react-native-screens
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you see multiple versions where you expected one, that can be a clue.&lt;/p&gt;
&lt;p&gt;This does not automatically mean that multiple versions are wrong. Some projects legitimately have them.&lt;/p&gt;
&lt;p&gt;The important part is to understand &lt;strong&gt;why&lt;/strong&gt; they are there before trying to force everything onto one version.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-your-react-native-version-too&quot;&gt;Check your React Native version too&lt;/h3&gt;
&lt;p&gt;Expo SDK and React Native versions are closely related.&lt;/p&gt;
&lt;p&gt;If you manually changed your React Native version, or upgraded one without properly upgrading the Expo SDK around it, Expo Doctor may start reporting compatibility problems.&lt;/p&gt;
&lt;p&gt;For example, if your &lt;code&gt;package.json&lt;/code&gt; contains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;dependencies&quot;: {
    &quot;expo&quot;: &quot;~54.0.0&quot;,
    &quot;react-native&quot;: &quot;...&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;make sure those versions belong together.&lt;/p&gt;
&lt;p&gt;If you are upgrading Expo, follow the Expo upgrade process instead of manually changing a bunch of package versions and hoping they line up afterward.&lt;/p&gt;
&lt;p&gt;A partially upgraded project is often harder to fix than a project that was left alone.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;run-expo-doctor-again-after-fixing-something&quot;&gt;Run &lt;code&gt;expo-doctor&lt;/code&gt; again after fixing something&lt;/h3&gt;
&lt;p&gt;This sounds obvious, but it is an important part of the process.&lt;/p&gt;
&lt;p&gt;After making a change, run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;again.&lt;/p&gt;
&lt;p&gt;You want to know whether the change actually fixed the problem.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Before:
✖ Check package versions

After:
✔ Check package versions
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you know that particular problem is gone.&lt;/p&gt;
&lt;p&gt;If another check is still failing, you can work on that one separately.&lt;/p&gt;
&lt;p&gt;This is much easier than making ten changes and running Expo Doctor once at the end.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-if-expo-doctor-still-fails-&quot;&gt;What if Expo Doctor still fails?&lt;/h3&gt;
&lt;p&gt;If you have corrected the obvious dependency and configuration problems but Expo Doctor is still reporting an error, look at the &lt;strong&gt;exact check that is failing&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;✖ Check for common project configuration problems
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not enough information by itself.&lt;/p&gt;
&lt;p&gt;Read the lines underneath it.&lt;/p&gt;
&lt;p&gt;Expo Doctor normally provides additional information about what it found.&lt;/p&gt;
&lt;p&gt;That information is much more useful than the &lt;code&gt;✖&lt;/code&gt; itself.&lt;/p&gt;
&lt;p&gt;Copy the complete section of the output, not just the first line.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;clear-the-cache-if-the-project-still-behaves-strangely&quot;&gt;Clear the cache if the project still behaves strangely&lt;/h3&gt;
&lt;p&gt;Clearing caches is not usually the first fix for an Expo Doctor warning.&lt;/p&gt;
&lt;p&gt;Expo Doctor is checking your project configuration and dependencies, so a cache reset will not magically fix an incorrect package version.&lt;/p&gt;
&lt;p&gt;However, if you have already fixed the reported problem and the project is still behaving as if the old state exists, clearing the Metro cache can help.&lt;/p&gt;
&lt;p&gt;For Expo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is particularly useful after changing dependencies, imports, or other files that Metro may have cached.&lt;/p&gt;
&lt;p&gt;Just keep in mind that &lt;strong&gt;clearing Metro's cache and fixing Expo Doctor are two different things&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-reinstalling-node-modules-makes-sense&quot;&gt;When reinstalling &lt;code&gt;node_modules&lt;/code&gt; makes sense&lt;/h3&gt;
&lt;p&gt;If your package configuration looks correct but your installation itself seems broken, reinstalling dependencies can be worth trying.&lt;/p&gt;
&lt;p&gt;Delete:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;node_modules
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then reinstall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Afterward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And if necessary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I would not delete your lockfile automatically.&lt;/p&gt;
&lt;p&gt;Your &lt;code&gt;package-lock.json&lt;/code&gt; or &lt;code&gt;yarn.lock&lt;/code&gt; records the dependency versions that your project is using. Removing it can cause a completely new dependency tree to be installed, which may introduce more changes than you wanted.&lt;/p&gt;
&lt;p&gt;If you specifically need to regenerate the lockfile, that is a different situation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;be-careful-when-upgrading-an-expo-project&quot;&gt;Be careful when upgrading an Expo project&lt;/h3&gt;
&lt;p&gt;If the reason for your Expo Doctor errors is that you are trying to move to a newer Expo SDK, do not treat it like a normal dependency update.&lt;/p&gt;
&lt;p&gt;An Expo SDK upgrade can involve:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Expo packages&lt;/li&gt;
&lt;li&gt;React Native&lt;/li&gt;
&lt;li&gt;React&lt;/li&gt;
&lt;li&gt;native dependencies&lt;/li&gt;
&lt;li&gt;config plugins&lt;/li&gt;
&lt;li&gt;Android configuration&lt;/li&gt;
&lt;li&gt;iOS configuration&lt;/li&gt;
&lt;li&gt;other packages tied to the SDK&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you upgrade only one or two pieces, you can end up with a project where everything is technically installed but the versions do not belong together.&lt;/p&gt;
&lt;p&gt;If your goal is specifically to upgrade Expo, follow the upgrade process for the target SDK rather than trying to fix every warning individually.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-simple-way-to-approach-expo-doctor&quot;&gt;A simple way to approach Expo Doctor&lt;/h3&gt;
&lt;p&gt;When I get an Expo Doctor error, I usually work through it in this order:&lt;/p&gt;
&lt;h4 id=&quot;1-run-expo-doctor&quot;&gt;1. Run Expo Doctor&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-find-the-failed-check&quot;&gt;2. Find the failed check&lt;/h4&gt;
&lt;p&gt;Do not worry about every line yet. Find the actual &lt;code&gt;✖&lt;/code&gt; checks.&lt;/p&gt;
&lt;h4 id=&quot;3-read-the-details-underneath&quot;&gt;3. Read the details underneath&lt;/h4&gt;
&lt;p&gt;That is usually where the useful information is.&lt;/p&gt;
&lt;h4 id=&quot;4-check-what-changed-recently&quot;&gt;4. Check what changed recently&lt;/h4&gt;
&lt;p&gt;Did you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;install a package?&lt;/li&gt;
&lt;li&gt;update Expo?&lt;/li&gt;
&lt;li&gt;update React Native?&lt;/li&gt;
&lt;li&gt;remove a package?&lt;/li&gt;
&lt;li&gt;change &lt;code&gt;app.json&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;add a config plugin?&lt;/li&gt;
&lt;li&gt;switch branches?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Recent changes are often the best clue.&lt;/p&gt;
&lt;h4 id=&quot;5-fix-the-smallest-thing-first&quot;&gt;5. Fix the smallest thing first&lt;/h4&gt;
&lt;p&gt;If a package version is wrong, fix that package.&lt;/p&gt;
&lt;p&gt;If a plugin is left behind, remove it.&lt;/p&gt;
&lt;p&gt;If a dependency is no longer needed, uninstall it.&lt;/p&gt;
&lt;h4 id=&quot;6-run-expo-doctor-again&quot;&gt;6. Run Expo Doctor again&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then see what remains.&lt;/p&gt;
&lt;p&gt;This gives you a much cleaner debugging loop.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;one-example&quot;&gt;One example&lt;/h3&gt;
&lt;p&gt;Imagine you run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and get a dependency version warning.&lt;/p&gt;
&lt;p&gt;You check &lt;code&gt;package.json&lt;/code&gt; and notice that you manually installed a newer version of an Expo-related package a few days ago.&lt;/p&gt;
&lt;p&gt;Instead of immediately reinstalling everything, you can try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or, if Expo identifies the package as needing a correction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install --fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;again.&lt;/p&gt;
&lt;p&gt;If the check passes, you have your answer.&lt;/p&gt;
&lt;p&gt;If it does not, you now have a smaller problem to investigate.&lt;/p&gt;
&lt;p&gt;That is the part that makes debugging much easier: &lt;strong&gt;change something, test it, and use the new output to decide what to do next.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Expo Doctor is there to help you find problems before they turn into much bigger issues.&lt;/p&gt;
&lt;p&gt;The important thing is not to treat every warning as a reason to rebuild your entire project.&lt;/p&gt;
&lt;p&gt;Start with the exact check that failed.&lt;/p&gt;
&lt;p&gt;If it is a dependency problem, check the package versions.&lt;/p&gt;
&lt;p&gt;If it is a configuration problem, check your Expo config.&lt;/p&gt;
&lt;p&gt;If you recently installed or removed a package, investigate that change.&lt;/p&gt;
&lt;p&gt;And if you are upgrading Expo, make sure you are upgrading the related dependencies together instead of manually mixing versions.&lt;/p&gt;
&lt;p&gt;Most importantly, avoid making a dozen changes at once. When you do that, even if the error disappears, you have no idea which change actually fixed it.&lt;/p&gt;
&lt;p&gt;If your Expo Doctor output is more complicated—especially when several checks fail at the same time—you can paste the full error into &lt;strong&gt;FixMyError&lt;/strong&gt; and use it to break down what the output is actually telling you.&lt;/p&gt;
&lt;p&gt;You can try it at &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;https://www.fixmyerrorapp.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/26393/how-to-fix-expo-doctor-errors-in-expo</guid>
<pubDate>Mon, 07 Sep 2026 11:14:51 +0000</pubDate>
</item>
<item>
<title>How to Fix “Unable to Resolve Module” Errors in React Native and Expo</title>
<link>https://coderlegion.com/25620/how-to-fix-unable-to-resolve-module-errors-in-react-native-and-expo</link>
<description>&lt;h2 id=&quot;how-to-fix-unable-to-resolve-module-errors-in-react-native-and-expo&quot;&gt;How to Fix “Unable to Resolve Module” Errors in React Native and Expo&lt;/h2&gt;
&lt;p&gt;If you have been working with React Native or Expo for a while, there is a good chance you have seen an error that looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unable to resolve module ./components/Header from App.js

None of these files exist:
  * components/Header(.native|.ios|.android|.js|.jsx|.ts|.tsx)
  * components/Header/index(.native|.ios|.android|.js|.jsx|.ts|.tsx)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or maybe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unable to resolve module react-native-safe-area-context from App.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or the error is buried somewhere inside a much larger Metro bundler log, making it harder to see what is actually wrong.&lt;/p&gt;
&lt;p&gt;The annoying part about this error is that &lt;strong&gt;&quot;Unable to resolve module&quot; does not always mean the same thing&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Sometimes the file path is wrong. Sometimes the package was never installed. Sometimes it is installed, but your dependencies are in a bad state. And sometimes everything looks correct until you notice that one letter in a filename has the wrong capitalization.&lt;/p&gt;
&lt;p&gt;I've run into all of these before.&lt;/p&gt;
&lt;p&gt;So instead of randomly deleting &lt;code&gt;node_modules&lt;/code&gt; and hoping for the best, here is how I usually work through this error.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-read-the-exact-module-metro-cannot-find&quot;&gt;First, read the exact module Metro cannot find&lt;/h3&gt;
&lt;p&gt;This sounds obvious, but the first line of the error usually tells you exactly what Metro is looking for.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unable to resolve module ./components/Header from App.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Metro is trying to find:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;./components/Header
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;relative to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;App.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That immediately gives you something to check.&lt;/p&gt;
&lt;p&gt;Ask yourself:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Does that file actually exist?&lt;/li&gt;
&lt;li&gt;Is the path correct?&lt;/li&gt;
&lt;li&gt;Is the filename spelled correctly?&lt;/li&gt;
&lt;li&gt;Are you importing it from the correct folder?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For example, imagine your project looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;my-app/
├── App.js
└── components/
    └── Header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then this import should work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;./components/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But this will not:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;./component/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One missing &lt;code&gt;s&lt;/code&gt; is enough to break the build.&lt;/p&gt;
&lt;p&gt;When the error points to a local file, I usually start here before doing anything else.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-the-file-name-and-capitalization&quot;&gt;Check the file name and capitalization&lt;/h3&gt;
&lt;p&gt;This one can be surprisingly annoying, especially if your app worked on one machine but suddenly fails during a build or after pushing the project somewhere else.&lt;/p&gt;
&lt;p&gt;Imagine your file is named:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But your import says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;./components/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Depending on the operating system and environment, this can cause problems because:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;are not always treated as the same file.&lt;/p&gt;
&lt;p&gt;The same thing can happen with folders:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Components/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;vs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;components/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If Metro says it cannot find a local module and the path looks correct at first glance, check every part of the path carefully.&lt;/p&gt;
&lt;p&gt;I have lost more time than I would like to admit because of a single capital letter.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;make-sure-the-package-is-actually-installed&quot;&gt;Make sure the package is actually installed&lt;/h3&gt;
&lt;p&gt;Sometimes the missing module is not one of your own files.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unable to resolve module react-native-safe-area-context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In that case, check whether the package exists in your project.&lt;/p&gt;
&lt;p&gt;You can look inside &lt;code&gt;package.json&lt;/code&gt; and see whether it is listed under &lt;code&gt;dependencies&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For an Expo project, it is usually better to install Expo-compatible packages with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install react-native-safe-area-context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a regular React Native project, you would normally use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install react-native-safe-area-context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yarn add react-native-safe-area-context
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exact command depends on your project, but the important part is this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not assume a package is installed just because you used it in your code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It is also possible that you copied code from another project, tutorial, or GitHub repository without installing all of the required dependencies.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-if-the-package-name-is-correct&quot;&gt;Check if the package name is correct&lt;/h3&gt;
&lt;p&gt;Another simple but common problem is importing the wrong package name.&lt;/p&gt;
&lt;p&gt;For example, you might write something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Something from &quot;react-native-something&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but the actual package name could be slightly different.&lt;/p&gt;
&lt;p&gt;Before reinstalling everything, check:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The package name in your import.&lt;/li&gt;
&lt;li&gt;The package name in &lt;code&gt;package.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The package name from the package documentation.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One small typo can produce the same &quot;Unable to resolve module&quot; error as a completely missing dependency.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;reset-the-metro-cache&quot;&gt;Reset the Metro cache&lt;/h3&gt;
&lt;p&gt;If the file or package exists and the import looks correct, the next thing I would try is clearing Metro's cache.&lt;/p&gt;
&lt;p&gt;For Expo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For React Native, you can try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native start --reset-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Metro caches information to make development faster, but occasionally the cache gets in the way after things change.&lt;/p&gt;
&lt;p&gt;For example, you might have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;renamed a file,&lt;/li&gt;
&lt;li&gt;moved a folder,&lt;/li&gt;
&lt;li&gt;installed a new package,&lt;/li&gt;
&lt;li&gt;changed an import,&lt;/li&gt;
&lt;li&gt;switched branches in Git.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the project is still behaving like the old version exists, clearing the cache is a quick thing to try.&lt;/p&gt;
&lt;p&gt;I would not start by deleting half the project, though. Resetting the cache is much less destructive.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-your-node-modules&quot;&gt;Check your &lt;code&gt;node_modules&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Sometimes the dependency is listed in &lt;code&gt;package.json&lt;/code&gt;, but something went wrong during installation.&lt;/p&gt;
&lt;p&gt;This can happen after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;switching branches,&lt;/li&gt;
&lt;li&gt;changing package versions,&lt;/li&gt;
&lt;li&gt;interrupting an installation,&lt;/li&gt;
&lt;li&gt;moving the project to another computer,&lt;/li&gt;
&lt;li&gt;or getting dependency conflicts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A common approach is to reinstall your dependencies.&lt;/p&gt;
&lt;p&gt;First, remove &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or, if you use Yarn:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;yarn install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, start the project again and clear Metro's cache.&lt;/p&gt;
&lt;p&gt;For Expo, that could look like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Try to use the same package manager consistently. If your project uses &lt;code&gt;package-lock.json&lt;/code&gt;, randomly switching between npm, Yarn, and other package managers can create confusing dependency problems.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-whether-you-are-importing-from-the-correct-location&quot;&gt;Check whether you are importing from the correct location&lt;/h3&gt;
&lt;p&gt;This is especially easy to miss after moving files around.&lt;/p&gt;
&lt;p&gt;Imagine you originally had:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
├── screens/
│   └── HomeScreen.js
└── components/
    └── Header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside &lt;code&gt;HomeScreen.js&lt;/code&gt;, you might import:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;../components/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then later, you move &lt;code&gt;HomeScreen.js&lt;/code&gt; somewhere else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
├── app/
│   └── screens/
│       └── HomeScreen.js
└── components/
    └── Header.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The old import path may no longer be correct.&lt;/p&gt;
&lt;p&gt;You would need to update it accordingly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;../../components/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you see an &quot;Unable to resolve module&quot; error after reorganizing your project, check whether the import path still matches the new folder structure.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;be-careful-with-relative-paths&quot;&gt;Be careful with relative paths&lt;/h3&gt;
&lt;p&gt;Relative imports can become difficult to read when your project grows.&lt;/p&gt;
&lt;p&gt;You might end up with something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Something from &quot;../../../../components/Something&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At that point, it becomes much easier to accidentally use one &lt;code&gt;../&lt;/code&gt; too many or too few.&lt;/p&gt;
&lt;p&gt;If the error points to a long relative path, carefully trace it from the file where the import is written.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;../../../components/Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not just count the dots and slashes quickly. Actually check the location of the current file and follow the path step by step.&lt;/p&gt;
&lt;p&gt;It is boring, but it is often faster than trying random fixes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-whether-the-package-is-compatible-with-expo&quot;&gt;Check whether the package is compatible with Expo&lt;/h3&gt;
&lt;p&gt;This is more important if you are using Expo.&lt;/p&gt;
&lt;p&gt;You may find a React Native package online, install it successfully, and then run into problems because the version does not match your Expo SDK or the package needs native setup that your project does not currently support.&lt;/p&gt;
&lt;p&gt;When possible, use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install package-name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;instead of blindly installing the latest version with npm.&lt;/p&gt;
&lt;p&gt;Expo can choose a version that is compatible with your current SDK.&lt;/p&gt;
&lt;p&gt;You can also run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to check your project for dependency and configuration issues.&lt;/p&gt;
&lt;p&gt;If Metro suddenly cannot resolve something after installing or updating a package, compatibility is worth checking.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;watch-out-for-renamed-or-removed-files&quot;&gt;Watch out for renamed or removed files&lt;/h3&gt;
&lt;p&gt;Another situation that can cause this error is when your code still imports something that no longer exists.&lt;/p&gt;
&lt;p&gt;For example, you rename:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OldButton.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;CustomButton.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but somewhere in the project, you still have:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import OldButton from &quot;./OldButton&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The error might not happen immediately if that file is not loaded until you visit a specific screen.&lt;/p&gt;
&lt;p&gt;This is why I would search the project for the old filename.&lt;/p&gt;
&lt;p&gt;Your editor's global search is useful here. Search for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;OldButton
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and see whether any old imports are still left behind.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-index-files&quot;&gt;Check &lt;code&gt;index&lt;/code&gt; files&lt;/h3&gt;
&lt;p&gt;Sometimes the missing module is actually caused by an &lt;code&gt;index.js&lt;/code&gt; or &lt;code&gt;index.ts&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;components/
├── Button/
│   ├── Button.js
│   └── index.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might import it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Button from &quot;./components/Button&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That usually depends on Metro being able to resolve the folder's &lt;code&gt;index&lt;/code&gt; file correctly.&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;index.js&lt;/code&gt; was deleted, renamed, or contains a broken export, your import setup may stop working.&lt;/p&gt;
&lt;p&gt;Check whether the folder contains the file Metro expects.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export { default } from &quot;./Button&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export { Button } from &quot;./Button&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The correct setup depends on whether you are using default or named exports.&lt;/p&gt;
&lt;p&gt;The important part is to check the whole import chain, not just the first file.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;default-exports-and-named-exports-are-a-different-problem-but-still-worth-checking&quot;&gt;Default exports and named exports are a different problem, but still worth checking&lt;/h3&gt;
&lt;p&gt;This usually causes a different error, but it can easily appear around the same time.&lt;/p&gt;
&lt;p&gt;For example, this is a default export:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default Header;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And it is imported like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Header from &quot;./Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A named export looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const Header = () =&amp;gt; {
  return null;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And it should be imported like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { Header } from &quot;./Header&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you have already fixed the module resolution error but the app still fails afterward, this is one of the next things I would check.&lt;/p&gt;
&lt;p&gt;Sometimes you fix one problem and Metro finally gets far enough to show you the next one.&lt;/p&gt;
&lt;p&gt;That is just part of debugging.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;monorepos-can-make-module-resolution-more-complicated&quot;&gt;Monorepos can make module resolution more complicated&lt;/h3&gt;
&lt;p&gt;If you are using a monorepo, workspaces, shared packages, or code outside the main app folder, Metro module resolution can become more complicated.&lt;/p&gt;
&lt;p&gt;Your project might look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;my-project/
├── apps/
│   └── mobile/
└── packages/
    └── shared/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the React Native or Expo app might need to import code from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;packages/shared
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In these cases, a normal relative import may not be enough. Metro may need additional configuration to watch or resolve files outside the main project directory.&lt;/p&gt;
&lt;p&gt;If you only started seeing the error after introducing a workspace or shared package, I would focus on your Metro configuration instead of repeatedly reinstalling dependencies.&lt;/p&gt;
&lt;p&gt;Monorepo issues usually need a more specific fix because the correct setup depends heavily on how the project is structured.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;if-you-use-expo-router-check-your-imports-and-project-structure&quot;&gt;If you use Expo Router, check your imports and project structure&lt;/h3&gt;
&lt;p&gt;Expo Router adds another layer to how files are organized.&lt;/p&gt;
&lt;p&gt;For example, your routes may live inside:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and you may have layouts, route groups, and nested folders.&lt;/p&gt;
&lt;p&gt;If you recently moved or renamed route files, double-check your imports and references.&lt;/p&gt;
&lt;p&gt;Also make sure the required Expo Router packages are correctly installed for your current Expo setup.&lt;/p&gt;
&lt;p&gt;When something goes wrong inside a routing setup, the error message may make it look like a general Metro problem when the real issue is simply an incorrect import somewhere inside a route or layout.&lt;/p&gt;
&lt;p&gt;Again, start with the exact module Metro says it cannot find.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;do-not-immediately-delete-everything&quot;&gt;Do not immediately delete everything&lt;/h3&gt;
&lt;p&gt;When developers hit a module resolution error, a common reaction is to do all of this at once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Delete node_modules
Delete package-lock.json
Clear Metro
Clear npm cache
Reinstall everything
Restart the computer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sometimes that works.&lt;/p&gt;
&lt;p&gt;But it can also make debugging harder because now you have changed five different things and do not know what actually fixed the problem.&lt;/p&gt;
&lt;p&gt;I would usually check the error in this order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Read the exact missing module.&lt;/li&gt;
&lt;li&gt;Check whether the file or package exists.&lt;/li&gt;
&lt;li&gt;Check the spelling and capitalization.&lt;/li&gt;
&lt;li&gt;Check the import path.&lt;/li&gt;
&lt;li&gt;Check whether the dependency is installed.&lt;/li&gt;
&lt;li&gt;Clear the Metro cache.&lt;/li&gt;
&lt;li&gt;Reinstall dependencies if necessary.&lt;/li&gt;
&lt;li&gt;Check Expo or React Native version compatibility.&lt;/li&gt;
&lt;li&gt;Investigate Metro configuration for more complex project setups.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is usually much faster than throwing random commands at the project.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-quick-example&quot;&gt;A quick example&lt;/h3&gt;
&lt;p&gt;Imagine you get this error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Unable to resolve module ./components/ProfileCard from App.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You check your project and see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;components/
└── Profilecard.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem is the capitalization.&lt;/p&gt;
&lt;p&gt;Your import says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import ProfileCard from &quot;./components/ProfileCard&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the actual file is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Profilecard.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rename the file so it matches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ProfileCard.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then restart Metro if necessary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is a simple example, but it shows why reading the exact error matters.&lt;/p&gt;
&lt;p&gt;The error is not always asking you to reinstall your entire project. Sometimes Metro is simply telling you that it cannot find something because the path does not exactly match what exists on disk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;&quot;Unable to resolve module&quot; errors can look more serious than they really are.&lt;/p&gt;
&lt;p&gt;Most of the time, the problem comes down to one of these things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A wrong import path&lt;/li&gt;
&lt;li&gt;A missing package&lt;/li&gt;
&lt;li&gt;A typo in the package name&lt;/li&gt;
&lt;li&gt;A filename capitalization problem&lt;/li&gt;
&lt;li&gt;A stale Metro cache&lt;/li&gt;
&lt;li&gt;Broken or missing dependencies&lt;/li&gt;
&lt;li&gt;A file that was moved or renamed&lt;/li&gt;
&lt;li&gt;A more complex Metro configuration issue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The biggest thing I have learned is to &lt;strong&gt;start with the exact module Metro says it cannot find&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Do not start by reinstalling the entire project.&lt;/p&gt;
&lt;p&gt;Check what Metro is looking for, where it is looking from, and whether that file or package actually exists. In many cases, the answer is much simpler than the size of the error message makes it seem.&lt;/p&gt;
&lt;p&gt;If your error is much messier than this—especially if the missing module is buried inside a large Expo, Metro, dependency, or build error—you can also paste the full error into &lt;strong&gt;FixMyError&lt;/strong&gt; and get help breaking down what is actually causing it.&lt;/p&gt;
&lt;p&gt;Sometimes the hard part is not finding a fix. It is figuring out which part of a long error log actually matters.&lt;/p&gt;
&lt;p&gt;Try it here: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;https://www.fixmyerrorapp.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/25620/how-to-fix-unable-to-resolve-module-errors-in-react-native-and-expo</guid>
<pubDate>Fri, 28 Aug 2026 13:36:15 +0000</pubDate>
</item>
<item>
<title>How to Fix EAS Build Failed Errors in Expo</title>
<link>https://coderlegion.com/24440/how-to-fix-eas-build-failed-errors-in-expo</link>
<description>&lt;p&gt;There is a particular kind of Expo error that I think almost every developer eventually runs into.&lt;/p&gt;
&lt;p&gt;You make a change to your project, run your usual build command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and instead of getting a nice &lt;code&gt;.apk&lt;/code&gt; or &lt;code&gt;.aab&lt;/code&gt; at the end, you get:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that's it.&lt;/p&gt;
&lt;p&gt;Well, not really.&lt;/p&gt;
&lt;p&gt;If you open the build logs, you get several hundred lines of output containing Gradle tasks, Android dependencies, warnings, stack traces, environment information, and somewhere in the middle of all of that is the actual reason your build failed.&lt;/p&gt;
&lt;p&gt;I've been there more times than I'd like to admit.&lt;/p&gt;
&lt;p&gt;The frustrating part is that &lt;strong&gt;&quot;EAS Build failed&quot; isn't really an error by itself&lt;/strong&gt;. EAS is just telling you that something went wrong during the build. The actual problem can be a completely different issue.&lt;/p&gt;
&lt;p&gt;It might be an Android dependency conflict.&lt;/p&gt;
&lt;p&gt;It might be a missing environment variable.&lt;/p&gt;
&lt;p&gt;It might be an invalid &lt;code&gt;app.json&lt;/code&gt; configuration.&lt;/p&gt;
&lt;p&gt;It might be a native package that doesn't work with your current Expo SDK.&lt;/p&gt;
&lt;p&gt;Or it might be something as simple as a credentials problem.&lt;/p&gt;
&lt;p&gt;So instead of trying random fixes, let's go through how I approach these failures and how to find the actual cause.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-don-t-focus-on-eas-build-failed-&quot;&gt;First: Don't Focus on &quot;EAS Build Failed&quot;&lt;/h3&gt;
&lt;p&gt;When you see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;it's tempting to search for that exact phrase.&lt;/p&gt;
&lt;p&gt;The problem is that thousands of completely different problems can end with the same message.&lt;/p&gt;
&lt;p&gt;EAS Build is responsible for building your application. It isn't necessarily responsible for the thing that broke.&lt;/p&gt;
&lt;p&gt;For example, your log might eventually contain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:mergeDexDebug'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's an Android dependency/build problem.&lt;/p&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Could not resolve all files for configuration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's pointing toward dependency resolution.&lt;/p&gt;
&lt;p&gt;Or you might see something related to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Missing environment variable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's an environment/configuration problem.&lt;/p&gt;
&lt;p&gt;And if you see something related to signing credentials, you're dealing with an entirely different category of failure.&lt;/p&gt;
&lt;p&gt;The first thing I do is therefore &lt;strong&gt;ignore the generic &quot;Build failed&quot; message and find the first meaningful error underneath it.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-open-the-full-eas-build-logs&quot;&gt;1. Open the Full EAS Build Logs&lt;/h2&gt;
&lt;p&gt;Start by looking at the failed build in the EAS dashboard.&lt;/p&gt;
&lt;p&gt;You can also list recent builds from your terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas build:list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find the failed build and open its logs.&lt;/p&gt;
&lt;p&gt;Don't just look at the last few lines.&lt;/p&gt;
&lt;p&gt;The last line might say:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but that doesn't tell you much.&lt;/p&gt;
&lt;p&gt;Instead, search through the logs for terms such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ERROR
FAILURE
Error:
Caused by:
Exception
Could not
Unable to
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The useful error is often somewhere above the final failure message.&lt;/p&gt;
&lt;p&gt;For example, imagine the bottom of your log looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&amp;gt; Task :app:mergeDexRelease FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':app:mergeDexRelease'.

&amp;gt; com.android.builder.dexing.DexArchiveMergerException:
  Error while merging dex archives

BUILD FAILED

Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The useful information isn't really:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:mergeDexRelease'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and whatever caused the DEX merge to fail.&lt;/p&gt;
&lt;p&gt;That distinction saves a lot of time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-check-whether-the-problem-is-actually-your-expo-configuration&quot;&gt;2. Check Whether the Problem Is Actually Your Expo Configuration&lt;/h2&gt;
&lt;p&gt;One of the first things I check after an EAS build fails is the project's configuration.&lt;/p&gt;
&lt;p&gt;Depending on your setup, this can include:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;app.json
app.config.js
app.config.ts
eas.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A small configuration mistake can prevent the native project from being built correctly.&lt;/p&gt;
&lt;p&gt;For example, if you've recently changed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;android&quot;: {
    &quot;package&quot;: &quot;com.example.myapp&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or added a plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;plugins&quot;: [
    &quot;some-native-package&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and the build immediately started failing, that change is worth investigating.&lt;/p&gt;
&lt;p&gt;The same applies to permissions, native configuration, app identifiers, and Expo config plugins.&lt;/p&gt;
&lt;p&gt;If the failure started immediately after a configuration change, don't assume the EAS service itself is broken.&lt;/p&gt;
&lt;p&gt;Look at what changed in your project first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-check-your-expo-sdk-and-native-dependencies&quot;&gt;3. Check Your Expo SDK and Native Dependencies&lt;/h2&gt;
&lt;p&gt;This is one of the biggest sources of trouble I've encountered with Expo projects.&lt;/p&gt;
&lt;p&gt;Expo SDK versions are tied to specific versions of React Native and a collection of native dependencies.&lt;/p&gt;
&lt;p&gt;You can have a perfectly valid JavaScript project and still break the native build by installing a package that doesn't fit your current environment.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install some-package
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;looks harmless.&lt;/p&gt;
&lt;p&gt;But if that package contains native Android or iOS code, it becomes part of your native build.&lt;/p&gt;
&lt;p&gt;That's where things can get interesting.&lt;/p&gt;
&lt;p&gt;Before randomly upgrading packages, check your project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This can identify common dependency and configuration problems.&lt;/p&gt;
&lt;p&gt;If you're using Expo, I also recommend using Expo's package installation command when appropriate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install package-name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rather than blindly installing a version with npm.&lt;/p&gt;
&lt;p&gt;Expo can select a version that is compatible with your current SDK.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-if-the-error-mentions-gradle-investigate-the-android-build&quot;&gt;4. If the Error Mentions Gradle, Investigate the Android Build&lt;/h2&gt;
&lt;p&gt;Sometimes an EAS failure is actually an Android build failure hiding underneath Expo.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:mergeDexRelease'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Could not resolve all files for configuration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Duplicate class found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are not really &quot;Expo errors.&quot;&lt;/p&gt;
&lt;p&gt;They're Android/Gradle dependency problems occurring during your Expo build.&lt;/p&gt;
&lt;p&gt;This distinction matters because searching for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EAS Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;will give you a huge number of unrelated results.&lt;/p&gt;
&lt;p&gt;Searching for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task mergeDexRelease React Native
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is much more useful.&lt;/p&gt;
&lt;p&gt;That's generally how I approach these logs: &lt;strong&gt;follow the most specific error rather than the generic EAS failure.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-check-your-environment-variables&quot;&gt;5. Check Your Environment Variables&lt;/h2&gt;
&lt;p&gt;Another surprisingly common cause of failed builds is an environment variable that exists locally but doesn't exist in EAS.&lt;/p&gt;
&lt;p&gt;For example, your application might expect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;API_URL
SUPABASE_URL
SUPABASE_ANON_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your local development environment works because those variables exist in your &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;Then you create an EAS build and suddenly something breaks.&lt;/p&gt;
&lt;p&gt;Why?&lt;/p&gt;
&lt;p&gt;Because the remote build environment doesn't automatically have every variable from your local machine.&lt;/p&gt;
&lt;p&gt;Check your EAS environment configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas env:list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure the variables your application actually needs are available in the environment being used for the build.&lt;/p&gt;
&lt;p&gt;This is especially important if the failure happens only on EAS while:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;works perfectly locally.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;6-check-your-eas-build-profile&quot;&gt;6. Check Your EAS Build Profile&lt;/h2&gt;
&lt;p&gt;Your &lt;code&gt;eas.json&lt;/code&gt; determines how your builds are configured.&lt;/p&gt;
&lt;p&gt;A typical configuration might look something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;build&quot;: {
    &quot;development&quot;: {
      &quot;developmentClient&quot;: true,
      &quot;distribution&quot;: &quot;internal&quot;
    },
    &quot;preview&quot;: {
      &quot;distribution&quot;: &quot;internal&quot;
    },
    &quot;production&quot;: {}
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the problem only happens with one profile, compare that profile with the one that works.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas build --profile preview
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;might work while:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas build --profile production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;fails.&lt;/p&gt;
&lt;p&gt;That immediately gives you another clue.&lt;/p&gt;
&lt;p&gt;The problem may not be your entire project. It may be something specific to the build profile.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;7-don-t-ignore-credentials-errors&quot;&gt;7. Don't Ignore Credentials Errors&lt;/h2&gt;
&lt;p&gt;If the log mentions things such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;credentials
keystore
provisioning profile
certificate
signing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;don't start changing Gradle dependencies.&lt;/p&gt;
&lt;p&gt;You're probably dealing with app signing or build credentials instead.&lt;/p&gt;
&lt;p&gt;For Android, EAS can manage your app signing credentials for you.&lt;/p&gt;
&lt;p&gt;You can inspect the credentials with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;eas credentials
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the error is specifically about signing, work through the credential configuration rather than treating it like a code or dependency problem.&lt;/p&gt;
&lt;p&gt;This is one of those situations where identifying the &lt;strong&gt;category of the error&lt;/strong&gt; is much more useful than knowing a particular command.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;8-if-it-worked-before-ask-what-changed&quot;&gt;8. If It Worked Before, Ask What Changed&lt;/h2&gt;
&lt;p&gt;This is probably my favorite debugging question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;What changed immediately before the build started failing?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Don't underestimate how useful this is.&lt;/p&gt;
&lt;p&gt;If yesterday you had:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EAS build → SUCCESS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and today you have:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;EAS build → FAILED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;something changed.&lt;/p&gt;
&lt;p&gt;Maybe you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;installed a package&lt;/li&gt;
&lt;li&gt;upgraded Expo&lt;/li&gt;
&lt;li&gt;changed &lt;code&gt;app.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;changed &lt;code&gt;eas.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;modified environment variables&lt;/li&gt;
&lt;li&gt;changed an Android dependency&lt;/li&gt;
&lt;li&gt;added a config plugin&lt;/li&gt;
&lt;li&gt;changed native code&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Start there.&lt;/p&gt;
&lt;p&gt;For example, if the failure appeared immediately after:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install some-package
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I would investigate that package before touching ten unrelated parts of the project.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;9-clean-up-before-rebuilding&quot;&gt;9. Clean Up Before Rebuilding&lt;/h2&gt;
&lt;p&gt;If you've already made changes trying to fix the problem, make sure you're not debugging an old local state.&lt;/p&gt;
&lt;p&gt;For a native project, a clean build can sometimes help:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
cd ..
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Expo projects using prebuild, you may also need to regenerate native projects depending on your setup.&lt;/p&gt;
&lt;p&gt;But don't use cleaning as your first response to every error.&lt;/p&gt;
&lt;p&gt;If the build is failing because a dependency is genuinely incompatible, cleaning won't magically make the dependency compatible.&lt;/p&gt;
&lt;p&gt;It only removes cached/generated build artifacts.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-most-important-part-find-the-root-error&quot;&gt;The Most Important Part: Find the Root Error&lt;/h2&gt;
&lt;p&gt;This is the part that took me the longest to appreciate.&lt;/p&gt;
&lt;p&gt;When a build log contains hundreds of lines, the error at the bottom isn't necessarily the error you need to fix.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Task :app:assembleRelease FAILED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not particularly useful.&lt;/p&gt;
&lt;p&gt;Neither is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The useful part might be 30, 50, or even 200 lines above it.&lt;/p&gt;
&lt;p&gt;You might eventually find:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Duplicate class androidx.lifecycle.ViewModel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And suddenly the whole situation makes sense.&lt;/p&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Could not resolve com.google.firebase:...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you're dealing with a dependency resolution problem.&lt;/p&gt;
&lt;p&gt;Or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Missing environment variable: API_URL
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you know exactly where to look.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The final error tells you that the build stopped. The underlying error tells you why.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-if-your-error-doesn-t-match-any-of-these-&quot;&gt;What If Your Error Doesn't Match Any Of These?&lt;/h2&gt;
&lt;p&gt;This is where things get much more frustrating.&lt;/p&gt;
&lt;p&gt;You can search the exact error message and find ten different Stack Overflow answers.&lt;/p&gt;
&lt;p&gt;One says to upgrade Gradle.&lt;/p&gt;
&lt;p&gt;Another says to delete &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another says to run &lt;code&gt;expo prebuild --clean&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Someone else says to downgrade React Native.&lt;/p&gt;
&lt;p&gt;And suddenly you're changing five different things without knowing which one actually matters.&lt;/p&gt;
&lt;p&gt;I've made that mistake myself.&lt;/p&gt;
&lt;p&gt;The problem with large React Native and Expo build logs is that they often contain &lt;strong&gt;a lot of information but very little prioritization&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You don't necessarily need another list of 15 possible fixes.&lt;/p&gt;
&lt;p&gt;You need to know:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Which part of this particular log is actually causing the failure?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's also the problem I wanted to solve with &lt;strong&gt;FixMyError&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of manually digging through a huge React Native or Expo error log and trying to decide which line matters, you can paste the log into &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;FixMyError&lt;/a&gt; and get a focused diagnosis aimed at identifying the most likely cause and the first fix worth trying.&lt;/p&gt;
&lt;p&gt;It's particularly useful when your error doesn't fit neatly into one of the common categories above and you're staring at a wall of Gradle, Expo, or native build output wondering where to even start.&lt;/p&gt;
&lt;p&gt;You don't need to use it for every error. If the log clearly tells you what's wrong, fix it and move on.&lt;/p&gt;
&lt;p&gt;But when the useful information is buried somewhere inside a massive build log, that's exactly when a diagnostic tool becomes useful.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-simple-workflow-i-use-now&quot;&gt;A Simple Workflow I Use Now&lt;/h2&gt;
&lt;p&gt;When an Expo EAS build fails, I don't immediately start changing dependencies or upgrading packages.&lt;/p&gt;
&lt;p&gt;I go through this:&lt;/p&gt;
&lt;h4 id=&quot;1-find-the-actual-failure&quot;&gt;1. Find the actual failure&lt;/h4&gt;
&lt;p&gt;Don't stop at:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find the underlying error.&lt;/p&gt;
&lt;h4 id=&quot;2-identify-the-category&quot;&gt;2. Identify the category&lt;/h4&gt;
&lt;p&gt;Is it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;configuration?&lt;/li&gt;
&lt;li&gt;dependency?&lt;/li&gt;
&lt;li&gt;Android/Gradle?&lt;/li&gt;
&lt;li&gt;iOS/Xcode?&lt;/li&gt;
&lt;li&gt;environment variables?&lt;/li&gt;
&lt;li&gt;credentials?&lt;/li&gt;
&lt;li&gt;native module compatibility?&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-ask-what-changed&quot;&gt;3. Ask what changed&lt;/h4&gt;
&lt;p&gt;What did you install, upgrade, or modify immediately before the failure?&lt;/p&gt;
&lt;h4 id=&quot;4-check-the-full-log&quot;&gt;4. Check the full log&lt;/h4&gt;
&lt;p&gt;Don't assume the last error line is the root cause.&lt;/p&gt;
&lt;h4 id=&quot;5-run-the-relevant-diagnostic-tools&quot;&gt;5. Run the relevant diagnostic tools&lt;/h4&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and inspect your EAS configuration and dependencies.&lt;/p&gt;
&lt;h4 id=&quot;6-change-one-thing-at-a-time&quot;&gt;6. Change one thing at a time&lt;/h4&gt;
&lt;p&gt;Don't simultaneously upgrade Expo, downgrade Gradle, delete &lt;code&gt;node_modules&lt;/code&gt;, regenerate native projects, and change your dependencies.&lt;/p&gt;
&lt;p&gt;If the build starts working afterward, you won't know what actually fixed it.&lt;/p&gt;
&lt;h4 id=&quot;7-if-the-log-is-still-unclear-diagnose-the-log-itself&quot;&gt;7. If the log is still unclear, diagnose the log itself&lt;/h4&gt;
&lt;p&gt;That's where a tool like FixMyError can save time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-2&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;An &lt;code&gt;EAS Build failed&lt;/code&gt; message can look like a single problem, but it really isn't.&lt;/p&gt;
&lt;p&gt;EAS is just the environment where the failure becomes visible.&lt;/p&gt;
&lt;p&gt;The actual cause might be hiding in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Expo configuration&lt;/li&gt;
&lt;li&gt;native dependencies&lt;/li&gt;
&lt;li&gt;Gradle&lt;/li&gt;
&lt;li&gt;Xcode&lt;/li&gt;
&lt;li&gt;environment variables&lt;/li&gt;
&lt;li&gt;credentials&lt;/li&gt;
&lt;li&gt;config plugins&lt;/li&gt;
&lt;li&gt;or a package that isn't compatible with your current SDK&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The biggest improvement you can make to your debugging process is to stop treating:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Build failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;as the error.&lt;/p&gt;
&lt;p&gt;Treat it as the &lt;strong&gt;symptom&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Then work backward until you find the first specific failure that actually explains what happened.&lt;/p&gt;
&lt;p&gt;And if you're staring at a huge build log and can't tell which of the hundreds of lines actually matters, that's precisely the kind of situation where having something analyze the error for you can be useful.&lt;/p&gt;
&lt;p&gt;Happy debugging.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/24440/how-to-fix-eas-build-failed-errors-in-expo</guid>
<pubDate>Tue, 11 Aug 2026 11:46:41 +0000</pubDate>
</item>
<item>
<title>How to Fix `Execution failed for task ':app:mergeDexDebug'` in React Native</title>
<link>https://coderlegion.com/24096/how-to-fix-execution-failed-for-task-app-mergedexdebug-in-react-native</link>
<description>&lt;p&gt;You make a small change to your React Native project, install a new package, or upgrade a dependency. Everything looks fine until you try to build your Android app.&lt;/p&gt;
&lt;p&gt;Then Gradle stops with something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Execution failed for task ':app:mergeDexDebug'.

&amp;gt; com.android.builder.dexing.DexArchiveMergerException:
Error while merging dex archives
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or maybe you see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Cannot fit requested classes in a single dex file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Duplicate class found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, this error feels confusing because it happens during the Android build process, not inside your JavaScript or React Native code.&lt;/p&gt;
&lt;p&gt;Your components are fine. Your logic is fine.&lt;/p&gt;
&lt;p&gt;The problem is usually somewhere inside your Android dependencies.&lt;/p&gt;
&lt;p&gt;In this guide, we will go through what causes &lt;code&gt;mergeDexDebug&lt;/code&gt; failures, how to identify the real problem, and the fixes that actually work.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-does-mergedexdebug-actually-mean-&quot;&gt;What Does &lt;code&gt;mergeDexDebug&lt;/code&gt; Actually Mean?&lt;/h3&gt;
&lt;p&gt;When you build a React Native Android app, your JavaScript code is not the only thing being packaged.&lt;/p&gt;
&lt;p&gt;Your project also includes native Android dependencies from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native modules&lt;/li&gt;
&lt;li&gt;Expo packages&lt;/li&gt;
&lt;li&gt;Firebase libraries&lt;/li&gt;
&lt;li&gt;AndroidX libraries&lt;/li&gt;
&lt;li&gt;Third-party native packages&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;During the Android build, Gradle converts these Java/Kotlin dependencies into DEX files that Android can run.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;mergeDexDebug&lt;/code&gt; task is the step where Android combines those generated DEX files together.&lt;/p&gt;
&lt;p&gt;When this step fails, it usually means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there are too many methods to fit into one DEX file&lt;/li&gt;
&lt;li&gt;two dependencies contain the same classes&lt;/li&gt;
&lt;li&gt;dependency versions are conflicting&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cause-1-your-app-has-too-many-methods-64k-limit-&quot;&gt;Cause 1: Your App Has Too Many Methods (64K Limit)&lt;/h2&gt;
&lt;p&gt;One of the most common reasons for this error is Android's method limit.&lt;/p&gt;
&lt;p&gt;A single DEX file can only contain around 65,536 methods.&lt;/p&gt;
&lt;p&gt;If your project includes many libraries, you can eventually exceed this limit.&lt;/p&gt;
&lt;p&gt;The error usually looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Cannot fit requested classes in a single dex file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;The number of method references in a .dex file cannot exceed 64K
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This happens frequently in projects using packages like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Firebase&lt;/li&gt;
&lt;li&gt;Google Play Services&lt;/li&gt;
&lt;li&gt;Maps&lt;/li&gt;
&lt;li&gt;Analytics libraries&lt;/li&gt;
&lt;li&gt;Multiple native modules&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fix-enable-multidex&quot;&gt;Fix: Enable Multidex&lt;/h3&gt;
&lt;p&gt;Open:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;android/app/build.gradle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;defaultConfig {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;defaultConfig {
    multiDexEnabled true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then add the multidex dependency:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;dependencies {
    implementation 'androidx.multidex:multidex:2.0.1'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, clean your build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
cd ..
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild your app.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cause-2-duplicate-classes-between-dependencies&quot;&gt;Cause 2: Duplicate Classes Between Dependencies&lt;/h2&gt;
&lt;p&gt;Another common reason is dependency duplication.&lt;/p&gt;
&lt;p&gt;You may see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Duplicate class androidx.lifecycle.ViewModel found in modules:

androidx.lifecycle:lifecycle-viewmodel:2.8.2

and

androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means two libraries are trying to include different versions of the same Android classes.&lt;/p&gt;
&lt;p&gt;Gradle does not know which one should be used, so the build fails.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;fix-align-dependency-versions&quot;&gt;Fix: Align Dependency Versions&lt;/h3&gt;
&lt;p&gt;Check your Android dependencies.&lt;/p&gt;
&lt;p&gt;You can inspect your dependency tree with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew app:dependencies
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look for libraries that appear multiple times with different versions.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;androidx.lifecycle:lifecycle-viewmodel:2.8.2

androidx.lifecycle:lifecycle-viewmodel:2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The solution is usually to make all related dependencies use the same version.&lt;/p&gt;
&lt;p&gt;After updating dependencies, run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cause-3-a-recently-installed-native-package-introduced-conflicts&quot;&gt;Cause 3: A Recently Installed Native Package Introduced Conflicts&lt;/h2&gt;
&lt;p&gt;Sometimes the problem appears right after installing a package.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install some-native-library
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything works until the next Android build.&lt;/p&gt;
&lt;p&gt;This happens because native packages are not only JavaScript code. They also add Android dependencies.&lt;/p&gt;
&lt;p&gt;A new package can introduce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;conflicting AndroidX versions&lt;/li&gt;
&lt;li&gt;outdated libraries&lt;/li&gt;
&lt;li&gt;duplicate classes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the error appeared immediately after installing something new, that package is the first thing I would investigate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-find-the-real-cause-faster&quot;&gt;How To Find The Real Cause Faster&lt;/h2&gt;
&lt;p&gt;Instead of guessing, get more information from Gradle.&lt;/p&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew app:assembleDebug --stacktrace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important part is usually not the final error line.&lt;/p&gt;
&lt;p&gt;The useful information is often several lines above it.&lt;/p&gt;
&lt;p&gt;Look for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;duplicate class names&lt;/li&gt;
&lt;li&gt;conflicting library versions&lt;/li&gt;
&lt;li&gt;dependency names&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last line often only says:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:mergeDexDebug'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;but the actual reason appears earlier.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;clean-your-android-build-cache&quot;&gt;Clean Your Android Build Cache&lt;/h2&gt;
&lt;p&gt;After changing dependencies, Gradle may still use old build files.&lt;/p&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
cd ..
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For React Native CLI projects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native run-android
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Expo projects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo run:android
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A surprising number of build problems disappear after a proper clean rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;preventing-future-mergedexdebug-errors&quot;&gt;Preventing Future &lt;code&gt;mergeDexDebug&lt;/code&gt; Errors&lt;/h2&gt;
&lt;p&gt;A few habits can save a lot of debugging time:&lt;/p&gt;
&lt;h3 id=&quot;keep-dependencies-updated&quot;&gt;Keep dependencies updated&lt;/h3&gt;
&lt;p&gt;Old native dependencies are a common source of Android build conflicts.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;avoid-installing-unnecessary-packages&quot;&gt;Avoid installing unnecessary packages&lt;/h3&gt;
&lt;p&gt;Every native package increases the complexity of your Android dependency tree.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;check-changes-after-adding-native-libraries&quot;&gt;Check changes after adding native libraries&lt;/h3&gt;
&lt;p&gt;If your build breaks immediately after installing a package, don't start changing random Gradle files.&lt;/p&gt;
&lt;p&gt;First check what dependency that package introduced.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understand-the-error-before-applying-fixes&quot;&gt;Understand the error before applying fixes&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;mergeDexDebug&lt;/code&gt; failure is not one single problem.&lt;/p&gt;
&lt;p&gt;It is a symptom.&lt;/p&gt;
&lt;p&gt;The real cause could be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;too many methods&lt;/li&gt;
&lt;li&gt;duplicate classes&lt;/li&gt;
&lt;li&gt;incompatible versions&lt;/li&gt;
&lt;li&gt;a broken native dependency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Finding the cause is usually faster than trying random fixes.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;need-help-diagnosing-react-native-errors-&quot;&gt;Need Help Diagnosing React Native Errors?&lt;/h2&gt;
&lt;p&gt;Sometimes build errors are frustrating because the important clue is buried inside hundreds of lines of Gradle output.&lt;/p&gt;
&lt;p&gt;That is exactly why I built &lt;strong&gt;FixMyError&lt;/strong&gt; — a tool designed around the real debugging process developers go through.&lt;/p&gt;
&lt;p&gt;You can paste your React Native or Expo error log and get a focused diagnosis that explains what is likely causing the problem and what fix is worth trying first.&lt;/p&gt;
&lt;p&gt;Instead of searching through dozens of similar Stack Overflow answers, you can start with a clearer direction.&lt;/p&gt;
&lt;p&gt;You can check it out here:&lt;/p&gt;
&lt;p&gt;You can check it out here:&lt;/p&gt;
&lt;h3 id=&quot;fixmyerror&quot;&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;FixMyError&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;React Native Android build errors can look intimidating, but once you understand where the failure happens, most &lt;code&gt;mergeDexDebug&lt;/code&gt; problems become much easier to diagnose.&lt;/p&gt;
&lt;p&gt;The key is not memorizing commands.&lt;/p&gt;
&lt;p&gt;It is learning how to read what Gradle is actually telling you.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/24096/how-to-fix-execution-failed-for-task-app-mergedexdebug-in-react-native</guid>
<pubDate>Tue, 04 Aug 2026 12:27:52 +0000</pubDate>
</item>
<item>
<title>How to Fix &quot;Duplicate class ... found in modules&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/23340/how-to-fix-duplicate-class-found-in-modules-in-react-native-expo</link>
<description>&lt;p&gt;If you've been developing with React Native or Expo for a while, chances are you've had a project that suddenly refuses to build for what seems like no reason.&lt;/p&gt;
&lt;p&gt;Maybe you installed a new package. Maybe you upgraded Expo. Maybe you only changed a single dependency. You confidently run your Android build, expecting it to compile like always...&lt;/p&gt;
&lt;p&gt;Instead, Gradle greets you with something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Execution failed for task ':app:checkDebugDuplicateClasses'.

Duplicate class androidx.lifecycle.ViewModelLazy found in modules lifecycle-viewmodel-2.8.2.aar and lifecycle-viewmodel-ktx-2.6.1.aar

Duplicate class com.google.android.gms.internal.measurement.zzab found in modules play-services-measurement-base-22.0.0.aar and play-services-measurement-impl-21.6.2.aar

Duplicate class ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, the error looks straightforward: &lt;em&gt;there are duplicate classes&lt;/em&gt;. Easy enough, right?&lt;/p&gt;
&lt;p&gt;Not exactly.&lt;/p&gt;
&lt;p&gt;In reality, this is one of those Android build errors that can send you down a rabbit hole of Gradle files, dependency trees and Stack Overflow threads, only to realize the real cause was something completely different.&lt;/p&gt;
&lt;p&gt;Let's break down what's actually happening and, more importantly, how to fix it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-this-error-happens&quot;&gt;Why this error happens&lt;/h2&gt;
&lt;p&gt;Every Android library included in your project contains compiled Java or Kotlin classes.&lt;/p&gt;
&lt;p&gt;When Gradle builds your application, it combines all of those libraries into a single APK or AAB.&lt;/p&gt;
&lt;p&gt;If two different dependencies contain the exact same class, Gradle doesn't know which version should be packaged.&lt;/p&gt;
&lt;p&gt;Instead of guessing, it stops the build with a &lt;strong&gt;Duplicate class&lt;/strong&gt; error.&lt;/p&gt;
&lt;p&gt;The difficult part is that the dependency causing the conflict usually isn't the one shown in the error.&lt;/p&gt;
&lt;p&gt;Very often, it's another package pulling in an older or incompatible version behind the scenes.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-most-common-causes&quot;&gt;The most common causes&lt;/h2&gt;
&lt;p&gt;After seeing this error many times, these are usually the culprits.&lt;/p&gt;
&lt;h3 id=&quot;1-two-libraries-depend-on-different-versions-of-the-same-package&quot;&gt;1. Two libraries depend on different versions of the same package&lt;/h3&gt;
&lt;p&gt;This is by far the most common scenario.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Library A requires:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;androidx.lifecycle:lifecycle-viewmodel:2.8.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;while Library B depends on:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;androidx.lifecycle:lifecycle-viewmodel:2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both versions get added to your dependency graph, eventually leading to duplicate classes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-an-outdated-react-native-package&quot;&gt;2. An outdated React Native package&lt;/h3&gt;
&lt;p&gt;Older React Native libraries often haven't been updated for newer AndroidX or Google Play Services versions.&lt;/p&gt;
&lt;p&gt;Even though your project is current, that one dependency may still reference libraries released years ago.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-expo-sdk-upgrades&quot;&gt;3. Expo SDK upgrades&lt;/h3&gt;
&lt;p&gt;After upgrading Expo, many native dependency versions change.&lt;/p&gt;
&lt;p&gt;If one third-party package hasn't caught up yet, Gradle may end up resolving incompatible versions.&lt;/p&gt;
&lt;p&gt;This is especially common immediately after major SDK releases.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-mixing-manual-native-configuration-with-expo-managed-dependencies&quot;&gt;4. Mixing manual native configuration with Expo-managed dependencies&lt;/h3&gt;
&lt;p&gt;Sometimes developers manually add Android dependencies that Expo already manages internally.&lt;/p&gt;
&lt;p&gt;The result is two copies of essentially the same library.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-fix-it&quot;&gt;How to fix it&lt;/h2&gt;
&lt;p&gt;There isn't one universal solution, but these steps solve the majority of cases.&lt;/p&gt;
&lt;h3 id=&quot;step-1-read-the-duplicated-class-carefully&quot;&gt;Step 1 — Read the duplicated class carefully&lt;/h3&gt;
&lt;p&gt;Don't focus on the entire error.&lt;/p&gt;
&lt;p&gt;Look specifically for the two modules involved.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;lifecycle-viewmodel-2.8.2

lifecycle-viewmodel-ktx-2.6.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those names usually point you toward the conflicting dependency.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-find-who-is-importing-it&quot;&gt;Step 2 — Find who is importing it&lt;/h3&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew app:dependencies
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./gradlew app:dependencyInsight --dependency lifecycle-viewmodel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This shows which package is pulling each version into your project.&lt;/p&gt;
&lt;p&gt;It's much faster than guessing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-update-outdated-packages&quot;&gt;Step 3 — Update outdated packages&lt;/h3&gt;
&lt;p&gt;If one library is significantly behind, updating it often resolves the conflict immediately.&lt;/p&gt;
&lt;p&gt;Always check whether a newer version exists before trying complicated Gradle workarounds.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-clean-the-project&quot;&gt;Step 4 — Clean the project&lt;/h3&gt;
&lt;p&gt;Sometimes Gradle continues using cached artifacts.&lt;/p&gt;
&lt;p&gt;Try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android

./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-regenerate-native-files-expo-&quot;&gt;Step 5 — Regenerate native files (Expo)&lt;/h3&gt;
&lt;p&gt;If you're using Expo Prebuild:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo prebuild --clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This recreates the native Android project using your current configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-force-dependency-versions-only-if-necessary-&quot;&gt;Step 6 — Force dependency versions (only if necessary)&lt;/h3&gt;
&lt;p&gt;Sometimes you need to force Gradle to use one specific version.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;configurations.all {
    resolutionStrategy {
        force &quot;androidx.lifecycle:lifecycle-viewmodel:2.8.2&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this carefully.&lt;/p&gt;
&lt;p&gt;If two libraries genuinely require incompatible versions, forcing one version may create runtime problems later.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-avoid-this-error-in-the-future&quot;&gt;How to avoid this error in the future&lt;/h2&gt;
&lt;p&gt;While duplicate class errors can't always be prevented, these habits reduce the chances considerably.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep Expo SDKs reasonably up to date.&lt;/li&gt;
&lt;li&gt;Avoid abandoned React Native libraries.&lt;/li&gt;
&lt;li&gt;Update dependencies together instead of one at a time.&lt;/li&gt;
&lt;li&gt;Review package compatibility before major upgrades.&lt;/li&gt;
&lt;li&gt;Avoid manually adding Android dependencies unless absolutely necessary.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;when-the-error-isn-t-actually-the-problem&quot;&gt;When the error isn't actually the problem&lt;/h2&gt;
&lt;p&gt;One thing that makes this error frustrating is that the duplicate classes are often just a symptom.&lt;/p&gt;
&lt;p&gt;The real issue might be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an outdated library,&lt;/li&gt;
&lt;li&gt;conflicting Firebase dependencies,&lt;/li&gt;
&lt;li&gt;mixed AndroidX versions,&lt;/li&gt;
&lt;li&gt;incompatible Google Play Services packages,&lt;/li&gt;
&lt;li&gt;or an incomplete Expo migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That's why simply searching the duplicated class name doesn't always lead to the correct fix.&lt;/p&gt;
&lt;p&gt;Understanding &lt;strong&gt;why&lt;/strong&gt; those dependencies ended up together is usually more important than the class itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-fixmyerror-can-help&quot;&gt;How FixMyError can help&lt;/h2&gt;
&lt;p&gt;Instead of manually digging through Gradle output, dependency trees and dozens of forum posts, you can paste the full error into &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;FixMyError&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The tool analyzes the build log, identifies the likely source of the conflict, explains what's happening in plain English, and suggests fixes based on the specific dependencies involved.&lt;/p&gt;
&lt;p&gt;Rather than treating every duplicate class error as identical, it tries to narrow the problem down to the actual cause behind your particular build failure.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-3&quot;&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;Duplicate class errors are among the most frustrating Android build issues because the message rarely points directly to the package that's responsible.&lt;/p&gt;
&lt;p&gt;The good news is that, in most cases, the problem comes down to dependency resolution rather than something fundamentally broken in your project.&lt;/p&gt;
&lt;p&gt;Once you identify which libraries are introducing conflicting versions, the fix usually becomes much more straightforward.&lt;/p&gt;
&lt;p&gt;If you're currently staring at a wall of Gradle output wondering where to begin, start by identifying the conflicting modules, trace where they're coming from, and work outward from there.&lt;/p&gt;
&lt;p&gt;It takes a bit of patience, but it's almost always easier than blindly trying random fixes from different Stack Overflow threads.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/23340/how-to-fix-duplicate-class-found-in-modules-in-react-native-expo</guid>
<pubDate>Thu, 23 Jul 2026 12:36:11 +0000</pubDate>
</item>
<item>
<title>How to Fix: &quot;Namespace not specified&quot; Gradle 8 Error in React Native &amp; Expo</title>
<link>https://coderlegion.com/22158/how-to-fix-namespace-not-specified-gradle-8-error-in-react-native-expo</link>
<description>&lt;h2 id=&quot;fixing-the-namespace-not-specified-error-in-react-native-and-expo-android-builds&quot;&gt;Fixing the &quot;Namespace Not Specified&quot; Error in React Native and Expo Android Builds&lt;/h2&gt;
&lt;p&gt;If you recently upgraded a project to React Native 0.73+ or bumped your Expo SDK to version 50+, you may have run into an Android build error that looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring project ':react-native-some-old-library'.

&amp;gt; Namespace not specified for group 'react-native-some-old-library'
  and version '1.0.0' as per the Android Gradle Plugin 8.0+ guidelines.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At first glance, it looks like something is wrong with your app. In most cases, it isn't.&lt;/p&gt;
&lt;p&gt;The error is usually coming from an older dependency inside &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here's what's actually causing it and how to fix it properly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-root-cause&quot;&gt;The Root Cause&lt;/h3&gt;
&lt;p&gt;Before Android Gradle Plugin (AGP) 8.0, Android libraries could define their package name inside &lt;code&gt;AndroidManifest.xml&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;
    package=&quot;com.someoldlibrary&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Starting with AGP 8.0, that is no longer enough.&lt;/p&gt;
&lt;p&gt;Every Android module is now expected to define a &lt;code&gt;namespace&lt;/code&gt; inside its &lt;code&gt;build.gradle&lt;/code&gt; file.&lt;/p&gt;
&lt;p&gt;When you upgrade React Native or Expo, your Android tooling gets upgraded as well. The problem is that many older React Native libraries haven't been updated to follow the new requirement.&lt;/p&gt;
&lt;p&gt;As soon as Gradle encounters one of those libraries, it stops during configuration and the build fails.&lt;/p&gt;
&lt;p&gt;This started showing up much more frequently after Expo SDK 50 and newer React Native releases adopted newer Android tooling.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;before-you-change-anything&quot;&gt;Before You Change Anything&lt;/h3&gt;
&lt;p&gt;Look closely at the error message.&lt;/p&gt;
&lt;p&gt;Most of the time you'll see something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A problem occurred configuring project ':react-native-some-old-library'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That project name is usually the library causing the issue.&lt;/p&gt;
&lt;p&gt;A common mistake is to start editing your app's own Gradle files. In most cases, the problem is actually inside the dependency listed in the error.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fix-use-patch-package&quot;&gt;The Fix: Use patch-package&lt;/h3&gt;
&lt;p&gt;Since the problem comes from a third-party library, editing files directly inside &lt;code&gt;node_modules&lt;/code&gt; isn't a real fix.&lt;/p&gt;
&lt;p&gt;The next time you run &lt;code&gt;npm install&lt;/code&gt;, your changes will be overwritten.&lt;/p&gt;
&lt;p&gt;Instead, make the change once and save it using &lt;code&gt;patch-package&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;step-1-find-the-package-name&quot;&gt;Step 1: Find the Package Name&lt;/h4&gt;
&lt;p&gt;Navigate to the library's manifest file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;node_modules/react-native-some-old-library/android/src/main/AndroidManifest.xml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look for the package attribute:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;manifest
    package=&quot;com.someoldlibrary&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Copy that value.&lt;/p&gt;
&lt;p&gt;You'll use it as the namespace in the next step.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;step-2-add-the-missing-namespace&quot;&gt;Step 2: Add the Missing Namespace&lt;/h4&gt;
&lt;p&gt;Open:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;node_modules/react-native-some-old-library/android/build.gradle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find the &lt;code&gt;android {}&lt;/code&gt; block and add the namespace at the top:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;android {
    namespace &quot;com.someoldlibrary&quot;

    compileSdkVersion safeExtGet('compileSdkVersion', 34)

    defaultConfig {
        minSdkVersion safeExtGet('minSdkVersion', 21)
        targetSdkVersion safeExtGet('targetSdkVersion', 34)
    }

    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure the namespace exactly matches the package name from the manifest.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;step-3-generate-a-patch&quot;&gt;Step 3: Generate a Patch&lt;/h4&gt;
&lt;p&gt;From your project root, run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx patch-package react-native-some-old-library
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a file inside a new &lt;code&gt;patches&lt;/code&gt; directory containing the modification you just made.&lt;/p&gt;
&lt;p&gt;Commit that file to Git.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;step-4-apply-the-patch-automatically&quot;&gt;Step 4: Apply the Patch Automatically&lt;/h4&gt;
&lt;p&gt;Open &lt;code&gt;package.json&lt;/code&gt; and add a &lt;code&gt;postinstall&lt;/code&gt; script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;scripts&quot;: {
    &quot;start&quot;: &quot;expo start&quot;,
    &quot;android&quot;: &quot;expo run:android&quot;,
    &quot;ios&quot;: &quot;expo run:ios&quot;,
    &quot;postinstall&quot;: &quot;patch-package&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now whenever someone runs &lt;code&gt;npm install&lt;/code&gt;—whether it's you, another developer, CI, or EAS Build—the patch gets applied automatically.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;that-s-it&quot;&gt;That's It&lt;/h3&gt;
&lt;p&gt;Clear any caches if needed and run your Android build again.&lt;/p&gt;
&lt;p&gt;The build should now complete normally.&lt;/p&gt;
&lt;p&gt;More importantly, you won't have to keep making the same edit every time &lt;code&gt;node_modules&lt;/code&gt; gets reinstalled.&lt;/p&gt;
&lt;p&gt;If you're working with older React Native dependencies, this is usually the cleanest way to handle AGP 8 namespace issues without waiting for the library maintainer to release an update.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/22158/how-to-fix-namespace-not-specified-gradle-8-error-in-react-native-expo</guid>
<pubDate>Thu, 09 Jul 2026 12:45:41 +0000</pubDate>
</item>
<item>
<title>How to Fix FCM &quot;MismatchSenderId&quot; in Multi-Environment Expo (EAS) Builds</title>
<link>https://coderlegion.com/21444/how-to-fix-fcm-mismatchsenderid-in-multi-environment-expo-eas-builds</link>
<description>&lt;h2 id=&quot;how-to-fix-mismatchsenderid-in-expo-eas-push-notifications-across-multiple-environments&quot;&gt;How to Fix &lt;code&gt;MismatchSenderId&lt;/code&gt; in Expo EAS Push Notifications Across Multiple Environments&lt;/h2&gt;
&lt;p&gt;If you are managing multiple environments (Staging, Preview, Production) in a React Native Expo app, setting up Firebase Cloud Messaging (FCM) can quickly turn into a credential nightmare.&lt;/p&gt;
&lt;p&gt;You set up separate Firebase projects for each environment, download your config files, and configure your service accounts. Everything looks right. But when you test push notifications on your staging build, you hit a wall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Error: MismatchSenderId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This error doesn't mean your code is broken. It means you've hit a structural limitation in how Expo EAS handles push credentials.&lt;/p&gt;
&lt;p&gt;Here is exactly why this happens and how to architect a clean solution.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-the-mismatch-happens&quot;&gt;Why the Mismatch Happens&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;MismatchSenderId&lt;/code&gt; error occurs because of a strict identity mismatch:&lt;/p&gt;
&lt;p&gt;The device token was generated by Firebase Project A, but the notification payload was dispatched using credentials from Firebase Project B.&lt;/p&gt;
&lt;p&gt;In a standard web or backend setup, this is easy to manage. But in the Expo ecosystem, you run into the EAS Project Limit:&lt;/p&gt;
&lt;p&gt;Expo maps push notification service accounts per EAS Project, not per EAS Build Profile (&lt;code&gt;development&lt;/code&gt;, &lt;code&gt;preview&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;When you run &lt;code&gt;eas credentials&lt;/code&gt; or use the automated CLI prompts, EAS expects one default push notification Service Account (SA) key for the entire project.&lt;/p&gt;
&lt;p&gt;If you try to reuse or attach the same service account across different Google Cloud/Firebase projects via IAM permissions, FCM will reject the cross-project token request, throwing &lt;code&gt;MismatchSenderId&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;solution-1-split-your-eas-projects-recommended-for-clean-automation-&quot;&gt;Solution 1: Split Your EAS Projects (Recommended for Clean Automation)&lt;/h2&gt;
&lt;p&gt;If you want to rely on Expo's automated push notification infrastructure without overriding credentials constantly, the cleanest architectural fix is to separate your environments at the EAS project level.&lt;/p&gt;
&lt;p&gt;Instead of one EAS project with multiple build profiles, initialize separate EAS projects in your ecosystem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;your-app-staging&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;your-app-production&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;implementation-steps&quot;&gt;Implementation Steps&lt;/h3&gt;
&lt;p&gt;In your &lt;code&gt;app.json&lt;/code&gt;, dynamically switch the &lt;code&gt;expo.projectId&lt;/code&gt; and &lt;code&gt;expo.slug&lt;/code&gt; based on an environment variable during your build step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;expo&quot;: {
    &quot;name&quot;: &quot;Your App&quot;,
    &quot;slug&quot;: &quot;your-app-production&quot;,
    &quot;extra&quot;: {
      &quot;eas&quot;: {
        &quot;projectId&quot;: &quot;YOUR-PROD-PROJECT-ID&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Link your staging build profile to your staging EAS project.&lt;/p&gt;
&lt;p&gt;Run &lt;code&gt;eas credentials&lt;/code&gt; for each project separately. This allows you to upload the staging FCM Service Account to your staging EAS project, and the production FCM Service Account to your production EAS project.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;solution-2-bypass-eas-and-route-tokens-on-your-backend&quot;&gt;Solution 2: Bypass EAS and Route Tokens on Your Backend&lt;/h2&gt;
&lt;p&gt;If separating your EAS projects isn't an option and you need to keep everything under a single Expo App ID, you have to stop relying on Expo's unified push notification server and handle routing downstream.&lt;/p&gt;
&lt;h3 id=&quot;implementation-steps-1&quot;&gt;Implementation Steps&lt;/h3&gt;
&lt;h4 id=&quot;tag-tokens-by-environment&quot;&gt;Tag Tokens by Environment&lt;/h4&gt;
&lt;p&gt;When your app requests a push notification token using &lt;code&gt;Expo.getExpoPushTokenAsync()&lt;/code&gt;, append the current build environment metadata before sending it to your database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const token = await Expo.getExpoPushTokenAsync();

// Save to backend alongside the environment flag
await saveTokenToBackend({
  token: token.data,
  environment: process.env.EXPO_PUBLIC_APP_ENV // 'staging' or 'production'
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;handle-routing-on-your-server&quot;&gt;Handle Routing on Your Server&lt;/h4&gt;
&lt;p&gt;On your backend infrastructure, do not use a single global Firebase initialization instance.&lt;/p&gt;
&lt;p&gt;Initialize multiple Firebase Admin SDK instances using the respective service account JSON keys for each environment.&lt;/p&gt;
&lt;p&gt;When triggering a notification, check the token's environment tag and dispatch it explicitly through the matching Firebase Admin instance.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;verification-checklist-before-your-next-build&quot;&gt;Verification Checklist Before Your Next Build&lt;/h2&gt;
&lt;p&gt;Before you trigger your next EAS build, double-check that your native configuration files aren't bleeding into each other:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Config Plugins:&lt;/strong&gt; Ensure your &lt;code&gt;app.json&lt;/code&gt; or &lt;code&gt;app.config.js&lt;/code&gt; uses a dynamic config plugin to load &lt;code&gt;google-services.json&lt;/code&gt; (Android) and &lt;code&gt;GoogleService-Info.plist&lt;/code&gt; (iOS) based on the target build profile. If your staging build accidentally bundles the production &lt;code&gt;google-services.json&lt;/code&gt;, your token identities will instantly mismatch.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sender IDs:&lt;/strong&gt; Verify that the sender ID inside your bundled client-side Firebase config explicitly matches the project ID of the service account used by your notification server.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;I actually built a quick web tool called &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;Fix My Error&lt;/a&gt; to automate troubleshooting these kinds of React Native, Expo, and Gradle build traps. If you're stuck on a cryptic error log, feel free to drop it in there to grab the configuration fix.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/21444/how-to-fix-fcm-mismatchsenderid-in-multi-environment-expo-eas-builds</guid>
<pubDate>Sat, 27 Jun 2026 09:03:38 +0000</pubDate>
</item>
<item>
<title>How to Fix Expo's Cryptic Upgrade Trap: Android Resource Linking Failed (splashscreen_logo not found</title>
<link>https://coderlegion.com/21025/how-expos-cryptic-upgrade-trap-android-resource-linking-failed-splashscreenlogo-found</link>
<description>&lt;h2 id=&quot;how-to-fix-resource-drawable-splashscreen-logo-not-found-after-an-expo-sdk-upgrade&quot;&gt;How to Fix &quot;resource drawable/splashscreen_logo not found&quot; After an Expo SDK Upgrade&lt;/h2&gt;
&lt;p&gt;Upgrading your Expo SDK version to keep up with the latest App Store and Google Play requirements is usually a smooth process—until Gradle decides to punch you in the face with a cryptic asset compilation error.&lt;/p&gt;
&lt;p&gt;If you just kicked off an Android build or ran a prebuild, and your terminal suddenly spit out a wall of red text looking exactly like this, you are not alone:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
&amp;gt; Android resource linking failed
error: resource drawable/splashscreen_logo (aka com.yourcompany.app:drawable/splashscreen_logo) not found.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worst part about this error is that it doesn't give you a file name, a line number, or any mention of Expo.&lt;/p&gt;
&lt;p&gt;It just drops a native Android resource compilation failure and leaves you to figure out the rest.&lt;/p&gt;
&lt;p&gt;Let's break down exactly why this happens during Expo upgrades and how to fix it in two minutes flat.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;why-this-happens-the-missing-asset-trap&quot;&gt;Why This Happens: The Missing Asset Trap&lt;/h2&gt;
&lt;p&gt;This issue is a classic side effect of how newer Expo SDK versions handle Continuous Native Generation (CNG) via the &lt;code&gt;expo-splash-screen&lt;/code&gt; plugin.&lt;/p&gt;
&lt;p&gt;In older versions of Expo, if you only wanted a solid background color for your splash screen, you could get away with just defining &lt;code&gt;backgroundColor&lt;/code&gt; in your config and skipping the logo image entirely.&lt;/p&gt;
&lt;p&gt;The native generator would handle it gracefully.&lt;/p&gt;
&lt;p&gt;However, in newer Expo SDK configurations, the prebuild engine has become much more strict.&lt;/p&gt;
&lt;p&gt;If the &lt;code&gt;image&lt;/code&gt; property is missing from your splash screen configuration, the Expo generator completely skips creating the native Android XML drawable references for the logo.&lt;/p&gt;
&lt;p&gt;When the Android Gradle plugin compiles the app layer, it looks into the generated styles for the splash screen layout, tries to link the &lt;code&gt;splashscreen_logo&lt;/code&gt; asset, finds a blank void, and crashes the entire build.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;how-to-fix-it-1&quot;&gt;How to Fix It&lt;/h2&gt;
&lt;p&gt;To resolve this, we need to explicitly force the Expo prebuild engine to map a valid drawable resource so Gradle stops complaining.&lt;/p&gt;
&lt;h3 id=&quot;step-1-update-your-app-configuration&quot;&gt;Step 1: Update Your App Configuration&lt;/h3&gt;
&lt;p&gt;Open your &lt;code&gt;app.json&lt;/code&gt; (or &lt;code&gt;app.config.js&lt;/code&gt;) file and locate your &lt;code&gt;plugins&lt;/code&gt; array.&lt;/p&gt;
&lt;p&gt;Make sure the &lt;code&gt;expo-splash-screen&lt;/code&gt; plugin block contains both a background color and an explicit path to a fallback image asset.&lt;/p&gt;
&lt;p&gt;Even if you don't actually want a logo on your splash screen, you need to provide a placeholder image (like a tiny 1x1 transparent or solid pixel matching your background color) to satisfy the native compiler.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;plugins&quot;: [
  [
    &quot;expo-splash-screen&quot;,
    {
      &quot;backgroundColor&quot;: &quot;#ffffff&quot;,
      &quot;image&quot;: &quot;./assets/splash-icon.png&quot;
    }
  ]
]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-clear-and-regenerate-native-directories&quot;&gt;Step 2: Clear and Regenerate Native Directories&lt;/h3&gt;
&lt;p&gt;Because native Android build directories heavily cache old configurations, simply changing &lt;code&gt;app.json&lt;/code&gt; isn't always enough to clear out the corrupted Gradle state.&lt;/p&gt;
&lt;p&gt;Run the following commands in your terminal to wipe the slate clean and force a fresh native generation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# If you are testing local native builds:
npx expo prebuild --clean

# Or if you are running a fresh release compilation:
npx expo run:android --variant release --no-build-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the prebuild engine re-runs with the new configuration, it will successfully generate the &lt;code&gt;splashscreen_logo.xml&lt;/code&gt; drawable file inside your native directories, and Gradle will breeze right past the linking stage.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;interactive-reference-amp-live-fix&quot;&gt;Interactive Reference &amp;amp; Live Fix&lt;/h2&gt;
&lt;p&gt;If you want to view a fully verified configuration block or run this code through an interactive debugger to make sure your syntax matches up perfectly, you can check out the public resolution ledger here:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;Fix My Error&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-4&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Native Android build failures often look terrifying because the error message points to generated resources rather than the actual configuration mistake.&lt;/p&gt;
&lt;p&gt;In this case, the root cause is usually a missing splash screen image definition after upgrading to a newer Expo SDK version.&lt;/p&gt;
&lt;p&gt;Once you add a valid image path and regenerate your native directories, the build should proceed normally.&lt;/p&gt;
&lt;p&gt;Have you run into any other weird compilation errors while moving your project up to the latest Expo SDK?&lt;/p&gt;
&lt;p&gt;Drop them in the comments below and let's unblock them.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/21025/how-expos-cryptic-upgrade-trap-android-resource-linking-failed-splashscreenlogo-found</guid>
<pubDate>Sat, 20 Jun 2026 12:10:50 +0000</pubDate>
</item>
<item>
<title>Android 17 released</title>
<link>https://coderlegion.com/20794/android-17-released</link>
<description>&lt;p&gt;Google has released Android 17 and started rolling it out to most supported Pixel devices. The Android 17 source code is also available through AOSP, while new devices running the release are expected over the coming months.&lt;/p&gt;
&lt;p&gt;Android 17 pushes the ecosystem toward adaptive layouts, Compose-first development, stronger privacy controls, stricter performance behavior, and new AI-facing app capabilities.&lt;/p&gt;
&lt;h3 id=&quot;adaptive-first-becomes-the-default&quot;&gt;Adaptive-first becomes the default&lt;/h3&gt;
&lt;p&gt;The biggest practical change is around large screens. For apps targeting API 37, Android 17 removes the ability to opt out of orientation and resizability behavior on large screen devices. The system can ignore legacy restrictions such as screenOrientation, setRequestedOrientation(), resizeableActivity=false, and min or max aspect ratio constraints. Games remain exempt, but ordinary apps need to handle changing window sizes as a normal runtime condition.&lt;/p&gt;
&lt;p&gt;That direction is reinforced by new multitasking features. Android 17 introduces App Bubbles for any app, a Bubble Bar on tablets and foldables, and interactive Picture-in-Picture for desktop-style environments. Apps that only look correct in a single portrait phone window will be under more pressure than before.&lt;/p&gt;
&lt;p&gt;Activity recreation behavior is also changing. Android 17 no longer restarts activities by default for several common configuration changes that do not require a full UI redraw. Apps receive those updates through onConfigurationChanged(). Apps that depend on a full restart can opt in with android:recreateOnConfigChanges.&lt;/p&gt;
&lt;h3 id=&quot;android-starts-exposing-app-actions-to-ai-agents&quot;&gt;Android starts exposing app actions to AI agents&lt;/h3&gt;
&lt;p&gt;Google describes Android 17 as the beginning of a transition from an operating system to an intelligence system. The main developer-facing part of that shift is AppFunctions, a platform API with a Jetpack library that lets apps expose their own actions as callable tools for on-device agents.&lt;/p&gt;
&lt;p&gt;In practice, this means an app can describe workflows such as creating a note, starting a task, or updating local data, then make those workflows discoverable to agent experiences. Gemini integration is still in private preview, but Google is already encouraging developers to identify the actions that make sense to expose.&lt;/p&gt;
&lt;h3 id=&quot;compose-is-now-the-forward-path&quot;&gt;Compose is now the forward path&lt;/h3&gt;
&lt;p&gt;Android development is now officially Compose-first. Google says new Android APIs, libraries, tools, and guidance will be built for Jetpack Compose. Legacy View components and View-based Jetpack libraries, including Fragments, RecyclerView, and ViewPager, are moving into maintenance mode with critical fixes only.&lt;/p&gt;
&lt;p&gt;For existing apps, this does not mean rewriting everything immediately. It does mean that new UI work, adaptive navigation, multi-pane layouts, non-touch input support, and dynamic window handling will be much easier to align with the platform when built in Compose.&lt;/p&gt;
&lt;h3 id=&quot;performance-rules-get-stricter&quot;&gt;Performance rules get stricter&lt;/h3&gt;
&lt;p&gt;Android 17 introduces stricter app memory limits based on total device RAM. If a foreground app or service exceeds those limits, the system can terminate the process. Google is pairing this with tooling such as the R8 configuration analyzer, LeakCanary integration in Android Studio, ApplicationExitInfo diagnostics, and trigger-based profiling through ProfilingManager.&lt;/p&gt;
&lt;p&gt;The platform also adds generational garbage collection improvements, a lock-free MessageQueue for apps targeting SDK 37 or higher, stricter handling for static final fields, and tighter custom notification view limits. Apps that depend on reflection into framework internals should be tested carefully.&lt;/p&gt;
&lt;h3 id=&quot;privacy-and-security-changes-to-watch&quot;&gt;Privacy and security changes to watch&lt;/h3&gt;
&lt;p&gt;Android 17 continues the move away from broad, permanent permissions. The release adds a system-level contact picker, customization options for the photo picker, a system-rendered location button for session-only precise location access, and an EyeDropper API for selecting a color from the display without broad screen capture access.&lt;/p&gt;
&lt;p&gt;Local network access is also changing. Apps targeting Android 17 need either the ACCESS_LOCAL_NETWORK runtime permission or a system-mediated device picker for local network communication. SMS OTP protection is stronger as well, with Android delaying access to one-time-password messages in cases where apps are not the intended recipient or are targeting SDK 37 and above.&lt;/p&gt;
&lt;p&gt;Other security updates include post-quantum cryptography support through secure hardware on supported devices, hybrid APK signing with v3.2, stricter native dynamic code loading, and safer password entry behavior for physical keyboards.&lt;/p&gt;
&lt;h3 id=&quot;media-camera-and-accessibility-updates&quot;&gt;Media, camera, and accessibility updates&lt;/h3&gt;
&lt;p&gt;Android 17 adds several media and camera capabilities, including Eclipsa Video, RAW14 image support, vendor-defined camera extensions, an Extended HE-AAC software encoder, H.266 support paths, APIs for identifying camera device type, and constant-quality video recording controls.&lt;/p&gt;
&lt;p&gt;Accessibility also gets attention through better Bluetooth LE Audio hearing aid support and more granular routing for notifications, ringtones, and alarms. CameraX and Media3 have been updated for Android 17, and Google notes that CameraX users should update to version 1.5.2 or 1.6.0+ to avoid a crash related to a newly added dynamic range mode.&lt;/p&gt;
&lt;p&gt;Official announcement:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://android-developers.googleblog.com/2026/06/Android-17.html&quot;&gt;https://android-developers.googleblog.com/2026/06/Android-17.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Android 17 developer site:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.android.com/about/versions/17&quot;&gt;https://developer.android.com/about/versions/17&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20794/android-17-released</guid>
<pubDate>Wed, 17 Jun 2026 09:08:31 +0000</pubDate>
</item>
<item>
<title>How to Fix &quot;Module Could Not Be Found&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/20703/how-to-fix-module-could-not-be-found-in-react-native-expo</link>
<description>&lt;p&gt;We’ve all been there.&lt;/p&gt;
&lt;p&gt;You find an awesome library, you run &lt;code&gt;npx expo install&lt;/code&gt;, you import it into your code, and you start your development server.&lt;/p&gt;
&lt;p&gt;You expect magic.&lt;/p&gt;
&lt;p&gt;Instead, your simulator turns blindingly red with an error that looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ERROR Invariant Violation: TurboModuleRegistry.getEnforcing(...):
'RNGestureHandlerModule' could not be found.

Verify that your native modules are linked correctly.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your app is completely bricked, your Metro bundler is acting confused, and you’re left wondering why a package you just installed is allegedly missing from the face of the earth.&lt;/p&gt;
&lt;p&gt;Let's look at exactly why this happens and the 3-step checklist to clear it up without losing your sanity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-root-cause-javascript-vs-native-code&quot;&gt;The Root Cause: JavaScript vs. Native Code&lt;/h3&gt;
&lt;p&gt;Modern Expo apps are beautifully split into two worlds:&lt;/p&gt;
&lt;h2 id=&quot;the-js-bundle-&quot;&gt;The JS Bundle:&lt;/h2&gt;
&lt;p&gt;Your components, logic, and regular styles. Metro can hot-reload this in milliseconds.&lt;/p&gt;
&lt;h2 id=&quot;the-native-layer-&quot;&gt;The Native Layer:&lt;/h2&gt;
&lt;p&gt;The underlying Kotlin/Java and Swift/Objective-C code that actually talks to the phone's hardware.&lt;/p&gt;
&lt;p&gt;When you install a package that uses native code (like &lt;code&gt;react-native-gesture-handler&lt;/code&gt;, &lt;code&gt;react-native-reanimated&lt;/code&gt;, or a map library), Metro cannot hot-reload native code into an active app binary.&lt;/p&gt;
&lt;p&gt;If you are running a pre-built Development Client or using Expo Go, it only knows about the native modules that were compiled the last time you built the app.&lt;/p&gt;
&lt;p&gt;It has no idea this new native module exists yet, so the registry panics and throws an &lt;code&gt;Invariant Violation&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-ultimate-un-brick-my-app-checklist&quot;&gt;The Ultimate &quot;Un-Brick My App&quot; Checklist&lt;/h2&gt;
&lt;p&gt;Next time this red screen of death pops up, run through these three steps in order.&lt;/p&gt;
&lt;h3 id=&quot;1-rebuild-the-app-binary-the-absolute-must-&quot;&gt;1. Rebuild the App Binary (The Absolute Must)&lt;/h3&gt;
&lt;p&gt;Simply restarting the Metro bundler won’t cut it.&lt;/p&gt;
&lt;p&gt;You need to recompile your native code so the new library gets bundled into the actual simulator app.&lt;/p&gt;
&lt;p&gt;Stop your server and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# For Android Simulators / Devices
npx expo run:android

# For iOS Simulators
npx expo run:ios
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-force-a-clean-prebuild-if-using-custom-native-directories-&quot;&gt;2. Force a Clean Prebuild (If Using Custom Native Directories)&lt;/h3&gt;
&lt;p&gt;If you are managing your own &lt;code&gt;android&lt;/code&gt; or &lt;code&gt;ios&lt;/code&gt; directories and things get desynced, force Expo to regenerate them with the new native dependencies linked:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo prebuild --clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This removes the generated native projects and recreates them from your Expo configuration, ensuring newly installed native packages are correctly integrated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-nuke-the-metro-cache&quot;&gt;3. Nuke the Metro Cache&lt;/h3&gt;
&lt;p&gt;Sometimes Metro holds onto a stale dependency graph like a grudge.&lt;/p&gt;
&lt;p&gt;If you’ve rebuilt the binary and it still complains, start your project while forcing a total cache clearance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This clears Metro's cache and forces it to rebuild the dependency graph from scratch.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-5&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Native linking issues can feel incredibly frustrating because nothing appears wrong in your JavaScript code.&lt;/p&gt;
&lt;p&gt;The problem is usually that your app binary and your JavaScript bundle have fallen out of sync.&lt;/p&gt;
&lt;p&gt;Whenever you see errors like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RNGestureHandlerModule could not be found&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Native module cannot be null&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TurboModuleRegistry.getEnforcing(...) failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Module has not been registered&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run through this checklist:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Rebuild the app binary.&lt;/li&gt;
&lt;li&gt;Run a clean prebuild if you're using native directories.&lt;/li&gt;
&lt;li&gt;Clear the Metro cache.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most of the time, one of those three steps will get you back up and running.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-quick-sidebar-for-tired-developers&quot;&gt;A Quick Sidebar for Tired Developers&lt;/h2&gt;
&lt;p&gt;If you're reading this at 2:00 AM while violently copy-pasting cryptic mobile stack traces into search engines, I feel your pain deeply.&lt;/p&gt;
&lt;p&gt;I got so tired of hunting down hidden Gradle errors and obscure CocoaPods issues that I decided to build a tool to automate the headache.&lt;/p&gt;
&lt;p&gt;It's called &lt;a rel=&quot;nofollow&quot; href=&quot;https://fixmyerrorapp.com&quot;&gt;Fix My Error&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I set up a completely registration-free sandbox right on the homepage using this exact native linkage error so you can see how it works in real time without handing over your data.&lt;/p&gt;
&lt;p&gt;If you like how it parses the logs, you can drop your own daily errors into the core engine with a free account (which gives you 5 free fixes per day), or check out the Pro tier if you're working in a heavy production environment.&lt;/p&gt;
&lt;p&gt;Give the sandbox a spin next time Expo decides to ruin your afternoon, and let me know if it helps your workflow.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;How do you usually handle these types of native linking bugs?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Drop a comment if this checklist worked for you, or let me know if there's an obscure Expo error that's currently driving you crazy.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20703/how-to-fix-module-could-not-be-found-in-react-native-expo</guid>
<pubDate>Tue, 16 Jun 2026 11:45:20 +0000</pubDate>
</item>
<item>
<title>The React Native Native-Dependency Trap: How to Fix Demanding Build Failures Without Nuking node_mod</title>
<link>https://coderlegion.com/20039/the-react-native-native-dependency-trap-demanding-build-failures-without-nuking-nodemod</link>
<description>&lt;p&gt;It’s the same story every time. You run a simple package upgrade, or you decide it's time to bump your Expo SDK version. Locally, JavaScript compiled perfectly. But the second you run a native build, your terminal explodes with hundreds of lines of red text.&lt;/p&gt;
&lt;p&gt;On iOS, it’s a cryptic &lt;code&gt;CocoaPods could not find compatible versions for pod&lt;/code&gt; or a sudden compilation failure in &lt;code&gt;AppDelegate.mm&lt;/code&gt;. On Android, it’s a fatal Gradle lifecycle error or a missing namespace exception.&lt;/p&gt;
&lt;p&gt;When native dependencies break, most developers fall back on the classic loop: delete &lt;code&gt;node_modules&lt;/code&gt;, delete &lt;code&gt;package-lock.json&lt;/code&gt;, clear cache, reinstall, and pray.&lt;/p&gt;
&lt;p&gt;But blind-nuking your files rarely fixes the underlying architectural conflict. Here is how to actually diagnose and surgically resolve native dependency hell in modern React Native and Expo apps.&lt;/p&gt;
&lt;h2 id=&quot;1-the-root-cause-transitive-dependency-syncing&quot;&gt;1. The Root Cause: Transitive Dependency Syncing&lt;/h2&gt;
&lt;p&gt;When you install a library like &lt;code&gt;react-native-reanimated&lt;/code&gt; or a native camera module, that library relies on specific versions of underlying native libraries (Pods or Android libraries).&lt;/p&gt;
&lt;p&gt;If two different third-party packages require the same native dependency but expect completely different versions, your package manager forces a compromise in JavaScript. But when the native build tool (Xcode or Gradle) steps in, it sees two conflicting native frameworks trying to occupy the same space.&lt;/p&gt;
&lt;h4 id=&quot;for-expo-users-always-prioritize-the-pinned-versions&quot;&gt;For Expo Users: Always Prioritize the Pinned Versions&lt;/h4&gt;
&lt;p&gt;Running &lt;code&gt;npm install&lt;/code&gt; can bypass Expo's guardrails. Always use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install --fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces Expo to look at your current SDK version and automatically downgrade or upgrade conflicting community packages to their exact validated native counterparts.&lt;/p&gt;
&lt;h2 id=&quot;2-the-ios-podfile-lock-paradox&quot;&gt;2. The iOS Podfile.lock Paradox&lt;/h2&gt;
&lt;p&gt;If your team introduces a package or you pull down &lt;code&gt;main&lt;/code&gt; and suddenly iOS won't build, the culprit is usually an out-of-sync &lt;code&gt;Podfile.lock&lt;/code&gt;. Running &lt;code&gt;pod install&lt;/code&gt; blindly sometimes isn't enough if cached pods are conflicting.&lt;/p&gt;
&lt;h4 id=&quot;the-surgical-fix&quot;&gt;The Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of deleting your whole project configuration, clear the native iOS build cache specifically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd ios &amp;amp;&amp;amp; pod cache clean --all
rm -rf Pods Podfile.lock
pod install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces CocoaPods to re-evaluate the dependency tree from absolute scratch based on your current &lt;code&gt;package.json&lt;/code&gt;, without losing your local JS settings.&lt;/p&gt;
&lt;h2 id=&quot;3-the-android-gradle-namespace-meltdown&quot;&gt;3. The Android Gradle Namespace Meltdown&lt;/h2&gt;
&lt;p&gt;With newer versions of Gradle and React Native, the way native Android modules declare their packages has changed (moving entirely to &lt;code&gt;namespace&lt;/code&gt; inside &lt;code&gt;build.gradle&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;If you are using an older, unmaintained community package, Gradle will completely fail to compile the app on launch.&lt;/p&gt;
&lt;h4 id=&quot;the-surgical-fix-1&quot;&gt;The Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of waiting for an open-source maintainer to update a dead repository, you can use &lt;code&gt;patch-package&lt;/code&gt; or Expo Config Plugins to alter the third-party library’s &lt;code&gt;build.gradle&lt;/code&gt; file locally.&lt;/p&gt;
&lt;p&gt;Alternatively, ensure your &lt;code&gt;android/gradle.properties&lt;/code&gt; has the proper architecture properties enabled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;android.useAndroidX=true
android.enableJetifier=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;triage-your-terminal-output&quot;&gt;Triage Your Terminal Output&lt;/h2&gt;
&lt;p&gt;The biggest mistake developers make is trying to read the very bottom of a failed build log.&lt;/p&gt;
&lt;p&gt;Xcode and Gradle put the actual error at the beginning of the failure block, while the bottom lines are just the generic system telling you the process exited with code &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Always scroll back up to locate the first root error flag.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I'm currently tracking down these weird native compilation bugs and building a database of exact solutions for them. If you are currently fighting a messy React Native or Expo native stack trace that makes absolutely no sense, I built a live beta engine to parse them and spit out precise resolutions over at &lt;a rel=&quot;nofollow&quot; href=&quot;https://fixmyerrorapp.com&quot;&gt;fix-my-error-app.com&lt;/a&gt;. Drop your errors in there if you're stuck, and let me know in the comments what your most hated native build error is!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Drop your errors in there if you're stuck, and let me know in the comments what your most hated native build error is!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20039/the-react-native-native-dependency-trap-demanding-build-failures-without-nuking-nodemod</guid>
<pubDate>Tue, 09 Jun 2026 09:26:33 +0000</pubDate>
</item>
<item>
<title>Lightbuild - a new declarative build experience for Android</title>
<link>https://coderlegion.com/19586/lightbuild-a-new-declarative-build-experience-for-android</link>
<description>&lt;p&gt;Google has quietly published documentation for Lightbuild, a new build configuration experience for Android projects. Lightbuild is designed as a declarative, YAML-based build configuration layer for Android.&lt;/p&gt;
&lt;p&gt;Today, Android projects usually use either Kotlin DSL or Groovy DSL for Gradle configuration. Google says Lightbuild will become a third option once it reaches general availability.&lt;/p&gt;
&lt;p&gt;Instead of writing build logic directly, developers describe how the project should be built. Lightbuild then converts that declarative configuration into another build system, such as Gradle, which still performs the actual build behind the scenes.&lt;/p&gt;
&lt;p&gt;Google describes Lightbuild as a build experience aimed at streamlining tasks in Android Studio and supporting agentic developer workflows through Android CLI.&lt;br&gt;
The YAML format is a big part of that. Google says Lightbuild’s build files are designed to help AI agents parse build configuration, update dependencies, and edit modules more efficiently, while keeping those changes under developer control.&lt;/p&gt;
&lt;p&gt;Google lists three main benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Agent-friendly configuration - structured YAML should be easier for coding agents to read and safely edit.&lt;/li&gt;
&lt;li&gt;Android CLI integration - Lightbuild is intended to work with the same Android CLI build and test features used by developers and agents.&lt;/li&gt;
&lt;li&gt;Android Studio support - Lightbuild-based projects are designed to be fully supported inside Android Studio, including project templates and AI-created projects for users who have access.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For more info, check out the official post:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.android.com/tools/agents/lightbuild&quot;&gt;https://developer.android.com/tools/agents/lightbuild&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19586/lightbuild-a-new-declarative-build-experience-for-android</guid>
<pubDate>Wed, 03 Jun 2026 13:38:12 +0000</pubDate>
</item>
<item>
<title>How to Fix Gradle Error: Unsupported class file major version (React Native &amp; Expo)</title>
<link>https://coderlegion.com/19548/how-to-fix-gradle-error-unsupported-class-file-major-version-react-native-expo</link>
<description>&lt;p&gt;If your React Native or Expo Android compilation suddenly crashes with a massive terminal stack trace pointing to an &quot;unsupported class file major version&quot; or claims a class file has the wrong version (e.g., &lt;code&gt;wrong version 65.0, should be 61.0&lt;/code&gt;), your environment is caught in a silent version mismatch.&lt;/p&gt;
&lt;p&gt;This error almost always triggers right after you upgrade your Expo SDK, bump your React Native core version, or update Android Studio. &lt;/p&gt;
&lt;p&gt;Here is exactly why this happens, how to decode the cryptic internal version numbers, and how to get your build compiling cleanly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-did-this-happen-suddenly-&quot;&gt;Why Did This Happen Suddenly?&lt;/h3&gt;
&lt;p&gt;When React Native or Expo upgrades their framework architecture, they also upgrade the required version of the &lt;strong&gt;Android Gradle Plugin (AGP)&lt;/strong&gt; inside your project. &lt;/p&gt;
&lt;p&gt;Each version of AGP requires a strict minimum version of the Java Development Kit (JDK) to run the compilation pipeline. If your system's global terminal runtime environment or your IDE is still pointing to an older Java runtime, the build engine throws an immediate exception during the semantic analysis phase.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-version-decoder-cheat-sheet&quot;&gt;The Version Decoder Cheat Sheet&lt;/h3&gt;
&lt;p&gt;Java’s compiler doesn't map version numbers logically to their public marketing names. When the Gradle stack trace throws a decimal number at you, use this index to figure out what version your code is demanding:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 66.0&lt;/strong&gt; = Requires Java 22&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 65.0&lt;/strong&gt; = Requires Java 21 (Common in newer 2025/2026 native modules)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 61.0&lt;/strong&gt; = Requires Java 17 (The baseline standard for modern React Native)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Class file major version 55.0&lt;/strong&gt; = Requires Java 11 (Legacy)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your terminal says &lt;code&gt;wrong version 65.0, should be 61.0&lt;/code&gt;, your build script encountered a dependency compiled for JDK 21, but your system is executing the build toolchain using JDK 17.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-find-the-exact-path-to-your-target-jdk&quot;&gt;Step 1: Find the Exact Path to Your Target JDK&lt;/h3&gt;
&lt;p&gt;Before making adjustments, you need to locate where the correct JDK version is actually installed on your machine. &lt;/p&gt;
&lt;h4 id=&quot;on-macos&quot;&gt;On macOS&lt;/h4&gt;
&lt;p&gt;If you use Homebrew or standard installers, your JDK runtimes are stored under the virtual machine path. You can list all installed environments by running:&lt;br&gt;
&lt;code&gt;/usr/libexec/java_home -V&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Copy the path to the required version. It will look similar to this:&lt;br&gt;
&lt;code&gt;/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home&lt;/code&gt;&lt;/p&gt;
&lt;h4 id=&quot;on-windows&quot;&gt;On Windows&lt;/h4&gt;
&lt;p&gt;By default, standard Java installers or Android Studio bundle their JDK environments inside program directories. Check these common locations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;C:\Program Files\Java\jdk-17\&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;C:\Program Files\Android\Android Studio\jbr\&lt;/code&gt; (Android Studio's embedded Java runtime)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-apply-the-explicit-project-override-recommended-&quot;&gt;Step 2: Apply the Explicit Project Override (Recommended)&lt;/h3&gt;
&lt;p&gt;Instead of fighting global system environment variables that might break older projects on your machine, you can force this specific React Native project to use the correct runtime directory.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to your project's native android folder: &lt;code&gt;cd android&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Open the &lt;code&gt;gradle.properties&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Add the following property at the bottom, supplying the absolute path you copied in Step 1:&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;org.gradle.java.home=/Insert/Your/Actual/Target/JDK/Path/Here&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Note: Windows users must escape backslashes in this file, format it like: &lt;code&gt;C:\\Program Files\\Java\\jdk-17&lt;/code&gt;)&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-align-android-studio-with-your-terminal&quot;&gt;Step 3: Align Android Studio with Your Terminal&lt;/h3&gt;
&lt;p&gt;A massive source of frustration is when the project builds fine in the terminal via CLI, but crashes the second you open it in Android Studio (or vice versa). You must align the IDE's build path:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your project folder inside &lt;strong&gt;Android Studio&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Settings&lt;/strong&gt; (or &lt;strong&gt;Preferences&lt;/strong&gt; on macOS) &amp;gt; &lt;strong&gt;Build, Execution, Deployment&lt;/strong&gt; &amp;gt; &lt;strong&gt;Build Tools&lt;/strong&gt; &amp;gt; &lt;strong&gt;Gradle&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Locate the &lt;strong&gt;Gradle JDK&lt;/strong&gt; dropdown menu.&lt;/li&gt;
&lt;li&gt;Change it from the default system runtime to match the exact version your project requires (e.g., JDK 17 or JDK 21).&lt;/li&gt;
&lt;li&gt;Click Apply and hit &lt;strong&gt;Sync Project with Gradle Files&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-clear-the-aggressive-build-cache&quot;&gt;Step 4: Clear the Aggressive Build Cache&lt;/h3&gt;
&lt;p&gt;Gradle caches compilation footprints deeply. If you change your Java version without clearing the cache, Gradle will try to read the old, mismatched class files and throw the same error again. &lt;/p&gt;
&lt;p&gt;Wipe the execution deck completely by running these commands in your root directory:&lt;/p&gt;
&lt;h2 id=&quot;clean-the-native-android-directories&quot;&gt;Clean the native android directories&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;return-to-root-and-clear-bundler-caches&quot;&gt;Return to root and clear bundler caches&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd ..
npx react-native start --clear
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the development server reboots and paths match up seamlessly across your terminal, properties file, and IDE, run your run command (npx react-native run-android or npx expo run:android) and the version mismatch error will be gone.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/19548/how-to-fix-gradle-error-unsupported-class-file-major-version-react-native-expo</guid>
<pubDate>Wed, 03 Jun 2026 08:49:58 +0000</pubDate>
</item>
<item>
<title>Fixing Gradle TLS handshake_failure on Maven Central (Android Builds)</title>
<link>https://coderlegion.com/19512/fixing-gradle-tls-handshakefailure-on-maven-central-android-builds</link>
<description>&lt;p&gt;If your Android native build is constantly failing with a TLS handshake error when trying to download dependencies from Maven Central, the root cause might be an invisible environment variable polluting your Java settings. &lt;/p&gt;
&lt;p&gt;The most frustrating part of this error is that your local internet connection is usually completely fine. &lt;/p&gt;
&lt;h3 id=&quot;the-quick-diagnosis&quot;&gt;The Quick Diagnosis&lt;/h3&gt;
&lt;p&gt;Open your terminal and test the connection directly to the Maven repository using curl:&lt;/p&gt;
&lt;p&gt;curl -I &lt;a rel=&quot;nofollow&quot; href=&quot;https://repo1.maven.org/maven2/&quot;&gt;https://repo1.maven.org/maven2/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If curl connects successfully and returns an HTTP 200, but your Gradle build still throws a &lt;code&gt;Received fatal alert: handshake_failure&lt;/code&gt;, your network is not the problem. Gradle itself is failing to establish the secure line.&lt;/p&gt;
&lt;h3 id=&quot;the-root-cause-1&quot;&gt;The Root Cause&lt;/h3&gt;
&lt;p&gt;An environment variable called &lt;code&gt;GRADLE_OPTS&lt;/code&gt; is likely injecting broken SSL or JSSE flags into your build process. Specifically, flags like &lt;code&gt;-Djavax.net.ssl.trustStoreType=WINDOWS-ROOT&lt;/code&gt; force Gradle to look at system-specific certificate stores that conflict with standard Java behavior, breaking the handshake completely.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The Fix&lt;/h3&gt;
&lt;p&gt;To resolve this permanently, you need to wipe out the polluted configuration so Gradle can fall back to its clean JDK defaults.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open your system &lt;strong&gt;Environment Variables&lt;/strong&gt; settings.&lt;/li&gt;
&lt;li&gt;Check both the &lt;strong&gt;User variables&lt;/strong&gt; and the &lt;strong&gt;System/Machine variables&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look for &lt;code&gt;GRADLE_OPTS&lt;/code&gt;. &lt;/li&gt;
&lt;li&gt;Delete the variable entirely from both locations.&lt;/li&gt;
&lt;li&gt;Close your IDE, restart your terminal, and run your build again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once those injected flags are gone, Gradle will use standard JDK defaults, and your dependency downloads will start succeeding immediately. &lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I've been spending a lot of my spare time building out a diagnostic tool over at &lt;a rel=&quot;nofollow&quot; href=&quot;https://fix-my-error.vercel.app&quot;&gt;FixMyError&lt;/a&gt; to map out environment bugs like this automatically. I just put a live, free sandbox preview on the homepage if you want to see how the engine breaks down terminal stack traces before you ever make an account.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/19512/fixing-gradle-tls-handshakefailure-on-maven-central-android-builds</guid>
<pubDate>Tue, 02 Jun 2026 15:56:09 +0000</pubDate>
</item>
<item>
<title>How to fix &quot;Gradle build daemon disappeared unexpectedly&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/18873/how-to-fix-gradle-build-daemon-disappeared-unexpectedly-in-react-native-expo</link>
<description>&lt;p&gt;If you’ve been building mobile apps with React Native or Expo long enough, you’ve definitely hit that brick wall where a standard debug build works perfectly, but the moment you run &lt;code&gt;eas build&lt;/code&gt; or try to compile a production AAB/APK for the Play Store, the terminal throws a generic, frustrating crash:&lt;/p&gt;
&lt;p&gt;&quot;Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)&quot;&lt;/p&gt;
&lt;p&gt;What makes this error so frustrating is that the terminal output rarely tells you &lt;em&gt;why&lt;/em&gt; the daemon vanished. &lt;/p&gt;
&lt;h4 id=&quot;the-root-cause-out-of-memory-oom-&quot;&gt;The Root Cause: Out-of-Memory (OOM)&lt;/h4&gt;
&lt;p&gt;When you trigger a release build, the Node process responsible for bundling your JavaScript files, assets, and third-party native modules spikes heavily in memory. By default, Android's JVM (Java Virtual Machine) doesn't allocate enough heap space to handle these massive compilation steps for modern, dependency-heavy apps. &lt;/p&gt;
&lt;p&gt;When the local or CI machine hits its limit, it forcefully kills the Gradle daemon to protect system stability. &lt;/p&gt;
&lt;h4 id=&quot;the-3-step-surgical-fix&quot;&gt;The 3-Step Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of blindly deleting your &lt;code&gt;.gradle&lt;/code&gt; cache or reinstalling Android Studio, you can resolve this permanently by updating your configuration parameters.&lt;/p&gt;
&lt;h5 id=&quot;1-allocate-more-heap-space&quot;&gt;1. Allocate More Heap Space&lt;/h5&gt;
&lt;p&gt;Navigate to your project's &lt;code&gt;android/gradle.properties&lt;/code&gt; file and look for or append the &lt;code&gt;org.gradle.jvmargs&lt;/code&gt; line. Increase the maximum heap size (&lt;code&gt;-Xmx&lt;/code&gt;) to at least 4GB or 8GB depending on your app size:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;org.gradle.daemon=true
org.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;2-configure-eas-build-if-using-expo-&quot;&gt;2. Configure EAS Build (If using Expo)&lt;/h5&gt;
&lt;p&gt;If you are running your builds via Expo Application Services (EAS), you need to tell the remote builders to utilize a larger resource class. Open your &lt;code&gt;eas.json&lt;/code&gt; file and specify a larger builder instance under your production profile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;{
  &quot;build&quot;: {
    &quot;production&quot;: {
      &quot;android&quot;: {
        &quot;resourceClass&quot;: &quot;large&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;3-clear-and-reset&quot;&gt;3. Clear and Reset&lt;/h5&gt;
&lt;p&gt;Once the files are saved, kill any lingering zombie daemons and clear the old build artifacts so Gradle can start fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
./gradlew cleanBuildCache
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;taming-the-rest-of-your-terminal-logs&quot;&gt;Taming the rest of your terminal logs&lt;/h4&gt;
&lt;p&gt;Native Android and iOS build logs can easily stretch to hundreds of lines of total noise, hiding the true dependency mismatch. &lt;/p&gt;
&lt;p&gt;If you are currently fighting a different native module error or a breaking CocoaPods mismatch, I built a free developer utility called FixMyError that parses raw terminal dumps instantly. It strips out the conversational AI fluff and gives you the exact surgical terminal command or config change needed to unblock your build layout in 3 seconds.&lt;/p&gt;
&lt;p&gt;Give it a spin if you are currently stuck: &lt;a rel=&quot;nofollow&quot; href=&quot;https://fix-my-error.vercel.app&quot;&gt;Try FixMyError Free&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Let me know in the comments if increasing the JVM heap size successfully unblocked your production build!&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/18873/how-to-fix-gradle-build-daemon-disappeared-unexpectedly-in-react-native-expo</guid>
<pubDate>Mon, 25 May 2026 10:18:22 +0000</pubDate>
</item>
<item>
<title>Eunify: Bridging the Continuity Gap Between Mac and Android</title>
<link>https://coderlegion.com/18702/eunify-bridging-the-continuity-gap-between-mac-and-android</link>
<description>&lt;p&gt;If you’ve ever lived deep within the Apple ecosystem, you know the magic of Continuity. The ability to copy a link on your iPhone and instantly paste it on your Mac, or use your phone’s camera as a wireless webcam, feels like living in the future. Apple built these features seamlessly into their OS, giving their users a cohesive multi-device experience.&lt;/p&gt;
&lt;p&gt;But what if you love your Mac, but prefer an Android phone? Out of the box, you get nothing. The wall around Apple's garden is tall, and cross-platform continuity has historically been clunky, relying on a patchwork of third-party apps that drain battery and feel decidedly un-magical.&lt;/p&gt;
&lt;p&gt;Eunify (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/FredrickOdondi/eunify&quot;&gt;https://github.com/FredrickOdondi/eunify&lt;/a&gt;) changes that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What is Eunify?&lt;/strong&gt;&lt;br&gt;
Eunify is an open-source &quot;Continuity Bridge&quot; designed specifically for users who split their digital lives between macOS and Android. It consists of a native macOS host app (built with Swift and SwiftUI) and an Android client (built with Flutter), tied together by a clever cloud relay system.&lt;/p&gt;
&lt;p&gt;The goal? To give Android users the exact same seamless, real-time continuity features that were previously exclusive to the Apple ecosystem.&lt;/p&gt;
&lt;p&gt;Bringing Apple Magic to Android&lt;br&gt;
Eunify doesn't just mimic one or two features; it aims to replicate the entire Continuity suite:&lt;/p&gt;
&lt;p&gt;Universal Clipboard: Copy text or a block of code on your Mac, and it instantly lands in your Android clipboard.&lt;br&gt;
Tab &amp;amp; URL Push: Drag a Chrome or Safari tab onto the Eunify Mac app, and it opens instantly on your phone. You can also send links in reverse, from your Android back to your Mac browser.&lt;br&gt;
Notification Mirroring: See your Android push notifications appear on your Mac in real-time, complete with actionable quick replies.&lt;br&gt;
File Transfer: Drag any file onto the Mac app to send it directly to your Android device via high-speed P2P WebRTC.&lt;br&gt;
Continuity Camera: Stream your Android phone's camera feed directly to your Mac to use as a wireless webcam.&lt;br&gt;
Biometric Mac Unlock: Tap your fingerprint on your Android phone to unlock your Mac remotely.&lt;br&gt;
Media Sync: See what's playing on your Android and control it right from your Mac desktop.&lt;br&gt;
How It Works Under the Hood&lt;br&gt;
Achieving this level of synergy across fundamentally different operating systems requires a robust technical foundation.&lt;/p&gt;
&lt;p&gt;The Handshake: It starts with a simple QR code. The macOS app generates a QR code encoding a unique room ID. Scanning this with the Android app connects both devices to the same Supabase Realtime (WebSocket) channel.&lt;br&gt;
Signaling: Standard events—like URL pushes, clipboard syncs, and notification mirroring—are securely transmitted as structured JSON payloads over the Supabase cloud relay.&lt;br&gt;
Peer-to-Peer Streaming: For heavy lifting (binary data like files and live camera frames), the WebSocket channel is used to negotiate a direct WebRTC DataChannel. This ensures fast, secure, peer-to-peer data streaming without routing massive payloads through a cloud server.&lt;br&gt;
Conquering Android Background Restrictions&lt;br&gt;
One of the biggest hurdles for an app like this is keeping the connection alive while the mobile app is in the background. Modern Android is notoriously aggressive about killing background processes to save battery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eunify solves this with a two-layer approach:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A Foreground Service keeps the Supabase WebSocket connection alive with a WakeLock.&lt;br&gt;
A separate Background Isolate written in Dart re-initializes the connection independently, handling message delivery even if the main UI is completely killed by the OS.&lt;br&gt;
(It even employs clever workarounds, like using FlutterForegroundTask to briefly trampoline the app to the foreground on Android 10+ just to bypass background clipboard-writing restrictions.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get Started&lt;/strong&gt;&lt;br&gt;
If you're tired of emailing yourself links and want to bring your Android device into your Mac's ecosystem, Eunify is open source and ready to try.&lt;/p&gt;
&lt;p&gt;Check out the code, read the full protocol design, and download the latest releases directly from the GitHub repository:&lt;/p&gt;
&lt;p&gt;Explore Eunify on GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/FredrickOdondi/eunify&quot;&gt;https://github.com/FredrickOdondi/eunify&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apkpure.com/eunify/com.eunify.client&quot;&gt;https://apkpure.com/eunify/com.eunify.client&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built with Swift, SwiftUI, Flutter, Dart, and Supabase.&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/18702/eunify-bridging-the-continuity-gap-between-mac-and-android</guid>
<pubDate>Fri, 22 May 2026 17:24:01 +0000</pubDate>
</item>
<item>
<title>Eunify: Cross-Platform Continuity System for Android and MacOS</title>
<link>https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</link>
<description>&lt;blockquote&gt;&lt;p&gt;I Was Just Solving a Problem that I Face as an Android Phone User&lt;br&gt;
Working With MacOS. I Am Sure There's Someone Else Out There With&lt;br&gt;
Similar Challenges&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I developed Eunify, a continuity platform engineered to create a seamless, native-feeling ecosystem between macOS and Android. By bridging these two distinct operating systems, Eunify eliminates workflow friction and ensures uninterrupted cross-device synchronization for productivity and communication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Core Features&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Universal Clipboard: Instantly synchronize your clipboard across operating systems. Text or media copied on your macOS machine is immediately available to paste on your Android device.&lt;/p&gt;
&lt;p&gt;Fluid Browser Handoff: Maintain uninterrupted browsing sessions across devices. You can drag and drop an active tab into Eunify on macOS to instantly launch it on your Android device, or push a tab from Android to pop up seamlessly on your Mac.&lt;/p&gt;
&lt;p&gt;Bidirectional File Transfer: Securely and rapidly send files directly back and forth between your macOS and Android devices without relying on external cloud services.&lt;/p&gt;
&lt;p&gt;Biometric Authentication Integration: Enhance your security workflow by leveraging your Android device's native biometric sensors to unlock your macOS laptop.&lt;/p&gt;
&lt;p&gt;Video Call Continuity: Effortlessly hand off active video calls from your macOS machine to your Android device, allowing you to transition from your desk to mobile without dropping the connection.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6697081181013665602&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7464064703061912304&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5071500663947463985&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7189078171734645109&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7666383216313576231&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11077430319582350407&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12703508908033091367&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=783774772349424263&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5679720936373908046&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</guid>
<pubDate>Sun, 17 May 2026 22:12:01 +0000</pubDate>
</item>
<item>
<title>5 React Native Errors That Cost Me Hours (And How I Finally Fixed Them)</title>
<link>https://coderlegion.com/16986/5-react-native-errors-that-cost-me-hours-and-how-i-finally-fixed-them</link>
<description>&lt;h2 id=&quot;5-react-native-errors-that-cost-me-hours-and-how-i-finally-fixed-them-&quot;&gt;5 React Native Errors That Cost Me Hours (And How I Finally Fixed Them)&lt;/h2&gt;
&lt;p&gt;When I started building my React Native app, I thought the hard part would be designing the app itself.&lt;/p&gt;
&lt;p&gt;Turns out, debugging the environment was the real boss fight.&lt;/p&gt;
&lt;p&gt;I spent hours — sometimes entire days — fighting build failures, version mismatches, Gradle issues, and errors that made absolutely no sense at first glance. Some of them looked impossible to solve unless you were already an Android engineer.&lt;/p&gt;
&lt;p&gt;The worst part is that many tutorials online either skip these issues entirely or give solutions that don’t actually work.&lt;/p&gt;
&lt;p&gt;So I decided to write the post I wish I had when I started.&lt;/p&gt;
&lt;p&gt;These are 5 real React Native errors I personally ran into while building my app, and exactly how I fixed them.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1-gradle-tls-handshake-failure&quot;&gt;1. Gradle TLS Handshake Failure&lt;/h2&gt;
&lt;h3 id=&quot;the-error&quot;&gt;The Error&lt;/h3&gt;
&lt;p&gt;This one nearly made me quit for the day.&lt;/p&gt;
&lt;p&gt;Every time I tried building the Android app, Gradle failed while downloading dependencies from Maven Central with SSL/TLS handshake errors.&lt;/p&gt;
&lt;p&gt;The logs looked something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or sometimes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Could not GET 'https://repo.maven.apache.org/...'
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-it-happened&quot;&gt;Why It Happened&lt;/h3&gt;
&lt;p&gt;At first I thought:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Maven Central was down&lt;/li&gt;
&lt;li&gt;my internet was unstable&lt;/li&gt;
&lt;li&gt;Java was broken&lt;/li&gt;
&lt;li&gt;Gradle was corrupted&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of those were the real issue.&lt;/p&gt;
&lt;p&gt;The actual problem was an environment variable called &lt;code&gt;GRADLE_OPTS&lt;/code&gt; that was injecting custom SSL settings into Gradle.&lt;/p&gt;
&lt;p&gt;It included flags like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;-Djavax.net.ssl.trustStoreType=WINDOWS-ROOT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Curl requests worked perfectly, but Gradle kept failing because its SSL configuration was being overridden.&lt;/p&gt;
&lt;h3 id=&quot;how-i-fixed-it&quot;&gt;How I Fixed It&lt;/h3&gt;
&lt;p&gt;I removed the &lt;code&gt;GRADLE_OPTS&lt;/code&gt; environment variable completely from both:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User Environment Variables&lt;/li&gt;
&lt;li&gt;System Environment Variables&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then I restarted the terminal and rebuilt the project.&lt;/p&gt;
&lt;p&gt;Immediately after that, Gradle downloads started working again.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;Sometimes the problem is not React Native itself.&lt;/p&gt;
&lt;p&gt;Sometimes the environment around it is sabotaging the build process.&lt;/p&gt;
&lt;p&gt;And when debugging network-related Gradle errors, always compare:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;browser behavior&lt;/li&gt;
&lt;li&gt;curl behavior&lt;/li&gt;
&lt;li&gt;Gradle behavior&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If only Gradle fails, something in the Java/Gradle SSL configuration is probably interfering.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2-expo-and-react-native-version-mismatch&quot;&gt;2. Expo and React Native Version Mismatch&lt;/h2&gt;
&lt;h3 id=&quot;the-error-1&quot;&gt;The Error&lt;/h3&gt;
&lt;p&gt;This one caused random crashes, dependency warnings, and builds failing for reasons that seemed unrelated.&lt;/p&gt;
&lt;p&gt;I had packages that technically installed correctly, but internally they were incompatible with my Expo SDK version.&lt;/p&gt;
&lt;h3 id=&quot;why-it-happened-1&quot;&gt;Why It Happened&lt;/h3&gt;
&lt;p&gt;React Native ecosystems are extremely version-sensitive.&lt;/p&gt;
&lt;p&gt;You cannot always:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;install the latest package&lt;/li&gt;
&lt;li&gt;upgrade random dependencies&lt;/li&gt;
&lt;li&gt;mix versions freely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Expo SDKs expect very specific versions of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native&lt;/li&gt;
&lt;li&gt;React&lt;/li&gt;
&lt;li&gt;Expo packages&lt;/li&gt;
&lt;li&gt;Gradle plugins&lt;/li&gt;
&lt;li&gt;Android tooling&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even a small mismatch can create chaos.&lt;/p&gt;
&lt;h3 id=&quot;how-i-fixed-it-1&quot;&gt;How I Fixed It&lt;/h3&gt;
&lt;p&gt;I stopped manually installing random versions and aligned everything with the Expo SDK requirements.&lt;/p&gt;
&lt;p&gt;I rebuilt the Android folder using the correct setup and standardized the project around:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native 0.80.2&lt;/li&gt;
&lt;li&gt;Expo SDK 53&lt;/li&gt;
&lt;li&gt;AGP 8.6.1&lt;/li&gt;
&lt;li&gt;Kotlin 2.0.21&lt;/li&gt;
&lt;li&gt;Gradle 8.13&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After everything matched properly, the project became dramatically more stable.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-1&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;In React Native development, version consistency matters more than having the newest package.&lt;/p&gt;
&lt;p&gt;A stable stack beats a cutting-edge stack every single time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3-android-build-files-breaking-everything&quot;&gt;3. Android Build Files Breaking Everything&lt;/h2&gt;
&lt;h3 id=&quot;the-error-2&quot;&gt;The Error&lt;/h3&gt;
&lt;p&gt;At one point, Android builds completely stopped working because of Gradle configuration problems.&lt;/p&gt;
&lt;p&gt;The project would either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;fail immediately&lt;/li&gt;
&lt;li&gt;fail during dependency resolution&lt;/li&gt;
&lt;li&gt;fail during configuration phase&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the logs were massive.&lt;/p&gt;
&lt;h3 id=&quot;why-it-happened-2&quot;&gt;Why It Happened&lt;/h3&gt;
&lt;p&gt;React Native Android builds rely on multiple interconnected files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;settings.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gradle.properties&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;app-level Gradle configs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One incorrect line can break the entire build system.&lt;/p&gt;
&lt;p&gt;I also learned that newer React Native versions have different setup requirements compared to older tutorials online.&lt;/p&gt;
&lt;p&gt;Following outdated tutorials made things worse.&lt;/p&gt;
&lt;h3 id=&quot;how-i-fixed-it-2&quot;&gt;How I Fixed It&lt;/h3&gt;
&lt;p&gt;I rebuilt the Android configuration using the proper modern structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;correct &lt;code&gt;includeBuild&lt;/code&gt; setup&lt;/li&gt;
&lt;li&gt;Expo autolinking configuration&lt;/li&gt;
&lt;li&gt;AndroidX enabled&lt;/li&gt;
&lt;li&gt;Hermes enabled&lt;/li&gt;
&lt;li&gt;New Architecture configuration aligned correctly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also stopped randomly changing Gradle files without understanding what they did.&lt;/p&gt;
&lt;p&gt;That alone saved me future debugging time.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-2&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;When debugging Android builds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;avoid panic-editing files&lt;/li&gt;
&lt;li&gt;change one thing at a time&lt;/li&gt;
&lt;li&gt;keep backups of working configs&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And most importantly:&lt;br&gt;
older React Native tutorials can become outdated very quickly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4-metro-bundler-cache-weirdness&quot;&gt;4. Metro Bundler Cache Weirdness&lt;/h2&gt;
&lt;h3 id=&quot;the-error-3&quot;&gt;The Error&lt;/h3&gt;
&lt;p&gt;Sometimes the app behaved as if my code changes didn’t exist.&lt;/p&gt;
&lt;p&gt;I would:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;modify files&lt;/li&gt;
&lt;li&gt;save changes&lt;/li&gt;
&lt;li&gt;rebuild the app&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;…and somehow the old behavior remained.&lt;/p&gt;
&lt;p&gt;At first I genuinely thought I was losing my mind.&lt;/p&gt;
&lt;h3 id=&quot;why-it-happened-3&quot;&gt;Why It Happened&lt;/h3&gt;
&lt;p&gt;Metro bundler caching can occasionally become inconsistent, especially after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;dependency changes&lt;/li&gt;
&lt;li&gt;package upgrades&lt;/li&gt;
&lt;li&gt;Android rebuilds&lt;/li&gt;
&lt;li&gt;native configuration updates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cache can preserve outdated state even after rebuilding.&lt;/p&gt;
&lt;h3 id=&quot;how-i-fixed-it-3&quot;&gt;How I Fixed It&lt;/h3&gt;
&lt;p&gt;I cleared everything:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Metro cache&lt;/li&gt;
&lt;li&gt;node_modules&lt;/li&gt;
&lt;li&gt;Gradle cache&lt;/li&gt;
&lt;li&gt;build folders&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Commands like these became lifesavers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start --clear
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And sometimes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After cleaning the environment properly, the app started reflecting changes correctly again.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-3&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;Never underestimate caching problems.&lt;/p&gt;
&lt;p&gt;If behavior makes absolutely no sense, clear the caches before wasting hours debugging imaginary problems.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5-the-works-on-one-device-but-crashes-on-another-problem&quot;&gt;5. The “Works on One Device but Crashes on Another” Problem&lt;/h2&gt;
&lt;h3 id=&quot;the-error-4&quot;&gt;The Error&lt;/h3&gt;
&lt;p&gt;This one was incredibly frustrating because the app seemed completely fine at first.&lt;/p&gt;
&lt;p&gt;It worked on one device.&lt;br&gt;
Then suddenly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;crashed on another device&lt;/li&gt;
&lt;li&gt;failed on Android builds&lt;/li&gt;
&lt;li&gt;behaved differently after rebuilding&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sometimes the exact same code would work one day and fail the next after changing dependencies.&lt;/p&gt;
&lt;h3 id=&quot;why-it-happened-4&quot;&gt;Why It Happened&lt;/h3&gt;
&lt;p&gt;The real issue was inconsistency between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Gradle versions&lt;/li&gt;
&lt;li&gt;Android Gradle Plugin versions&lt;/li&gt;
&lt;li&gt;Kotlin versions&lt;/li&gt;
&lt;li&gt;React Native requirements&lt;/li&gt;
&lt;li&gt;Expo SDK compatibility&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At one point, parts of the Android environment were aligned while others were outdated or incompatible.&lt;/p&gt;
&lt;p&gt;React Native Android builds are surprisingly sensitive to tooling mismatches.&lt;/p&gt;
&lt;p&gt;Even if the app compiles, hidden incompatibilities can still create unstable behavior.&lt;/p&gt;
&lt;h3 id=&quot;how-i-fixed-it-4&quot;&gt;How I Fixed It&lt;/h3&gt;
&lt;p&gt;I standardized the entire Android stack instead of fixing things one-by-one.&lt;/p&gt;
&lt;p&gt;I rebuilt the project around a stable configuration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;React Native 0.80.2&lt;/li&gt;
&lt;li&gt;Expo SDK 53&lt;/li&gt;
&lt;li&gt;AGP 8.6.1&lt;/li&gt;
&lt;li&gt;Kotlin 2.0.21&lt;/li&gt;
&lt;li&gt;Gradle 8.13&lt;/li&gt;
&lt;li&gt;compileSdk 35&lt;/li&gt;
&lt;li&gt;targetSdk 35&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also cleaned and regenerated parts of the Android setup to remove leftover configuration issues from older versions.&lt;/p&gt;
&lt;p&gt;Once everything matched properly, the builds became dramatically more reliable.&lt;/p&gt;
&lt;h3 id=&quot;what-i-learned-4&quot;&gt;What I Learned&lt;/h3&gt;
&lt;p&gt;A lot of React Native debugging problems are actually ecosystem consistency problems.&lt;/p&gt;
&lt;p&gt;You can spend hours trying to patch symptoms individually when the real fix is making the entire toolchain compatible from top to bottom.&lt;/p&gt;
&lt;p&gt;Now whenever I hit strange Android issues, the first thing I check is:&lt;br&gt;
“Are all my versions actually meant to work together?”&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts-6&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Before building this app, I underestimated how much software development is actually debugging.&lt;/p&gt;
&lt;p&gt;Not writing code.&lt;br&gt;
Not designing features.&lt;/p&gt;
&lt;p&gt;Debugging.&lt;/p&gt;
&lt;p&gt;But strangely enough, this process also made me improve faster than any tutorial ever could.&lt;/p&gt;
&lt;p&gt;Every painful issue forced me to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;understand the stack better&lt;/li&gt;
&lt;li&gt;read logs more carefully&lt;/li&gt;
&lt;li&gt;stay patient under frustration&lt;/li&gt;
&lt;li&gt;stop blindly copying fixes from random forums&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And honestly, solving these problems feels more rewarding than writing simple features.&lt;/p&gt;
&lt;p&gt;If you're currently fighting React Native errors that make no sense:&lt;br&gt;
you're definitely not alone.&lt;/p&gt;
&lt;p&gt;Sometimes one line of configuration can cost you six hours.&lt;/p&gt;
&lt;p&gt;And sometimes the fix is deleting a single environment variable.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/16986/5-react-native-errors-that-cost-me-hours-and-how-i-finally-fixed-them</guid>
<pubDate>Thu, 07 May 2026 19:35:56 +0000</pubDate>
</item>
<item>
<title>We Built a Free, Fully Private Remote Control App for Windows. No Cloud, No Ads, No Compromise.</title>
<link>https://coderlegion.com/15518/we-built-a-free-fully-private-remote-control-app-for-windows-no-cloud-no-ads-no-compromise</link>
<description>&lt;h3 id=&quot;most-remote-control-apps-make-the-same-trade-convenience-in-exchange-for-your-data-passing-through-someone-else-s-server-&quot;&gt;Most remote control apps make the same trade: convenience in exchange for your data passing through someone else's server.&lt;/h3&gt;
&lt;p&gt;We wanted to build something different. An app that gives you full control of your Windows computer from your Android device, without any of the mentioned privacy risks.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The result is RemotePC for Windows — a free, local network remote&lt;br&gt;
control app with no ads, no data collection, and no cloud dependency&lt;br&gt;
of any kind.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why Local Network?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Cloud-based remote tools are useful, but they come with inherent trade offs: your input, your files, and your media commands all travel through an external server before reaching your own machine.&lt;/p&gt;
&lt;p&gt;For a lot of use cases, especially at home or in a local office that round trip is completely unnecessary.&lt;/p&gt;
&lt;p&gt;With RemotePC, your Android device and your Windows PC talk directly to each other over your local network. Your data never leaves it. There's no account to create, no server to trust, and nothing phoning home in the background.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What It Can Do&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RemotePC covers the full range of remote control functionality you'd actually need day to day:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Trackpad &amp;amp; keyboard — Use your phone as a wireless trackpad and&lt;br&gt;
keyboard for your PC&lt;/li&gt;
&lt;li&gt;Media control — Play, pause, skip, and adjust volume without touching&lt;br&gt;
your machine&lt;/li&gt;
&lt;li&gt;Live statistics — Watch your computer's performance statistics. From&lt;br&gt;
RAM and CPU usage, to temperatures RemotePC has it all&lt;/li&gt;
&lt;li&gt;File transfer — Send files between your Android device and Windows PC&lt;br&gt;
over the local network&lt;/li&gt;
&lt;li&gt;And more — designed to handle the everyday remote control tasks most&lt;br&gt;
users actually reach for&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Completely Free. Actually Free.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;There are no ads. There is no premium tier. There is no freemium gate hiding the useful features. RemotePC is free in the straightforward sense of the word.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Privacy by Architecture&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RemotePC doesn't ask you to trust a privacy policy. The architecture itself is the guarantee — local network only means &lt;strong&gt;there's no external infrastructure that could collect, store, or transmit your data, because none exists.&lt;/strong&gt; What happens on your network stays on your network.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get It&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RemotePC for Windows is available on the Google Play Store and UptoDown for Android.&lt;/p&gt;
&lt;p&gt;Download it by visiting our web page &lt;a rel=&quot;nofollow&quot; href=&quot;https://remotepc.tech&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you're looking for a lightweight, private, and genuinely free way to control your Windows PC from your phone — give it a try.&lt;/p&gt;
&lt;p&gt;RemotePC by Breadworks.&lt;br&gt;
Free and private — forever.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15518/we-built-a-free-fully-private-remote-control-app-for-windows-no-cloud-no-ads-no-compromise</guid>
<pubDate>Wed, 22 Apr 2026 14:13:05 +0000</pubDate>
</item>
<item>
<title>Build Reliable Local Notifications in Flutter (Step-by-Step)</title>
<link>https://coderlegion.com/15350/build-reliable-local-notifications-in-flutter-step-by-step</link>
<description>&lt;p&gt;&lt;em&gt;Companion post to &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.mxomasuku.com/blog/how-i-built-the-long-game-notification-system-a-journey-into-notifications-and-behavioral-engineering&quot;&gt;How I Built the Long Game Notification System&lt;/a&gt;. This is the walkthrough. If you want the thinking behind the decisions, read that first. If you just want the recipe, you're in the right place.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-re-building&quot;&gt;What You're Building&lt;/h3&gt;
&lt;p&gt;A notification system where:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Notifications fire at the &lt;strong&gt;exact time&lt;/strong&gt; you set, every day&lt;/li&gt;
&lt;li&gt;They work when the phone is &lt;strong&gt;locked&lt;/strong&gt;, the app is &lt;strong&gt;killed&lt;/strong&gt;, or the device is in &lt;strong&gt;Doze mode&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Notification content is &lt;strong&gt;personalized&lt;/strong&gt; with fresh data on every app open&lt;/li&gt;
&lt;li&gt;The entire system runs &lt;strong&gt;locally&lt;/strong&gt; — no push server, no Cloud Functions, zero delivery cost&lt;/li&gt;
&lt;li&gt;Pomodoro-style one-shot alarms fire on time even when the app is suspended&lt;/li&gt;
&lt;li&gt;Notifications &lt;strong&gt;survive Samsung, Xiaomi, and Huawei battery killers&lt;/strong&gt; without FCM&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-you-re-not-using&quot;&gt;What You're NOT Using&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;workmanager&lt;/code&gt; — the OS throttles background tasks. Your 15-minute poll becomes hours.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Dart Timer.periodic&lt;/code&gt; for delivery — dies the moment the phone locks.&lt;/li&gt;
&lt;li&gt;Firebase Cloud Messaging — overkill for local, user-specific scheduling.&lt;/li&gt;
&lt;li&gt;Background Fetch (iOS) — Apple gives you 0–2 executions per day if you're lucky.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-dependencies&quot;&gt;Step 1: Dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# pubspec.yaml
dependencies:
  flutter_local_notifications: ^18.0.1
  timezone: ^0.10.0
  flutter_timezone: ^3.0.1
  permission_handler: ^11.3.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. Four packages. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The notification plugin handles scheduling. &lt;/li&gt;
&lt;li&gt;The timezone packages ensure your 8:00 AM means 8:00 AM in Johannesburg, not UTC. &lt;/li&gt;
&lt;li&gt;The permission handler lets you request notification access on Android 13+ &lt;strong&gt;and&lt;/strong&gt; battery optimization exemption on Samsung/OEM devices.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-android-permissions&quot;&gt;Step 2: Android Permissions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;!-- android/app/src/main/AndroidManifest.xml --&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.POST_NOTIFICATIONS&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.RECEIVE_BOOT_COMPLETED&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.SCHEDULE_EXACT_ALARM&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.USE_EXACT_ALARM&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&quot; /&amp;gt;
&amp;lt;uses-permission android:name=&quot;android.permission.WAKE_LOCK&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What each one does:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Permission &lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Required on Android 13+ to show any notification at all&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Alarms survive device reboots&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Fires at the exact second, even in Doze mode (API ≤ 33)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;USE_EXACT_ALARM&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Guaranteed exact alarms on API 33+ — no user prompt needed, unlike &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; on API 34+&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;REQUEST_IGNORE_BATTERY_OPTIMIZATIONS&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Lets you show the system &quot;Allow unrestricted battery?&quot; dialog — critical for Samsung, Xiaomi, Huawei&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;WAKE_LOCK&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Keeps the CPU alive long enough to process the alarm and fire the notification&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; On Android 14+ (API 34), &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; isn't granted by default. &lt;code&gt;USE_EXACT_ALARM&lt;/code&gt; is a stronger alternative that's always granted for apps that declare it — but it may trigger Google Play review. Having both ensures maximum compatibility. Your code still needs the inexact fallback we'll cover in Step 7.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-initialize-on-app-start&quot;&gt;Step 3: Initialize on App Start&lt;/h3&gt;
&lt;p&gt;Before you can schedule anything, you need to initialize the timezone database and the notification plugin. This runs once in &lt;code&gt;main()&lt;/code&gt;, before &lt;code&gt;runApp()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // ... your other init code (Firebase, etc.)

  await NotificationService.init();
  runApp(const MyApp());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// notification_service.dart
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest_all.dart' as tzdata;
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:permission_handler/permission_handler.dart';

class NotificationService {
  static final FlutterLocalNotificationsPlugin _plugin =
      FlutterLocalNotificationsPlugin();

  static Future&amp;lt;void&amp;gt; init() async {
    // 1. Initialize the timezone database and detect the device's zone.
    //    Without this, all scheduled times are wrong.
    tzdata.initializeTimeZones();
    try {
      final tzName = await FlutterTimezone.getLocalTimezone();
      tz.setLocalLocation(tz.getLocation(tzName));
    } catch (_) {
      // Fallback: stays UTC if detection fails
    }

    // 2. Initialize the plugin.
    //    We don't request permissions here — that happens later,
    //    at a moment that makes sense in your UX flow.
    const androidSettings = AndroidInitializationSettings(
      '@mipmap/ic_launcher',
    );
    const iosSettings = DarwinInitializationSettings(
      requestAlertPermission: false,
      requestBadgePermission: false,
      requestSoundPermission: false,
    );

    await _plugin.initialize(
      const InitializationSettings(
        android: androidSettings,
        iOS: iosSettings,
      ),
      onDidReceiveNotificationResponse: _onNotificationTap,
      onDidReceiveBackgroundNotificationResponse: _onBackgroundTap,
    );
  }

  // Handle taps when the app is alive
  static void _onNotificationTap(NotificationResponse response) {
    // Navigate to the relevant screen, stop a timer, etc.
  }

  // Handle taps when the app was killed — must be top-level or static
  @pragma('vm:entry-point')
  static void _onBackgroundTap(NotificationResponse response) {
    // App relaunches — handle in your normal init flow
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why timezone matters:&lt;/strong&gt; If you schedule for &quot;8:00 AM&quot; without initializing timezones, the plugin may interpret that as UTC. In Johannesburg (UTC+2), that's 10:00 AM. In New York (UTC-5), that's 3:00 AM. Always initialize before any &lt;code&gt;zonedSchedule&lt;/code&gt; call.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-schedule-a-daily-repeating-notification&quot;&gt;Step 4: Schedule a Daily Repeating Notification&lt;/h3&gt;
&lt;p&gt;This is the core of the entire system. Three lines do the heavy lifting.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleDailyNotification({
  required int id,
  required String title,
  required String body,
  required int hour,
  required int minute,
}) async {
  await _plugin.zonedSchedule(
    id,                                           // Fixed ID per notification type
    title,
    body,
    _nextInstanceOfTime(hour, minute),            // Next occurrence of this time
    const NotificationDetails(
      android: AndroidNotificationDetails(
        'your_channel_id',
        'Your Channel Name',
        channelDescription: 'What this channel is for',
        importance: Importance.high,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(),
    ),
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    matchDateTimeComponents: DateTimeComponents.time,    // ← THIS IS THE KEY
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The three things that make it work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;exactAllowWhileIdle&lt;/code&gt;&lt;/strong&gt; — fires even in Doze mode. The OS wakes up just enough to deliver your notification.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;matchDateTimeComponents: DateTimeComponents.time&lt;/code&gt;&lt;/strong&gt; — tells the OS: &quot;repeat this every day at this hour:minute.&quot; You schedule it once. It fires every day. No background task. No polling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;_nextInstanceOfTime&lt;/code&gt;&lt;/strong&gt; — computes the next future occurrence of the target time.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
  final now = tz.TZDateTime.now(tz.local);
  var scheduled = tz.TZDateTime(
    tz.local,
    now.year,
    now.month,
    now.day,
    hour,
    minute,
  );
  // zonedSchedule requires a future datetime
  if (scheduled.isBefore(now)) {
    scheduled = scheduled.add(const Duration(days: 1));
  }
  return scheduled;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this helper exists:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; requires the initial fire time to be in the future. If it's 9:00 AM and you schedule for 8:00 AM, the helper pushes it to 8:00 AM tomorrow. The &lt;code&gt;matchDateTimeComponents&lt;/code&gt; flag handles every day after that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-critical-the-cancel-trap&quot;&gt;Step 5: CRITICAL: The Cancel Trap&lt;/h3&gt;
&lt;p&gt;==(This bit is annoying if not taken care of)==&lt;/p&gt;
&lt;p&gt;This is the bug that will silently break your notifications and you won't notice for days. I shipped it. It cost me a full day of missed notifications before I caught it.&lt;/p&gt;
&lt;h4 id=&quot;the-broken-pattern&quot;&gt;The broken pattern&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;//  DO NOT DO THIS
static Future&amp;lt;void&amp;gt; reschedule() async {
  await _plugin.cancel(id);                     // Destroys the repeating alarm
  await _plugin.zonedSchedule(id, ...);         // Creates a new one
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It looks correct. It's not. Here's what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User opens the app at &lt;strong&gt;9:00 AM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cancel(1)&lt;/code&gt; — destroys the existing repeating 8:00 AM alarm&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_nextInstanceOfTime(8, 0)&lt;/code&gt; — 8:00 AM today already passed, returns &lt;strong&gt;tomorrow 8:00 AM&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;zonedSchedule(1, ...)&lt;/code&gt; — schedules a new alarm starting tomorrow&lt;/li&gt;
&lt;li&gt;Tomorrow, user opens the app at 9:00 AM again → &lt;strong&gt;same thing&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The notification is perpetually pushed to &quot;tomorrow&quot; and &lt;strong&gt;never fires&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-correct-pattern&quot;&gt;The correct pattern&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Only cancel when the user disables the notification
static Future&amp;lt;void&amp;gt; schedule(bool enabled, int hour, int minute) async {
  if (!enabled) {
    await _plugin.cancel(id);    // User turned it off — remove the alarm
    return;
  }
  // Calling zonedSchedule with the same ID REPLACES the existing alarm
  // without resetting the repeat cycle. No cancel needed.
  await _plugin.zonedSchedule(id, ...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; &lt;code&gt;zonedSchedule&lt;/code&gt; with the same ID overwrites the previous alarm — it updates the title, body, and schedule time without destroying the repeat. You only need &lt;code&gt;cancel&lt;/code&gt; when you want the notification to stop entirely.&lt;/p&gt;
&lt;p&gt;This means you can safely call &lt;code&gt;rescheduleAll()&lt;/code&gt; on every app open to refresh notification content (e.g., &quot;You have 3 projects today&quot; becomes &quot;You have 4 projects today&quot;) without breaking delivery.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-refresh-content-on-app-open&quot;&gt;Step 6: Refresh Content on App Open&lt;/h3&gt;
&lt;p&gt;Notification bodies are frozen at schedule time. If the user adds a project at 11 PM, you want tomorrow's morning notification to include it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// main.dart — after init, before runApp
if (FirebaseAuth.instance.currentUser != null) {
  NotificationService.rescheduleAllNotifications().catchError((_) {});
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;rescheduleAllNotifications()&lt;/code&gt; fetches fresh data and calls &lt;code&gt;zonedSchedule&lt;/code&gt; for each enabled notification, overwriting the stale body. No &lt;code&gt;cancel&lt;/code&gt; — just overwrite.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two things to guard against:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth check&lt;/strong&gt; — if your notification content depends on user-specific data (Firestore queries), and no user is signed in, the query will hang. This blocks &lt;code&gt;main()&lt;/code&gt; and your app never starts. Gate it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fire-and-forget&lt;/strong&gt; — use &lt;code&gt;.catchError((_) {})&lt;/code&gt;. If the reschedule fails (offline, Firestore timeout), the previously scheduled alarm still fires with yesterday's content. That's better than crashing on startup.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-7-handle-android-14-exact-alarm-restriction&quot;&gt;Step 7: Handle Android 14+ Exact Alarm Restriction&lt;/h3&gt;
&lt;p&gt;Android 14 changed the rules. &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; is no longer auto-granted. If your app calls &lt;code&gt;exactAllowWhileIdle&lt;/code&gt; without the permission, it throws.&lt;/p&gt;
&lt;p&gt;The graceful fallback:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleDailyNotification({
  required int id,
  required String title,
  required String body,
  required int hour,
  required int minute,
}) async {
  final scheduledTime = _nextInstanceOfTime(hour, minute);
  final details = /* your NotificationDetails */;

  try {
    // Try exact first — best experience
    await _plugin.zonedSchedule(
      id, title, body, scheduledTime, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,
    );
  } catch (e) {
    // Exact alarm not permitted — fall back to inexact
    // Delivery may drift by ~10 minutes, but the notification still fires
    await _plugin.zonedSchedule(
      id, title, body, scheduledTime, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      matchDateTimeComponents: DateTimeComponents.time,
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For most notifications, a 10-minute window is fine. &quot;Your day is 63% gone&quot; at 2:10 PM instead of 2:00 PM doesn't break the experience.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Apply this same pattern to one-shot alarms too.&lt;/strong&gt; Your pomodoro alarm scheduler should have the same try/catch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; schedulePhaseAlarm({
  required int durationMinutes,
  required NotificationDetails details,
}) async {
  final fireAt = tz.TZDateTime.now(tz.local)
      .add(Duration(minutes: durationMinutes));

  try {
    await _plugin.zonedSchedule(
      alarmId, title, message, fireAt, details,
      uiLocalNotificationDateInterpretation:
          UILocalNotificationDateInterpretation.absoluteTime,
      androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    );
  } catch (_) {
    // Fall back to inexact — still fires, may drift ~5-10 min
    try {
      await _plugin.zonedSchedule(
        alarmId, title, message, fireAt, details,
        uiLocalNotificationDateInterpretation:
            UILocalNotificationDateInterpretation.absoluteTime,
        androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
      );
    } catch (e) {
      print('[Notifications] Failed to schedule alarm: $e');
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-8-one-shot-alarms-pomodoro-timers-&quot;&gt;Step 8: One-Shot Alarms (Pomodoro, Timers)&lt;/h3&gt;
&lt;p&gt;Daily repeating alarms cover most cases. But sometimes you need a notification that fires once at a specific future time — like when a 25-minute pomodoro focus session ends.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleOneShot({
  required int id,
  required String title,
  required String body,
  required int minutesFromNow,
  required NotificationDetails details,
}) async {
  final fireAt = tz.TZDateTime.now(tz.local)
      .add(Duration(minutes: minutesFromNow));

  await _plugin.zonedSchedule(
    id,
    title,
    body,
    fireAt,
    details,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
    // NO matchDateTimeComponents — this fires once, doesn't repeat
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why you need this for pomodoro:&lt;/strong&gt; A Dart &lt;code&gt;Timer.periodic&lt;/code&gt; runs in your app's process. When the user locks their phone, the OS suspends the app within ~30 seconds. Your timer stops ticking. The phase ends, and — silence. The bell only rings when they unlock the phone and the app resumes.&lt;/p&gt;
&lt;p&gt;An OS alarm doesn't care about your app's lifecycle. It fires regardless.&lt;/p&gt;
&lt;h4 id=&quot;making-one-shot-alarms-alarm-grade&quot;&gt;Making one-shot alarms alarm-grade&lt;/h4&gt;
&lt;p&gt;A standard &lt;code&gt;Importance.high&lt;/code&gt; notification won't cut it on Samsung, Xiaomi, or Huawei devices. These OEMs aggressively kill background processes and suppress notifications they deem non-essential. You need to make your notification look like an alarm clock to the OS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;import 'dart:typed_data';

NotificationDetails _alarmGradeDetails({required bool isWorkPhase}) {
  final soundFile = isWorkPhase ? 'bell_focus' : 'bell_break';
  final channelId = isWorkPhase ? 'focus_bell' : 'break_bell';
  final channelName = isWorkPhase ? 'Focus Bell' : 'Break Bell';

  return NotificationDetails(
    android: AndroidNotificationDetails(
      channelId,
      channelName,
      channelDescription: 'Pomodoro phase transition',
      importance: Importance.max,                              // Maximum priority
      priority: Priority.max,
      sound: RawResourceAndroidNotificationSound(soundFile),
      playSound: true,
      fullScreenIntent: true,                                  // Wakes the screen
      category: AndroidNotificationCategory.alarm,             // Treated like an alarm
      visibility: NotificationVisibility.public,               // Shows on lock screen
      enableVibration: true,
      vibrationPattern: Int64List.fromList([0, 400, 200, 400]),
    ),
    iOS: DarwinNotificationDetails(
      presentAlert: true,
      presentSound: true,
      sound: '$soundFile.wav',
      interruptionLevel: InterruptionLevel.timeSensitive,      // Breaks through Focus
    ),
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What each flag does:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Flag &lt;/th&gt;&lt;th&gt;Effect&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;importance: Importance.max&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Heads-up notification — appears at the top of the screen&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;fullScreenIntent: true&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Wakes the screen and shows the notification even when locked. This is what alarm clock apps use.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;category: AndroidNotificationCategory.alarm&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Tells the OS this is time-critical. Samsung's battery manager respects this category.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;visibility: NotificationVisibility.public&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Content visible on the lock screen without unlocking&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;vibrationPattern&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Custom vibration so the user physically feels it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;interruptionLevel: InterruptionLevel.timeSensitive&lt;/code&gt; &lt;/td&gt;&lt;td&gt;iOS 15+: breaks through Focus mode&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;This is the difference between a notification that works on your desk and one that works in your pocket.&lt;/strong&gt; Standard &lt;code&gt;Importance.high&lt;/code&gt; gets silently suppressed by Samsung's battery manager. &lt;code&gt;Importance.max&lt;/code&gt; + &lt;code&gt;fullScreenIntent&lt;/code&gt; + &lt;code&gt;category: alarm&lt;/code&gt; does not.&lt;/p&gt;
&lt;h4 id=&quot;the-dual-delivery-pattern&quot;&gt;The dual-delivery pattern&lt;/h4&gt;
&lt;p&gt;When the app is in the foreground, the Dart timer catches the transition first (instant feedback). When the phone is locked, the OS alarm delivers it. To avoid the user hearing two bells:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// In your timer tick (foreground path)
void _onPomodoroPhaseEnd() {
  _plugin.cancel(alarmId);            // Cancel the OS alarm (Dart beat it)
  _plugin.show(displayId, ...);       // Show the notification immediately
  _scheduleNextPhaseAlarm();          // Schedule for the next phase
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use separate IDs for the scheduled alarm and the instant notification if you want, or the same ID if you want one to replace the other. Either way, the user sees exactly one notification.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-9-weekly-repeating-notifications&quot;&gt;Step 9: Weekly Repeating Notifications&lt;/h3&gt;
&lt;p&gt;For notifications that fire on specific weekdays — like a project reminder every Monday, Wednesday, and Friday at 6:00 PM.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;static Future&amp;lt;void&amp;gt; scheduleWeekly({
  required int id,
  required String title,
  required String body,
  required int weekday,    // 1 = Monday, 7 = Sunday
  required int hour,
  required int minute,
}) async {
  await _plugin.zonedSchedule(
    id,
    title,
    body,
    _nextInstanceOfWeekdayTime(weekday, hour, minute),
    /* notificationDetails */,
    uiLocalNotificationDateInterpretation:
        UILocalNotificationDateInterpretation.absoluteTime,
    androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
    matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime,  // ← Weekly
  );
}

static tz.TZDateTime _nextInstanceOfWeekdayTime(
  int weekday, int hour, int minute,
) {
  final now = tz.TZDateTime.now(tz.local);
  var scheduled = tz.TZDateTime(
    tz.local, now.year, now.month, now.day, hour, minute,
  );
  // Advance to the target weekday
  while (scheduled.weekday != weekday) {
    scheduled = scheduled.add(const Duration(days: 1));
  }
  // If it's already passed this week, push to next week
  if (scheduled.isBefore(now)) {
    scheduled = scheduled.add(const Duration(days: 7));
  }
  return scheduled;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Each weekday needs its own notification ID. If you want reminders on Monday, Wednesday, and Friday, that's three separate &lt;code&gt;zonedSchedule&lt;/code&gt; calls with three different IDs. When removing the reminder, cancel all of them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;// Generating unique IDs per project + weekday
final notifId = baseId + projectId.hashCode.abs() % 900 + weekday;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-10-surviving-samsung-battery-optimization&quot;&gt;Step 10: Surviving Samsung Battery Optimization&lt;/h3&gt;
&lt;p&gt;This is the step most Flutter notification tutorials skip, and it's why your notifications work perfectly during development but fail silently in production.&lt;/p&gt;
&lt;h4 id=&quot;the-problem-1&quot;&gt;The problem&lt;/h4&gt;
&lt;p&gt;Samsung, Xiaomi, Huawei, OnePlus, and most Chinese OEMs add an aggressive battery optimization layer &lt;strong&gt;on top of&lt;/strong&gt; Android's standard Doze mode. Even if your alarm is correctly s&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15350/build-reliable-local-notifications-in-flutter-step-by-step</guid>
<pubDate>Tue, 21 Apr 2026 01:03:01 +0000</pubDate>
</item>
<item>
<title>Why React Native Builds Break After Updating Dependencies (And How to Fix It)</title>
<link>https://coderlegion.com/15184/why-react-native-builds-break-after-updating-dependencies-and-how-to-fix-it</link>
<description>&lt;h3 id=&quot;why-react-native-builds-break-after-updating-dependencies-and-how-to-fix-it-&quot;&gt;Why React Native Builds Break After Updating Dependencies (And How to Fix It)&lt;/h3&gt;
&lt;p&gt;If you've ever updated your dependencies and suddenly your React Native Android build stops working… you're not alone.&lt;/p&gt;
&lt;p&gt;Everything worked before.&lt;/p&gt;
&lt;p&gt;You change a few versions, run the build again—and now you're hit with cryptic Gradle errors, build failures, or crashes.&lt;/p&gt;
&lt;p&gt;This post breaks down &lt;strong&gt;why this happens&lt;/strong&gt; and how to actually fix it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-real-problem&quot;&gt;The Real Problem&lt;/h3&gt;
&lt;p&gt;React Native projects depend on a fragile ecosystem of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Gradle&lt;/li&gt;
&lt;li&gt;Android Gradle Plugin (AGP)&lt;/li&gt;
&lt;li&gt;Java (JDK)&lt;/li&gt;
&lt;li&gt;Firebase / native SDKs&lt;/li&gt;
&lt;li&gt;Third-party libraries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you update even one dependency, you can accidentally break compatibility between them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;common-symptoms-after-updating&quot;&gt;Common Symptoms After Updating&lt;/h3&gt;
&lt;p&gt;You might see errors like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Execution failed for task ':app:compileDebugJavaWithJavac'&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Duplicate class found in modules&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Could not determine Java version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;A problem occurred configuring project ':app'&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;App works before update, fails after&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The problem is rarely obvious from the error message.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-happens&quot;&gt;Why This Happens&lt;/h3&gt;
&lt;h4 id=&quot;1-version-mismatch&quot;&gt;1. Version Mismatch&lt;/h4&gt;
&lt;p&gt;Some libraries require:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Specific Gradle versions  &lt;/li&gt;
&lt;li&gt;Specific Android Gradle Plugin versions  &lt;/li&gt;
&lt;li&gt;Specific Java versions  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If one gets upgraded and others don’t → build breaks.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-transitive-dependency-conflicts&quot;&gt;2. Transitive Dependency Conflicts&lt;/h4&gt;
&lt;p&gt;When you install or update a package, it may bring its own dependencies.&lt;/p&gt;
&lt;p&gt;Sometimes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two libraries depend on different versions of the same module&lt;/li&gt;
&lt;li&gt;Gradle can't resolve the conflict → build failure&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-cached-corrupted-builds&quot;&gt;3. Cached / Corrupted Builds&lt;/h4&gt;
&lt;p&gt;Gradle caches aggressively.&lt;/p&gt;
&lt;p&gt;After updates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Old compiled artifacts remain&lt;/li&gt;
&lt;li&gt;Build system gets inconsistent → weird errors&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;4-breaking-changes-in-libraries&quot;&gt;4. Breaking Changes in Libraries&lt;/h4&gt;
&lt;p&gt;Some updates introduce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Removed APIs&lt;/li&gt;
&lt;li&gt;Changed configurations&lt;/li&gt;
&lt;li&gt;New requirements (e.g. minSdk, compileSdk)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-to-fix-it-step-by-step-&quot;&gt;How to Fix It (Step-by-Step)&lt;/h3&gt;
&lt;h4 id=&quot;1-clean-everything&quot;&gt;1. Clean Everything&lt;/h4&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then delete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;node_modules&lt;/li&gt;
&lt;li&gt;android/.gradle&lt;/li&gt;
&lt;li&gt;android/build&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Reinstall:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-verify-java-jdk-version&quot;&gt;2. Verify Java (JDK) Version&lt;/h4&gt;
&lt;p&gt;React Native (especially newer versions) typically requires:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;JDK 17&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;java -version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mismatch here breaks builds instantly.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-align-gradle-agp-versions&quot;&gt;3. Align Gradle + AGP Versions&lt;/h4&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;android/build.gradle&lt;/li&gt;
&lt;li&gt;gradle-wrapper.properties&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Make sure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Gradle version matches AGP requirements&lt;/li&gt;
&lt;li&gt;You're not mixing incompatible versions&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;4-check-compilesdk-targetsdk&quot;&gt;4. Check compileSdk / targetSdk&lt;/h4&gt;
&lt;p&gt;In android/app/build.gradle:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;compileSdkVersion 34
targetSdkVersion 34
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Some libraries require newer SDK versions.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;5-look-for-duplicate-dependencies&quot;&gt;5. Look for Duplicate Dependencies&lt;/h4&gt;
&lt;p&gt;If you see:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Duplicate class found in modules
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You may need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Exclude dependencies&lt;/li&gt;
&lt;li&gt;Align versions manually&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;6-reset-environment-variables-advanced-but-important-&quot;&gt;6. Reset Environment Variables (Advanced but Important)&lt;/h4&gt;
&lt;p&gt;One issue I ran into:&lt;/p&gt;
&lt;p&gt;A broken GRADLE_OPTS configuration caused TLS handshake failures and dependency download issues.&lt;/p&gt;
&lt;p&gt;Fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Remove custom GRADLE_OPTS&lt;/li&gt;
&lt;li&gt;Let Gradle use default JDK settings&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is rarely mentioned—but can completely break builds.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;7-rebuild-from-scratch&quot;&gt;7. Rebuild from Scratch&lt;/h4&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx react-native run-android
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-insight&quot;&gt;Key Insight&lt;/h3&gt;
&lt;p&gt;React Native builds don’t usually break randomly.&lt;/p&gt;
&lt;p&gt;They break because: &lt;strong&gt;your environment and dependency versions are no longer aligned&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-advice&quot;&gt;Final Advice&lt;/h3&gt;
&lt;p&gt;When updating dependencies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Don’t update everything at once  &lt;/li&gt;
&lt;li&gt;Upgrade incrementally  &lt;/li&gt;
&lt;li&gt;Test after each change  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes debugging much easier.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you're dealing with a specific error after an update, feel free to share it—I’ve probably hit it before &lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/15184/why-react-native-builds-break-after-updating-dependencies-and-how-to-fix-it</guid>
<pubDate>Sun, 19 Apr 2026 16:39:23 +0000</pubDate>
</item>
<item>
<title>Android CLI</title>
<link>https://coderlegion.com/15070/android-cli</link>
<description>&lt;p&gt;Google has announced Android CLI, a new official command line tool for Android development aimed at agent based workflows outside Android Studio. The release is part of a broader set of agent tools that also includes Android Skills and the Android Knowledge Base. Google says these tools are meant to help agents work with official Android workflows, recommended patterns, and up to date guidance.&lt;/p&gt;
&lt;p&gt;According to the announcement, Android CLI is designed to handle core terminal based Android tasks such as environment setup, project creation, SDK management, emulator management, and app deployment. Google highlights commands such as &lt;code&gt;android sdk install&lt;/code&gt;, &lt;code&gt;android create&lt;/code&gt;, a&lt;code&gt;ndroid emulator&lt;/code&gt;, &lt;code&gt;android run&lt;/code&gt;, and &lt;code&gt;android update&lt;/code&gt; as the foundation of the new workflow.&lt;/p&gt;
&lt;p&gt;Google also says that in its internal experiments, Android CLI reduced LLM token usage for project and environment setup by more than 70 percent and completed tasks 3 times faster than agents using standard toolsets alone. That performance claim comes directly from Google’s own announcement, so it should be viewed as an internal benchmark rather than an independent measurement.&lt;/p&gt;
&lt;p&gt;Google also launched the Android Knowledge Base. Google says it can be accessed through the &lt;code&gt;android docs&lt;/code&gt; command and is intended to give agents fresher, authoritative context from Android developer docs, Firebase, Google Developers, and Kotlin documentation. That is an important detail for teams using coding agents, because it gives Android a more official path for grounding AI generated output in current platform guidance.&lt;/p&gt;
&lt;p&gt;Official announcement:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://android-developers.googleblog.com/2026/04/build-android-apps-3x-faster-using-any-agent.html&quot;&gt;https://android-developers.googleblog.com/2026/04/build-android-apps-3x-faster-using-any-agent.html&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/15070/android-cli</guid>
<pubDate>Fri, 17 Apr 2026 12:07:23 +0000</pubDate>
</item>
<item>
<title>Understanding Flutter Method Channels Through a Real Production Bug</title>
<link>https://coderlegion.com/14995/understanding-flutter-method-channels-through-a-real-production-bug</link>
<description>&lt;h3 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Flutter is amazing—until it isn’t.&lt;/p&gt;
&lt;p&gt;Most of the time, you can build entire apps without ever touching native code. But the moment you hit a platform limitation, things get… interesting.&lt;/p&gt;
&lt;p&gt;This blog isn’t just about Method Channels.&lt;/p&gt;
&lt;p&gt;It’s about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When Flutter abstractions break down&lt;/li&gt;
&lt;li&gt;How to take control using native Android&lt;/li&gt;
&lt;li&gt;And how to think about Flutter ↔ Native communication like a system, not a hack&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Funny enough, this blog comes from a real bug I hit while building my app &lt;em&gt;Anahad&lt;/em&gt; — a saadhna app that plays mantra chants.&lt;/p&gt;
&lt;p&gt;And yeah… it broke in a very weird way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-real-problem-why-this-blog-exists-&quot;&gt;The Real Problem (Why This Blog Exists)&lt;/h3&gt;
&lt;p&gt;I was building a saadhna app that plays mantra chants on loop.&lt;/p&gt;
&lt;p&gt;Simple requirement:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Play audio  &lt;/li&gt;
&lt;li&gt;Loop it continuously  &lt;/li&gt;
&lt;li&gt;Run in the background (screen off)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;audio_service&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;just_audio&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;just_audio_background&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything worked perfectly…&lt;/p&gt;
&lt;p&gt;Until it didn’t.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-bug&quot;&gt;The Bug&lt;/h4&gt;
&lt;p&gt;After ~940–960 loops on Android (specifically Android 12+):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Playback would stop silently  &lt;/li&gt;
&lt;li&gt;No crash  &lt;/li&gt;
&lt;li&gt;No exception  &lt;/li&gt;
&lt;li&gt;Only when the screen was off  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Disabling battery optimization  &lt;/li&gt;
&lt;li&gt;Proper background setup  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Still broke.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;the-weird-part&quot;&gt;The Weird Part&lt;/h4&gt;
&lt;p&gt;It &lt;em&gt;consistently&lt;/em&gt; failed around the same loop range (~940–960).&lt;/p&gt;
&lt;p&gt;Not 100. Not 500. Not random.&lt;/p&gt;
&lt;p&gt;That kind of consistency usually means:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Something deeper is going on — not just app-level logic.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;at-this-point-two-choices&quot;&gt;At This Point, Two Choices&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Keep debugging blindly inside Flutter  &lt;/li&gt;
&lt;li&gt;Drop down to native Android and take control  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I chose option 2, cz i never interacted with native code and wanted to explore it&lt;/p&gt;
&lt;p&gt;Fun part, i had an android 11 device which wasn't having this bug at all, tested on other android 11 devices as well&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-flutter-isn-t-always-enough&quot;&gt;Why Flutter Isn’t Always Enough&lt;/h3&gt;
&lt;p&gt;Flutter is great, but it abstracts away platform details.&lt;/p&gt;
&lt;p&gt;That’s usually good—until you need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Deep lifecycle control  &lt;/li&gt;
&lt;li&gt;System-level integrations  &lt;/li&gt;
&lt;li&gt;Debug visibility into platform behavior  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Common cases where native is needed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Background services  &lt;/li&gt;
&lt;li&gt;Media playback control  &lt;/li&gt;
&lt;li&gt;Hardware interaction  &lt;/li&gt;
&lt;li&gt;Platform-specific APIs  &lt;/li&gt;
&lt;li&gt;Performance-critical logic  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In my case:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I needed more control over how Android handles long-running audio playback. Just to be absolutely sure that it was not the issue of the package that i was using&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;understanding-method-channels-the-right-mental-model-&quot;&gt;Understanding Method Channels (The Right Mental Model)&lt;/h3&gt;
&lt;p&gt;Before code, get this clear:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Dart is the client. Native Android is the server.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When you write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;platform.invokeMethod(&quot;play&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You’re essentially doing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dart → sends request&lt;/li&gt;
&lt;li&gt;Android → receives it&lt;/li&gt;
&lt;li&gt;Executes native logic&lt;/li&gt;
&lt;li&gt;Returns response&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of it like:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;An internal API call inside your app&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;No HTTP. No network. Just a binary bridge.&lt;/p&gt;
&lt;p&gt;Once you understand this, Method Channels stop feeling “magical”.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architecture-overview&quot;&gt;Architecture Overview&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Flutter (Dart)
├── UI Layer
├── Service Layer (PlaybackService.dart)
└── State Management

        ↓↓↓ Method Channel ↓↓↓

Android (Kotlin)
├── MethodChannel Handler
├── PlaybackService (MediaSessionService)
└── ExoPlayer
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;flow-step-by-step-&quot;&gt;Flow (Step-by-Step)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;User taps &quot;Play&quot;&lt;/li&gt;
&lt;li&gt;Dart calls &lt;code&gt;invokeMethod(&quot;play&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;MethodChannel sends message&lt;/li&gt;
&lt;li&gt;Kotlin receives it&lt;/li&gt;
&lt;li&gt;Native service executes logic&lt;/li&gt;
&lt;li&gt;Result is sent back&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-native-side-android-kotlin-&quot;&gt;The Native Side: Android (Kotlin)&lt;/h3&gt;
&lt;h4 id=&quot;why-mediasessionservice-&quot;&gt;Why MediaSessionService?&lt;/h4&gt;
&lt;p&gt;Instead of a basic service, we use:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;MediaSessionService&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Because it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Integrates with system controls (lock screen, notifications)&lt;/li&gt;
&lt;li&gt;Handles background playback more reliably&lt;/li&gt;
&lt;li&gt;Works with audio focus and lifecycle events&lt;/li&gt;
&lt;li&gt;Plays nicely with the Android system&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;playbackservice-kt&quot;&gt;PlaybackService.kt&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class PlaybackService : MediaSessionService() {
    private var mediaSession: MediaSession? = null

    override fun onCreate() {
        super.onCreate()

        val audioAttributes =
            AudioAttributes.Builder()
                .setUsage(C.USAGE_MEDIA)
                .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
                .build()

        val player =
            ExoPlayer.Builder(this)
                .setAudioAttributes(audioAttributes, true)
                .setHandleAudioBecomingNoisy(true)
                .setWakeMode(C.WAKE_MODE_NETWORK)
                .build()

        player.addListener(object : Player.Listener {
            override fun onPlayerError(error: PlaybackException) {
                Log.e(&quot;PlaybackService&quot;, &quot;Error: ${error.message}&quot;, error)
            }
        })

        val callback = object : MediaSession.Callback {}

        mediaSession = MediaSession.Builder(this, player)
            .setCallback(callback)
            .build()
    }

    override fun onDestroy() {
        mediaSession?.run {
            player.release()
            release()
            mediaSession = null
        }
        super.onDestroy()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-flutter-side-dart&quot;&gt;The Flutter Side: Dart&lt;/h3&gt;
&lt;h4 id=&quot;service-wrapper&quot;&gt;Service Wrapper&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class PlaybackService {
  static const platform =
      MethodChannel('com.theahambrahmasmi.anahad/playback');

  static Future&amp;lt;void&amp;gt; play(String url) async {
    await platform.invokeMethod('play', {'url': url});
  }

  static Future&amp;lt;void&amp;gt; pause() async {
    await platform.invokeMethod('pause');
  }

  static Future&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt; getStatus() async {
    final result = await platform.invokeMethod('getStatus');
    return Map&amp;lt;String, dynamic&amp;gt;.from(result);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;kotlin-method-channel-handler&quot;&gt;Kotlin: Method Channel Handler&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class PlaybackMethodHandler(private val context: Context) {

    fun setupChannel(flutterEngine: FlutterEngine) {
        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            &quot;com.theahambrahmasmi.anahad/playback&quot;
        ).setMethodCallHandler { call, result -&amp;gt;

            when (call.method) {
                &quot;play&quot; -&amp;gt; {
                    val url = call.argument&amp;lt;String&amp;gt;(&quot;url&quot;)
                    val intent = Intent(context, PlaybackService::class.java)
                    intent.putExtra(&quot;audio_url&quot;, url)
                    context.startService(intent)
                    result.success(null)
                }

                &quot;pause&quot; -&amp;gt; {
                    val intent = Intent(context, PlaybackService::class.java)
                    context.stopService(intent)
                    result.success(null)
                }

                else -&amp;gt; result.notImplemented()
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;real-world-insight-the-looping-problem&quot;&gt;Real-World Insight: The Looping Problem&lt;/h3&gt;
&lt;p&gt;If you're building:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Meditation apps&lt;/li&gt;
&lt;li&gt;Chanting apps&lt;/li&gt;
&lt;li&gt;Ambient loop apps&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Be careful.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Android may silently stop long-running playback after extended loops.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Even with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Battery optimization disabled&lt;/li&gt;
&lt;li&gt;Background services configured&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Test aggressively:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;500+ loops&lt;/li&gt;
&lt;li&gt;Screen-off playback&lt;/li&gt;
&lt;li&gt;Different OEM devices&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is not just Flutter—it’s often &lt;strong&gt;Android system behavior&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-the-root-cause-&quot;&gt;What About The Root Cause?&lt;/h3&gt;
&lt;p&gt;Short answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I don’t fully know yet.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And yeah — that’s intentional honesty.&lt;/p&gt;
&lt;p&gt;What I &lt;em&gt;do&lt;/em&gt; know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It only happens on &lt;strong&gt;Android 12+&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;It triggers after long-running background playback&lt;/li&gt;
&lt;li&gt;It happens silently (no crash, no logs)&lt;/li&gt;
&lt;li&gt;It correlates with a high loop count (~950)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;current-hypotheses-&quot;&gt;Current Hypotheses:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Doze / background execution limits&lt;/li&gt;
&lt;li&gt;Audio focus or media session interruption&lt;/li&gt;
&lt;li&gt;ExoPlayer internal behavior after prolonged looping&lt;/li&gt;
&lt;li&gt;OEM-level process killing&lt;/li&gt;
&lt;li&gt;Resource exhaustion over time&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;we-ll-be-updating-this&quot;&gt;We’ll Be Updating This&lt;/h3&gt;
&lt;p&gt;Once I dig deeper and find the exact root cause, I’ll update this blog with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What exactly broke&lt;/li&gt;
&lt;li&gt;Why Android 12+ behaves this way&lt;/li&gt;
&lt;li&gt;The proper fix (not just experimental ones)&lt;/li&gt;
&lt;li&gt;What you should do in production&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because honestly?&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Debugging something that breaks at the OS level is messy… and kinda fun.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-7&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;I eventually moved back to &lt;code&gt;just_audio&lt;/code&gt; because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The problematic flow was no longer needed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So yeah…&lt;/p&gt;
&lt;p&gt;This experimental fix wasn’t used in production.&lt;/p&gt;
&lt;p&gt;But it gave me something way more valuable:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A deeper understanding of how Flutter actually works under the hood.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And honestly?&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;I’m not perfect. I didn’t fully solve it (yet).&lt;br&gt;
But now I know one more thing than I did before.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And that’s what matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Flutter is great, but not enough for everything&lt;/li&gt;
&lt;li&gt;Method Channels = internal RPC bridge&lt;/li&gt;
&lt;li&gt;Native gives you control Flutter abstracts away&lt;/li&gt;
&lt;li&gt;Android background behavior can be unpredictable&lt;/li&gt;
&lt;li&gt;Sometimes… you won’t have all the answers immediately&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;If you're building something serious:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Don’t just rely on abstractions. Understand what’s underneath.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That’s where real engineering begins.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/14995/understanding-flutter-method-channels-through-a-real-production-bug</guid>
<pubDate>Thu, 16 Apr 2026 15:48:54 +0000</pubDate>
</item>
<item>
<title>Android official agent skills</title>
<link>https://coderlegion.com/14973/android-official-agent-skills</link>
<description>&lt;p&gt;The Android team has published an official repository of Agent Skills for Android development. The new &lt;code&gt;android/skills&lt;/code&gt; repository is hosted under the official Android GitHub organization, and Android describes these skills as modular instructions and resources that help LLMs better understand and execute Android specific tasks according to best practices.&lt;/p&gt;
&lt;p&gt;The github repo:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/android/skills&quot;&gt;https://github.com/android/skills&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The repository already shows the kind of workflows Android wants to support. Its current structure includes skills for AGP 9 upgrade work, XML Views to Jetpack Compose migration, Navigation 3, R8 analysis, Play Billing version upgrades, and edge to edge support. That makes the repository more than a generic demo. It is an initial curated set of focused Android engineering workflows.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14973/android-official-agent-skills</guid>
<pubDate>Thu, 16 Apr 2026 12:12:52 +0000</pubDate>
</item>
<item>
<title>React Native Android Build Failed: Could Not Resolve Dependency (Real Causes)</title>
<link>https://coderlegion.com/14696/react-native-android-build-failed-could-not-resolve-dependency-real-causes</link>
<description>&lt;p&gt;React Native Android builds can fail for a lot of reasons—but one of the most common (and confusing) ones is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Could not resolve dependency&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you’ve seen this, you probably also noticed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the error message is long&lt;/li&gt;
&lt;li&gt;it looks very “Gradle-heavy”&lt;/li&gt;
&lt;li&gt;and it doesn’t clearly tell you what to fix&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here’s what this error actually means—and how to approach it without wasting hours.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-could-not-resolve-dependency-really-means&quot;&gt;What “Could not resolve dependency” really means&lt;/h3&gt;
&lt;p&gt;This error usually appears when Gradle tries to fetch or link a dependency and fails.&lt;/p&gt;
&lt;p&gt;But the important part is this:&lt;/p&gt;
&lt;p&gt; it’s not always a network issue&lt;/p&gt;
&lt;p&gt;Most of the time, it’s caused by something in your project setup.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-version-mismatch-most-common-cause-&quot;&gt;1. Version mismatch (most common cause)&lt;/h3&gt;
&lt;p&gt;This is the #1 reason behind this error.&lt;/p&gt;
&lt;p&gt;Examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a library requires a newer React Native version&lt;/li&gt;
&lt;li&gt;your Gradle or Android plugin version is outdated&lt;/li&gt;
&lt;li&gt;Kotlin version doesn’t match project setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even a small mismatch can break dependency resolution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-repository-configuration-issues&quot;&gt;2. Repository configuration issues&lt;/h3&gt;
&lt;p&gt;Gradle needs to know &lt;strong&gt;where to download dependencies from&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Check your &lt;code&gt;build.gradle&lt;/code&gt; files for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mavenCentral()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;google()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If a required repository is missing, Gradle simply won’t find the dependency.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-incorrect-or-missing-dependency-declaration&quot;&gt;3. Incorrect or missing dependency declaration&lt;/h3&gt;
&lt;p&gt;Sometimes the issue is simpler than it looks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;typo in dependency name&lt;/li&gt;
&lt;li&gt;wrong version specified&lt;/li&gt;
&lt;li&gt;dependency removed or renamed upstream&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In these cases, Gradle is doing exactly what it should—it just can’t find what you asked for.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-caching-problems&quot;&gt;4. Caching problems&lt;/h3&gt;
&lt;p&gt;Yes—sometimes it really is cache.&lt;/p&gt;
&lt;p&gt;You might have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;corrupted Gradle cache&lt;/li&gt;
&lt;li&gt;stale dependencies&lt;/li&gt;
&lt;li&gt;partial downloads&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cleaning can help here:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But keep in mind:&lt;br&gt;
 if the issue is structural, cleaning won’t fix it&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-network-proxy-issues&quot;&gt;5. Network / proxy issues&lt;/h3&gt;
&lt;p&gt;Less common, but still possible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;blocked connections&lt;/li&gt;
&lt;li&gt;corporate proxies&lt;/li&gt;
&lt;li&gt;SSL issues&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If Gradle can’t reach the repository, dependency resolution fails.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-autolinking-side-effects&quot;&gt;6. Autolinking side effects&lt;/h3&gt;
&lt;p&gt;Sometimes this error shows up after installing a new package.&lt;/p&gt;
&lt;p&gt;What’s happening:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the package introduces native dependencies&lt;/li&gt;
&lt;li&gt;Gradle tries to resolve them&lt;/li&gt;
&lt;li&gt;something in the chain fails&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why the error often appears right after adding a library.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-to-debug-it-faster&quot;&gt;How to debug it faster&lt;/h3&gt;
&lt;p&gt;Instead of reacting to the full error message, do this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Look for the &lt;strong&gt;exact dependency name&lt;/strong&gt; that failed&lt;/li&gt;
&lt;li&gt;Check its &lt;strong&gt;version compatibility&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Verify your &lt;strong&gt;repositories&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Connect it to the &lt;strong&gt;last change you made&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This approach is much faster than trying random fixes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-8&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;“Could not resolve dependency” sounds like a simple error—but it can come from multiple causes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;version mismatches&lt;/li&gt;
&lt;li&gt;missing repositories&lt;/li&gt;
&lt;li&gt;incorrect dependencies&lt;/li&gt;
&lt;li&gt;caching issues&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you treat it as a category of problems instead of a single issue, debugging becomes much easier.&lt;/p&gt;
&lt;p&gt;And in most cases, the fix is simpler than it first appears.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/14696/react-native-android-build-failed-could-not-resolve-dependency-real-causes</guid>
<pubDate>Mon, 13 Apr 2026 16:06:32 +0000</pubDate>
</item>
<item>
<title>Building OneRule: A Technical Deep Dive into an Offline Password Manager with Flutter, SQLCipher, and AES-GCM</title>
<link>https://coderlegion.com/14676/building-onerule-technical-deep-dive-into-offline-password-manager-with-flutter-sqlcipher</link>
<description>&lt;p&gt;Password managers often get discussed at product level: sync, UX, pricing, recovery, browser extensions.&lt;/p&gt;
&lt;p&gt;What interested me more in OneRule was lower in stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;what &quot;offline-first&quot; actually means in implementation&lt;/li&gt;
&lt;li&gt;how local key derivation is handled&lt;/li&gt;
&lt;li&gt;where encryption boundaries sit&lt;/li&gt;
&lt;li&gt;how Flutter and native Android divide responsibility&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OneRule is an open-source Android password manager built with Flutter. It does not require an account or backend for core vault operations. The interesting part is not only that it works offline, but that the architecture is intentionally shaped around that constraint.&lt;/p&gt;
&lt;p&gt;This post is a technical walkthrough of current implementation: storage model, key flow, backup format, panic mode behavior, and Android Autofill boundary.&lt;/p&gt;
&lt;h3 id=&quot;architectural-shape&quot;&gt;Architectural Shape&lt;/h3&gt;
&lt;p&gt;At code level, OneRule follows a fairly direct layered structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flutter UI for screens, forms, and interaction flows&lt;/li&gt;
&lt;li&gt;Provider-based state for app-level reactive data&lt;/li&gt;
&lt;li&gt;service and facade classes for storage, crypto, backup, auth, clipboard, and platform integration&lt;/li&gt;
&lt;li&gt;native Android code where platform APIs are necessary, especially Autofill&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In practical terms, the flow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;UI/screens
  -&amp;gt; Provider state
    -&amp;gt; service/facade layer
      -&amp;gt; SQLCipher / secure storage / crypto / platform channels
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is not an especially exotic architecture, but it fits security-sensitive mobile software well. UI code stays mostly unaware of encryption details. Providers coordinate visible app state. Services own the mechanics of vault initialization, field encryption, backup creation, and unlock behavior.&lt;/p&gt;
&lt;p&gt;For a Flutter app, that separation matters because it reduces chances that sensitive logic leaks into widget code and becomes harder to reason about.&lt;/p&gt;
&lt;p&gt;The startup path is also explicit about local protections. On launch, the app initializes error handling and reminder services, then enables screen-capture protections before rendering UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await LocalLogService.instance.initializeGlobalErrorHandlers();
  await BackupReminderService.instance.initialize();

  if (Platform.isAndroid || Platform.isIOS) {
    await ScreenProtector.preventScreenshotOn();
    await ScreenProtector.protectDataLeakageOn();
    await ScreenProtector.protectDataLeakageWithBlur();
  }

  runApp(/* providers + app */);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is small, but it shows something important about OneRule: operational security controls are part of app bootstrap, not optional polish added later.&lt;/p&gt;
&lt;h3 id=&quot;data-at-rest-model&quot;&gt;Data-at-Rest Model&lt;/h3&gt;
&lt;p&gt;The vault is not stored as plaintext records in local SQLite.&lt;/p&gt;
&lt;p&gt;OneRule uses &lt;strong&gt;SQLCipher-backed SQLite&lt;/strong&gt; as the primary vault store. The database file is encrypted at rest, and the SQLCipher password is derived from the active in-memory session key.&lt;/p&gt;
&lt;p&gt;But the design does not stop there.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;password&lt;/code&gt; field is also encrypted again at application level using &lt;strong&gt;AES-256-GCM&lt;/strong&gt;. So the current storage model is effectively:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;encrypted database file via SQLCipher&lt;/li&gt;
&lt;li&gt;encrypted sensitive field payloads inside that database&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That second layer is important. It means the most sensitive credential field is stored and serialized as an authenticated encrypted envelope, not only as a row protected by database-level encryption. When the Flutter app needs to render or analyze entries, it decrypts that field at read time.&lt;/p&gt;
&lt;p&gt;The current field payload format is versioned and structured like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;or1:v2:gcm:&amp;lt;nonce_b64url&amp;gt;:&amp;lt;cipherText_b64url&amp;gt;:&amp;lt;tag_b64url&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using a versioned envelope instead of opaque bytes makes future migrations much easier. It also gives the app a clean way to distinguish between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;current AES-GCM records&lt;/li&gt;
&lt;li&gt;legacy AES-CBC records&lt;/li&gt;
&lt;li&gt;pre-envelope data from older versions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That matters because OneRule includes migration logic and does not assume the vault will always start in latest format.&lt;/p&gt;
&lt;p&gt;For backups, the code takes the same approach: explicit envelope, explicit metadata, explicit version.&lt;/p&gt;
&lt;h3 id=&quot;key-derivation-and-session-model&quot;&gt;Key Derivation and Session Model&lt;/h3&gt;
&lt;p&gt;The master PIN is not used directly as an encryption key.&lt;/p&gt;
&lt;p&gt;Instead, OneRule derives key material with &lt;strong&gt;PBKDF2-HMAC-SHA256&lt;/strong&gt;. Current implementation uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;100,000 iterations for interactive PIN unlock/session derivation&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Backup encryption uses a separate derivation profile:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;200,000 iterations&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salt embedded in backup payload&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That separation is a good design choice. Unlock flows need acceptable latency on-device. Backup export and import can afford a higher work factor because they are less frequent and less latency-sensitive.&lt;/p&gt;
&lt;p&gt;The session model is also worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the active vault session key is kept in memory&lt;/li&gt;
&lt;li&gt;SQLCipher password is derived from that key&lt;/li&gt;
&lt;li&gt;secure storage holds salts, verifiers, and biometric-restorable material&lt;/li&gt;
&lt;li&gt;session key is cleared on logout and lock paths&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the app has a fairly explicit distinction between:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;persistent verification or recovery metadata&lt;/li&gt;
&lt;li&gt;active decrypted session state&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is small but important. A lot of local-first security design is really state-lifecycle design.&lt;/p&gt;
&lt;p&gt;Backup derivation path is straightforward and readable in code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;SecretKey&amp;gt; _deriveKey(String passphrase, List&amp;lt;int&amp;gt; salt) async {
  final pbkdf2 = Pbkdf2(
    macAlgorithm: Hmac.sha256(),
    iterations: _pbkdf2Iterations,
    bits: 256,
  );
  return pbkdf2.deriveKey(
    secretKey: SecretKey(utf8.encode(passphrase)),
    nonce: salt,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the payload is encrypted with AES-GCM using fresh salt and nonce:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final salt = _randomBytes(16);
final nonce = _randomBytes(12);
final key = await _deriveKey(passphrase, salt);

final secretBox = await _gcm.encrypt(
  utf8.encode(plaintext),
  secretKey: key,
  nonce: nonce,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;why-sqlcipher-plus-field-level-aes-gcm-&quot;&gt;Why SQLCipher Plus Field-Level AES-GCM?&lt;/h3&gt;
&lt;p&gt;A reasonable question is: if SQLCipher already encrypts database file at rest, why encrypt the password field again?&lt;/p&gt;
&lt;p&gt;The answer is separation of concerns.&lt;/p&gt;
&lt;p&gt;SQLCipher protects the database as a container. Field-level AES-GCM protects the most sensitive payloads as application-managed cryptographic objects with explicit integrity tags and versioned envelopes.&lt;/p&gt;
&lt;p&gt;That gives OneRule a few advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;clean migration path from old formats to new ones&lt;/li&gt;
&lt;li&gt;explicit tamper detection at field level&lt;/li&gt;
&lt;li&gt;easier reuse of encrypted credential payloads outside normal Flutter list rendering&lt;/li&gt;
&lt;li&gt;tighter control over what can be serialized or handed to native layers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Android Autofill integration benefits directly from this. More on that later.&lt;/p&gt;
&lt;h3 id=&quot;vault-migration-strategy&quot;&gt;Vault Migration Strategy&lt;/h3&gt;
&lt;p&gt;OneRule still contains migration support for older storage models.&lt;/p&gt;
&lt;p&gt;There are two migration stories in current codebase:&lt;/p&gt;
&lt;h4 id=&quot;1-legacy-vault-backend-migration&quot;&gt;1. Legacy vault backend migration&lt;/h4&gt;
&lt;p&gt;Older vault data could exist in Hive-based storage. Current architecture uses SQLCipher, and the app includes a one-time migration reader that imports legacy records into encrypted SQLite, then removes old box data and marks migration complete.&lt;/p&gt;
&lt;h4 id=&quot;2-legacy-cipher-format-migration&quot;&gt;2. Legacy cipher format migration&lt;/h4&gt;
&lt;p&gt;Password payloads and backup payloads have moved from older layouts into AES-GCM-based envelopes.&lt;/p&gt;
&lt;p&gt;For vault records, the app can still read:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;legacy CBC envelope format&lt;/li&gt;
&lt;li&gt;older marker-based GCM payloads&lt;/li&gt;
&lt;li&gt;older plaintext-style legacy values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On unlock, rows are scanned and migrated into current &lt;code&gt;or1:v2:gcm&lt;/code&gt; format. Migration is designed to be non-lossy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;read legacy payload&lt;/li&gt;
&lt;li&gt;decrypt or parse&lt;/li&gt;
&lt;li&gt;re-encrypt in latest format&lt;/li&gt;
&lt;li&gt;persist migrated value&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If crypto validation fails, migration hard-fails rather than overwriting questionable data.&lt;/p&gt;
&lt;p&gt;That is exactly what you want in security migration code. Silent best-effort conversion is dangerous when integrity matters.&lt;/p&gt;
&lt;h3 id=&quot;backup-format-and-cryptographic-container&quot;&gt;Backup Format and Cryptographic Container&lt;/h3&gt;
&lt;p&gt;Offline-first apps still need a portability story. Otherwise &quot;no sync&quot; quickly turns into &quot;single-device risk.&quot;&lt;/p&gt;
&lt;p&gt;OneRule handles that with encrypted backup export and import. Current export format is a JSON-based &lt;code&gt;.enc&lt;/code&gt; container with schema version &lt;code&gt;v: 3&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The backup pipeline looks like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;collect vault records&lt;/li&gt;
&lt;li&gt;serialize plaintext JSON&lt;/li&gt;
&lt;li&gt;derive encryption key from backup passphrase using PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;encrypt with AES-256-GCM using random nonce&lt;/li&gt;
&lt;li&gt;emit structured JSON container with metadata&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The container includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;KDF metadata&lt;/li&gt;
&lt;li&gt;cipher metadata&lt;/li&gt;
&lt;li&gt;salt&lt;/li&gt;
&lt;li&gt;authenticated encryption envelope&lt;/li&gt;
&lt;li&gt;creation timestamp&lt;/li&gt;
&lt;li&gt;item count&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a much better design than &quot;just encrypt some bytes and hope future versions remember how.&quot; Explicit metadata makes forward migration, compatibility handling, and debugging safer.&lt;/p&gt;
&lt;p&gt;Current backup writer emits a structured object like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;return {
  'v': _backupSchemaVersion,
  'envelope': {
    'format': 'onerule-backup',
    'version': 'v3',
    'algorithm': _gcmAlgorithmName,
    'nonce': base64UrlEncode(secretBox.nonce),
    'cipherText': base64UrlEncode(secretBox.cipherText),
    'tag': base64UrlEncode(secretBox.mac.bytes),
  },
  'salt': base64UrlEncode(salt),
  'kdf': {
    'algorithm': 'PBKDF2-HMAC-SHA256',
    'iterations': _pbkdf2Iterations,
    'keyBits': 256,
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That snippet captures design philosophy well: crypto parameters travel with container, not only with developer memory.&lt;/p&gt;
&lt;p&gt;Import path is also backward-compatible with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;current &lt;code&gt;.enc&lt;/code&gt; encrypted backups&lt;/li&gt;
&lt;li&gt;legacy &lt;code&gt;.onerule&lt;/code&gt; extension&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 2&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 1&lt;/code&gt;, including legacy CBC layout&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Again, the pattern here is versioned crypto container plus explicit readers, not hidden magic.&lt;/p&gt;
&lt;h3 id=&quot;panic-pin-and-privacy-flow&quot;&gt;Panic PIN and Privacy Flow&lt;/h3&gt;
&lt;p&gt;Panic mode is one of the more interesting product decisions because it crosses UX, threat modeling, and implementation details.&lt;/p&gt;
&lt;p&gt;OneRule supports a separate Panic PIN verifier. When this flow is used, the app avoids exposing the real vault and enters a privacy-focused decoy mode.&lt;/p&gt;
&lt;p&gt;There are two implementation details worth noting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;provider state sets panic mode and suppresses real vault data&lt;/li&gt;
&lt;li&gt;home screen renders decoy credential entries for that mode&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means panic mode is not implemented as &quot;delete everything&quot; or &quot;rename list to empty.&quot; It is an explicit alternate UI and state flow.&lt;/p&gt;
&lt;p&gt;This is a subtle but meaningful distinction. Security-sensitive UX should not depend on the user remembering whether a blank screen is real, broken, or intentional.&lt;/p&gt;
&lt;p&gt;The app also warns users that panic mode is a decoy flow and can be confusing if triggered accidentally. That kind of warning is good product engineering. Safety features that are easy to misunderstand become liabilities.&lt;/p&gt;
&lt;h3 id=&quot;android-autofill-boundary&quot;&gt;Android Autofill Boundary&lt;/h3&gt;
&lt;p&gt;Password managers live or die on ergonomics, and Android Autofill is one place where architecture decisions show up clearly.&lt;/p&gt;
&lt;p&gt;OneRule uses a native Android &lt;code&gt;AutofillService&lt;/code&gt; for API 26+ and bridges it from Flutter over a platform channel. The relevant boundary is important:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flutter owns normal vault UX and decrypted entry access after unlock&lt;/li&gt;
&lt;li&gt;Flutter syncs an &lt;strong&gt;encrypted credential snapshot&lt;/strong&gt; to native storage&lt;/li&gt;
&lt;li&gt;Flutter also provides a session key to native layer for active unlock window&lt;/li&gt;
&lt;li&gt;native Autofill service performs decryption only when it needs to fill a request&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means Autofill is not backed by a permanently stored plaintext cache. Instead, encrypted envelopes are synced to native side, and usable decryption depends on active session state.&lt;/p&gt;
&lt;p&gt;The platform methods reflect that design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;syncAutofillCredentialSnapshot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clearAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If session key is cleared on lock/logout, Autofill stops returning datasets.&lt;/p&gt;
&lt;p&gt;Flutter side bridge is intentionally thin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;Future&amp;lt;void&amp;gt; syncCredentialSnapshot({
  required String payload,
  required String sessionKeyBase64,
}) async {
  final state = await availability();
  if (state != AutofillMvpAvailability.available) {
    return;
  }
  await _bridge.setSessionKey(sessionKeyBase64);
  await _bridge.syncCredentialSnapshot(payload);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That sequence matters. Native side receives encrypted snapshot plus active session key only when feature is available and vault is in usable state.&lt;/p&gt;
&lt;p&gt;This is a strong boundary design because it minimizes plaintext residency and ties Autofill usefulness to vault unlock state rather than treating Autofill as a separate always-on data channel.&lt;/p&gt;
&lt;h3 id=&quot;locking-clipboard-and-leakage-controls&quot;&gt;Locking, Clipboard, and Leakage Controls&lt;/h3&gt;
&lt;p&gt;Security work is often ruined by small post-decrypt leaks rather than broken cryptography.&lt;/p&gt;
&lt;p&gt;OneRule includes several controls in this category:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;screenshot prevention and screen-capture leakage protection on mobile&lt;/li&gt;
&lt;li&gt;auto-lock timeout based on app lifecycle transitions&lt;/li&gt;
&lt;li&gt;clipboard auto-clear after configurable delay&lt;/li&gt;
&lt;li&gt;optional application of clipboard policy to usernames as well&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The lifecycle lock path is especially relevant in Flutter apps, where it is easy to focus on route state and forget process state. OneRule tracks pause/resume transitions and clears session state when idle time exceeds configured threshold.&lt;/p&gt;
&lt;p&gt;The relevant branch in lifecycle observer is simple and effective:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;if (diff.inSeconds &amp;gt; timeoutSeconds) {
  storage.clearSessionKey();
  unawaited(PlatformCredentialProvider().onVaultLocked());
  unawaited(context.read&amp;lt;PasswordProvider&amp;gt;().lockForSession());
  Navigator.of(context).pushAndRemoveUntil(
    MaterialPageRoute(builder: (_) =&amp;gt; const LoginScreen()),
    (_) =&amp;gt; false,
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is exactly kind of code I like seeing in security-sensitive mobile apps: short, direct, and tied to explicit state invalidation.&lt;/p&gt;
&lt;p&gt;That is not glamorous, but it is real security work.&lt;/p&gt;
&lt;h3 id=&quot;vault-health-analysis&quot;&gt;Vault Health Analysis&lt;/h3&gt;
&lt;p&gt;OneRule also includes a local &quot;Vault Health&quot; analyzer. This is not cryptography, but it is useful security-adjacent logic.&lt;/p&gt;
&lt;p&gt;The current report model scores the vault from 0 to 100 based on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;weak passwords&lt;/li&gt;
&lt;li&gt;exact-match duplicate passwords&lt;/li&gt;
&lt;li&gt;stale credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Duplicate detection uses local hash-based comparison logic rather than remote checks, which keeps analysis offline and aligned with rest of system design.&lt;/p&gt;
&lt;p&gt;This is a good example of how offline-first does not need to mean feature-light. You can still deliver useful security insights locally if the data model and processing path are designed for it.&lt;/p&gt;
&lt;h3 id=&quot;why-flutter-works-here&quot;&gt;Why Flutter Works Here&lt;/h3&gt;
&lt;p&gt;Security software is often assumed to require fully native implementation, but OneRule is a good example of when Flutter is enough for most of stack and native code is reserved for true platform edges.&lt;/p&gt;
&lt;p&gt;Flutter handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;application UI&lt;/li&gt;
&lt;li&gt;state management&lt;/li&gt;
&lt;li&gt;settings and backup flows&lt;/li&gt;
&lt;li&gt;vault rendering and search&lt;/li&gt;
&lt;li&gt;generator and health screens&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Native Android handles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Autofill service&lt;/li&gt;
&lt;li&gt;platform-specific storage and bridge responsibilities&lt;/li&gt;
&lt;li&gt;native crash logging hooks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a practical split.&lt;/p&gt;
&lt;p&gt;Flutter gives fast iteration and a unified codebase. Native code stays focused on surfaces where Android APIs are non-negotiable. For a solo-built security product, that balance can make a big difference in maintainability.&lt;/p&gt;
&lt;h3 id=&quot;threat-model-boundaries&quot;&gt;Threat Model Boundaries&lt;/h3&gt;
&lt;p&gt;Good security writeups should include limits, not only protections.&lt;/p&gt;
&lt;p&gt;OneRule is designed to defend against:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;offline access to copied app data without the derived key&lt;/li&gt;
&lt;li&gt;theft or exfiltration of encrypted database files&lt;/li&gt;
&lt;li&gt;tampering of AES-GCM protected vault fields and backups&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It is not designed to fully defend against:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rooted or fully compromised operating systems&lt;/li&gt;
&lt;li&gt;malware with runtime access while vault is unlocked&lt;/li&gt;
&lt;li&gt;PIN disclosure by user coercion, phishing, or keylogging&lt;/li&gt;
&lt;li&gt;clipboard interception before auto-clear executes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is a reasonable and honest scope. Offline encryption can do a lot. It cannot fix a hostile runtime after trust boundary is already lost.&lt;/p&gt;
&lt;h3 id=&quot;why-this-project-is-interesting&quot;&gt;Why This Project Is Interesting&lt;/h3&gt;
&lt;p&gt;A lot of apps claim privacy because they do not have obvious ads or trackers. OneRule is more interesting than that because privacy is expressed in structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;no backend dependency for core vault flows&lt;/li&gt;
&lt;li&gt;explicit key derivation&lt;/li&gt;
&lt;li&gt;versioned encrypted payloads&lt;/li&gt;
&lt;li&gt;layered storage protection&lt;/li&gt;
&lt;li&gt;local-only backup generation&lt;/li&gt;
&lt;li&gt;native Autofill integration without persistent plaintext snapshot model&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is what I like about the project technically. The product promise and implementation choices mostly line up.&lt;/p&gt;
&lt;p&gt;That is rarer than it should be.&lt;/p&gt;
&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;
&lt;p&gt;If you want to study a Flutter security app that takes local-first architecture seriously, OneRule is worth reading through.&lt;/p&gt;
&lt;p&gt;It is not trying to out-feature every cloud password manager. It is solving a narrower problem: how to build a usable password vault where local storage, transparent cryptographic boundaries, and offline operation are first-class constraints rather than marketing copy.&lt;/p&gt;
&lt;p&gt;Project links:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/seralifatih/OneRule&quot;&gt;https://github.com/seralifatih/OneRule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.fidevelopment.onerule&quot;&gt;https://play.google.com/store/apps/details?id=com.fidevelopment.onerule&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you build security software in Flutter, this kind of project is a useful reminder that &quot;mobile-first&quot; and &quot;security-conscious&quot; do not have to be opposites. With careful boundaries, versioned crypto payloads, and disciplined state handling, a local-first password manager can stay practical without outsourcing trust to infrastructure.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14676/building-onerule-technical-deep-dive-into-offline-password-manager-with-flutter-sqlcipher</guid>
<pubDate>Mon, 13 Apr 2026 12:48:19 +0000</pubDate>
</item>
<item>
<title>React Native Android Autolinking Issues: Common Problems and What to Check</title>
<link>https://coderlegion.com/14097/react-native-android-autolinking-issues-common-problems-and-what-to-check</link>
<description>&lt;p&gt;Autolinking in React Native is supposed to simplify native module integration—but when it breaks, it can be frustrating and time-consuming to debug.&lt;/p&gt;
&lt;p&gt;If your Android build is failing or a package isn’t being recognized, autolinking is often the root cause.&lt;/p&gt;
&lt;p&gt;Here are the most common issues and what you should check.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-package-not-properly-installed&quot;&gt;1. Package not properly installed&lt;/h3&gt;
&lt;p&gt;Before diving deeper, make sure the dependency is correctly installed.&lt;/p&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;package.json&lt;/code&gt; includes the library&lt;/li&gt;
&lt;li&gt;&lt;code&gt;node_modules&lt;/code&gt; contains it&lt;/li&gt;
&lt;li&gt;You’ve run:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install
# or
yarn install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If something feels off, delete and reinstall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rm -rf node_modules
npm install
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-missing-or-outdated-gradle-sync&quot;&gt;2. Missing or outdated Gradle sync&lt;/h3&gt;
&lt;p&gt;Autolinking depends on Gradle picking up native modules.&lt;/p&gt;
&lt;p&gt;Try:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd android
./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then rebuild the project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native run-android
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-incorrect-react-native-cli-detection&quot;&gt;3. Incorrect React Native CLI detection&lt;/h3&gt;
&lt;p&gt;Sometimes the React Native CLI doesn’t properly detect the module.&lt;/p&gt;
&lt;p&gt;Run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx react-native config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check if your dependency appears under:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dependencies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;platforms.android&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If it’s missing, autolinking is not working correctly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-manual-linking-leftovers&quot;&gt;4. Manual linking leftovers&lt;/h3&gt;
&lt;p&gt;If the project was upgraded or previously used manual linking, conflicts can occur.&lt;/p&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MainApplication.java&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;settings.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;app/build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Remove any old manual linking entries.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-incompatible-library-version&quot;&gt;5. Incompatible library version&lt;/h3&gt;
&lt;p&gt;Some libraries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;don’t support your React Native version&lt;/li&gt;
&lt;li&gt;require additional setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Always verify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the library’s documentation&lt;/li&gt;
&lt;li&gt;compatibility with your RN version&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-android-specific-configuration-missing&quot;&gt;6. Android-specific configuration missing&lt;/h3&gt;
&lt;p&gt;Some packages still require manual steps.&lt;/p&gt;
&lt;p&gt;Look for instructions like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;adding permissions in &lt;code&gt;AndroidManifest.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;modifying &lt;code&gt;build.gradle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;adding custom native code&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Autolinking doesn’t cover everything.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-caching-issues&quot;&gt;7. Caching issues&lt;/h3&gt;
&lt;p&gt;React Native and Gradle caching can cause stale builds.&lt;/p&gt;
&lt;p&gt;Try clearing everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;watchman watch-del-all
rm -rf node_modules
rm -rf android/build
rm -rf android/app/build
npm install
cd android &amp;amp;&amp;amp; ./gradlew clean
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-9&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;Autolinking issues usually come down to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;installation problems&lt;/li&gt;
&lt;li&gt;Gradle not syncing correctly&lt;/li&gt;
&lt;li&gt;leftover manual configurations&lt;/li&gt;
&lt;li&gt;version incompatibilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of guessing, go step by step through these checks—you’ll usually find the issue faster.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you’re working a lot with React Native Android builds, you’ll notice most errors fall into a few recurring patterns like these.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/14097/react-native-android-autolinking-issues-common-problems-and-what-to-check</guid>
<pubDate>Mon, 06 Apr 2026 16:56:36 +0000</pubDate>
</item>
</channel>
</rss>