<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Attay Rasool</title>
    <description>The latest articles on DEV Community by Attay Rasool (@attayr).</description>
    <link>https://dev.to/attayr</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4163876%2F3264720a-aa1c-4006-8ee3-825c21b3e4a8.jpg</url>
      <title>DEV Community: Attay Rasool</title>
      <link>https://dev.to/attayr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/attayr"/>
    <language>en</language>
    <item>
      <title>Trail Notebook: a phone that hears the birds and writes your field notes, with no signal</title>
      <dc:creator>Attay Rasool</dc:creator>
      <pubDate>Wed, 07 Oct 2026 20:03:34 +0000</pubDate>
      <link>https://dev.to/attayr/trail-notebook-a-phone-that-hears-the-birds-and-writes-your-field-notes-with-no-signal-5h9p</link>
      <guid>https://dev.to/attayr/trail-notebook-a-phone-that-hears-the-birds-and-writes-your-field-notes-with-no-signal-5h9p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At home, airplane mode on. I hold my phone near a second device playing a Yellow-vented Bulbul call from YouTube, and tap Listen. About 11 seconds later (9 of them listening) the screen says &lt;strong&gt;Yellow-vented Bulbul, very likely&lt;/strong&gt;. Then a language model running on the phone writes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yellow-vented Bulbul is clearly audible, a steady, melodic chirp.&lt;br&gt;
&lt;strong&gt;Next:&lt;/strong&gt; Walk to the corner of the garden, observe the area.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To be clear, this was an indoor test with a recording, not a real bird on a trail. But it proves the part I cared about most: the whole loop runs with no network at all, which matters because trails are exactly where the signal drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Trail Notebook&lt;/strong&gt; is an offline field notebook for walks. You hear a bird, take the phone out, and tap one button. It listens for 9 seconds, buzzes, and names the species. Then Gemma, running on the phone, writes a two-line entry: a short &lt;strong&gt;NOTE&lt;/strong&gt; about the moment and a &lt;strong&gt;NEXT&lt;/strong&gt; line telling you what to look or listen for.&lt;/p&gt;

&lt;p&gt;Then the phone goes back in your pocket. One button, no feed, no chat box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;748 MB downloaded once. 282 ms from end of listening to species. 39 tokens/s from Gemma. Zero network after that.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Listen mode:&lt;/strong&gt; BirdNET V2.4 (6,522 classes) runs on the phone. You see the top species and up to two alternates, with a confidence word ("very likely", "likely", "possible") instead of a raw score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell-it mode:&lt;/strong&gt; you saw something, or heard something it can't name? Type "three crows chasing a hawk over the canal". Gemma writes the same NOTE and NEXT, and the prompt tells it not to name a species you didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journal:&lt;/strong&gt; every entry is saved locally with the time, an optional spot name, a rough location (rounded to about 1 km) and an "offline" stamp if it was written without a network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After a one-time download at home, nothing in this loop touches the network.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2a2w8h1woqy5swxrq99.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2a2w8h1woqy5swxrq99.png" alt="Trail Notebook result card on a Galaxy S23 Ultra in airplane mode: Yellow-vented Bulbul, very likely, with a Gemma note and Next line" width="720" height="1454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The airplane-mode test on my Samsung Galaxy S23 Ultra. The spot field said "Back garden", which is where Gemma got "the garden" from. It only used facts it was given.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucgrhp6onhm18ww61bwl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucgrhp6onhm18ww61bwl.gif" alt="Trail Notebook demo" width="360" height="731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 16-second screen recording from a separate indoor run on the S23 Ultra: Listen, countdown, Yellow-vented Bulbul "likely", Gemma streams the note, "Saved to journal · 12.8 s" (including the 9 seconds of listening). &lt;a href="https://github.com/AttayR/trail-notebook/blob/main/docs/demo/trail-notebook-demo.mp4" rel="noopener noreferrer"&gt;Full-quality MP4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it:&lt;/strong&gt; &lt;a href="https://github.com/AttayR/trail-notebook/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;Android APK, v0.1.0&lt;/a&gt; (arm64, 134 MB). The models are not in the APK. The app downloads them on first launch, so use Wi-Fi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/AttayR" rel="noopener noreferrer"&gt;
        AttayR
      &lt;/a&gt; / &lt;a href="https://github.com/AttayR/trail-notebook" rel="noopener noreferrer"&gt;
        trail-notebook
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Offline birdsong field notebook: BirdNET + Gemma 3 1B running fully on-device in React Native. DEV Hacktoberfest 2026 Touch Grass.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Trail Notebook&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;An offline field notebook for walks. You hear a bird, hold your phone up for nine seconds, and &lt;strong&gt;BirdNET&lt;/strong&gt; identifies the song on the phone. A small open-weight language model, &lt;strong&gt;Gemma 3 1B&lt;/strong&gt;, then writes a short journal entry and one thing to look or listen for next. If you would rather describe what you saw ("three crows chasing a hawk over the canal"), type it, and Gemma writes the entry from that.&lt;/p&gt;
&lt;p&gt;After a one-time download, everything runs on the phone. There are no accounts, no servers and no network calls, so it works in airplane mode on a trail with no signal.&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest 2026 "Touch Grass" open-source AI challenge.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;LISTEN   mic, 9 s @ 48 kHz mono float32 (react-native-audio-api)
           -&amp;gt; 5 windows of 3 s, 1.5 s hop
           -&amp;gt; BirdNET V2.4 FP16 TFLite (react-native-fast-tflite, CPU)   6,522 species
           -&amp;gt;&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/AttayR/trail-notebook" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The README covers setup, the model download, and the license notices for both models. The pure TypeScript logic (audio windowing, BirdNET post-processing, prompt building, output parsing) lives in &lt;code&gt;src/core/&lt;/code&gt; with no React Native imports and is covered by 82 unit tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; Expo SDK 57, React Native 0.86 (New Architecture), TypeScript, Expo Router. It needs a development build, not Expo Go, because of the native modules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bird sound ID:&lt;/strong&gt; &lt;a href="https://github.com/birdnet-team/BirdNET-Analyzer" rel="noopener noreferrer"&gt;BirdNET V2.4&lt;/a&gt;, FP16 TFLite (25.9 MB), run with &lt;a href="https://github.com/mrousavy/react-native-fast-tflite" rel="noopener noreferrer"&gt;&lt;code&gt;react-native-fast-tflite&lt;/code&gt;&lt;/a&gt; on the CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio:&lt;/strong&gt; &lt;a href="https://docs.swmansion.com/react-native-audio-api/" rel="noopener noreferrer"&gt;&lt;code&gt;react-native-audio-api&lt;/code&gt;&lt;/a&gt; gives raw Float32 PCM. The S23 Ultra delivers 48 kHz natively, so no resampling is needed there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writer:&lt;/strong&gt; Gemma 3 1B IT, Q4_0 GGUF (722 MB), run with &lt;a href="https://github.com/mybigday/llama.rn" rel="noopener noreferrer"&gt;&lt;code&gt;llama.rn&lt;/code&gt;&lt;/a&gt; 0.12.9 (llama.cpp under the hood). Journal in &lt;code&gt;expo-sqlite&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; mic, 9 s @ 48 kHz (Float32 PCM)
        |
 resample if needed -&amp;gt; clamp to [-1, 1]
        |
 5 windows x 3 s (1.5 s hop)
        |
 BirdNET V2.4 FP16 TFLite  -&amp;gt;  6,522 logits per window
        |
 sigmoid, max per species over windows, drop non-bird classes, top 3
        |                                    \
        |                                     -&amp;gt; species card (shown right away)
 prompt: time, season, spot, species as words
        |
 Gemma 3 1B Q4_0 (llama.rn, streamed)  -&amp;gt;  NOTE + NEXT
        |
 SQLite journal (stamped offline / online)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The species card appears before Gemma starts writing, so you never wait on the language model to learn what you heard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Five windows, one answer
&lt;/h3&gt;

&lt;p&gt;BirdNET takes exactly 3 seconds of audio at 48 kHz (144,000 samples). A bird rarely sings for exactly those 3 seconds, so the app slides five overlapping windows across the 9-second clip and keeps each species' best score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/core/birdnet/scores.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;maxOverWindows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;windowLogits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;sensitivity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Float32Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;windowLogits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;windowLogits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logits&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;windowLogits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;window outputs differ in length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sigmoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;sensitivity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scores of 0.8 or more read "very likely", 0.5 "likely", 0.15 "possible". If nothing reaches 0.5, the card says "Nothing clear" and offers tell-it mode instead of guessing.&lt;/p&gt;

&lt;p&gt;One bug worth sharing: &lt;code&gt;react-native-fast-tflite&lt;/code&gt; reuses its output buffer on every &lt;code&gt;run()&lt;/code&gt;, so my per-window &lt;code&gt;Float32Array&lt;/code&gt; views all pointed at the last window's scores. Copying with &lt;code&gt;out.slice(0)&lt;/code&gt; fixed it, and the app now matches a desktop LiteRT reference exactly on a test clip (Black-capped Chickadee 0.815).&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping a 1B model honest
&lt;/h3&gt;

&lt;p&gt;Gemma gets a short facts block (time, season, spot, what BirdNET heard, species already logged today) and is asked for exactly two lines. In the first run it echoed my instructions back instead. The fix: apply Gemma's chat template by hand and &lt;strong&gt;prefill the model's turn&lt;/strong&gt;, so it starts inside the answer. In listen mode the prefill includes the species:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/core/llm/gemmaFormat.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOTE_PREFILL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NOTE:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listenPrefill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topCommonName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;topCommonName&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;NOTE_PREFILL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topCommonName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NOTE_PREFILL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toGemmaPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;prefill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NOTE_PREFILL&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Gemma has no system role: fold any system text into the first user turn.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;start_of_turn&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;end_of_turn&amp;gt;\n`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;start_of_turn&amp;gt;model\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefill&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I tuned the prompt against the real model: a script ran the app's own prompt builders through llama.cpp with the same GGUF and sampling settings, over fixed cases and several seeds, and scored each output automatically.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;Format OK&lt;/th&gt;
&lt;th&gt;Invented details (trees, nests...)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original prompt&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shipped prompt + species prefill&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before: "Look for movement in the oak trees" (there were no oak trees in the facts). After: "Dark-eyed Junco was heard, chirping steadily." / Next: "Walk to the edge of the field, carefully observing."&lt;/p&gt;

&lt;p&gt;The trade-off: the notes are grounded but plain. Asking for more vivid writing made the 1B model invent branches and nests. For a notebook that should record only what you heard, I chose grounded.&lt;/p&gt;

&lt;p&gt;If Gemma is slow or fails, a template note is saved after a 30-second soft timeout, and Gemma's text replaces it when it finishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers from the phone
&lt;/h3&gt;

&lt;p&gt;Measured on a &lt;strong&gt;Samsung Galaxy S23 Ultra&lt;/strong&gt; (Snapdragon 8 Gen 2, Android 16), debug build, from the app's metrics export. It's a flagship, so this is a best case.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;S23 Ultra (median)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BirdNET per 3 s window&lt;/td&gt;
&lt;td&gt;53 ms (p90 60 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture end to species on screen&lt;/td&gt;
&lt;td&gt;282 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma load&lt;/td&gt;
&lt;td&gt;0.9 s (2.2 s first cold load)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma time to first token&lt;/td&gt;
&lt;td&gt;0.83 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma decode&lt;/td&gt;
&lt;td&gt;39 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One note (17 to 68 tokens)&lt;/td&gt;
&lt;td&gt;0.5 to 2.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mic start-up&lt;/td&gt;
&lt;td&gt;about 180 ms (9 s capture took 9.18 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemma is configured with &lt;code&gt;n_gpu_layers: 0&lt;/code&gt; on Android, although llama.rn reports a GPU device. I haven't confirmed yet whether OpenCL takes any of the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  What didn't work (yet)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-bird classes leaked into alternates.&lt;/strong&gt; The bulbul screenshot lists "Human whistle". BirdNET's 11 environmental classes (Dog, Siren...) are now filtered from alternates and from Gemma's prompt. If one clearly wins, the card says it didn't catch a bird.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No location filter.&lt;/strong&gt; BirdNET can suggest a species that doesn't live where you are, which is why the card shows alternates and confidence words. BirdNET's location model is the next step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The APK is 134 MB&lt;/strong&gt; with no models inside, mostly llama.rn's several CPU-specific native builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Battery and RAM aren't measured yet.&lt;/strong&gt; The phone was on USB power for every run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Each point below is something a closed, cloud-only API could not do for this app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It works where the signal stops.&lt;/strong&gt; Both models are files on the phone, so Listen, the species card, the Gemma note and the journal all run in airplane mode. I checked that on the S23 Ultra: the bulbul was named and the note written with no network. (The debug build loads its JavaScript from my laptop; the models and inference were all on the phone.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Your audio never leaves the phone.&lt;/strong&gt; The 9-second recording is analysed in memory and thrown away, never saved or uploaded. Location is optional, rounded to about 1 km, and stays in the local journal. A cloud bird-ID API would need your microphone audio and location on someone else's machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It costs nothing per use.&lt;/strong&gt; You pay one download: 748 MB in total. On my home Wi-Fi, Gemma's 722 MB took 325 seconds and BirdNET took 12.8 seconds. After that, every listen and every note is free, with no API key and no rate limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The model is a setting, not a contract.&lt;/strong&gt; Gemma is one entry in &lt;code&gt;src/config.ts&lt;/code&gt;: URL, filename and exact byte size. The app already switches to Gemma 3 270M (292 MB) with one environment variable. A newer Gemma, or one fine-tuned on field-journal writing, is a config change behind a small &lt;code&gt;NoteWriter&lt;/code&gt; interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Open means I can check it.&lt;/strong&gt; The downloaded Gemma file's SHA-256 matches its Hugging Face listing. The BirdNET file is byte-identical to the one in the official Zenodo release. Because the weights and the prompt format are open, I could see the echo problem, fix it with a prefill, and measure the fix on the same model the phone runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Licenses
&lt;/h3&gt;

&lt;p&gt;"Open" doesn't mean the same thing for both models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BirdNET V2.4&lt;/strong&gt; is open-weight but &lt;strong&gt;non-commercial&lt;/strong&gt; (CC BY-NC-SA 4.0). Trail Notebook is free and non-commercial, and the weights are downloaded at first run, not bundled in the repo or the APK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 3&lt;/strong&gt; is open-weight under the Gemma Terms of Use. &lt;em&gt;Gemma is provided under and subject to the Gemma Terms of Use found at &lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;ai.google.dev/gemma/terms&lt;/a&gt;.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;The app code is MIT.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Credits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BirdNET:&lt;/strong&gt; Stefan Kahl, Connor M. Wood, Maximilian Eibl and Holger Klinck; K. Lisa Yang Center for Conservation Bioacoustics (Cornell Lab of Ornithology) and Chemnitz University of Technology. Model DOI &lt;a href="https://doi.org/10.5281/zenodo.15050749" rel="noopener noreferrer"&gt;10.5281/zenodo.15050749&lt;/a&gt;. CC BY-NC-SA 4.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 3 1B IT:&lt;/strong&gt; Google DeepMind, &lt;a href="https://ai.google.dev/gemma/terms" rel="noopener noreferrer"&gt;Gemma Terms of Use&lt;/a&gt;. GGUF conversion by &lt;a href="https://huggingface.co/unsloth/gemma-3-1b-it-GGUF" rel="noopener noreferrer"&gt;Unsloth&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mirrors:&lt;/strong&gt; the unzipped TFLite file from &lt;a href="https://github.com/woheller69/whoBIRD-TFlite" rel="noopener noreferrer"&gt;whoBIRD-TFlite&lt;/a&gt; (woheller69), labels from &lt;a href="https://huggingface.co/tphakala/BirdNET-v2.4" rel="noopener noreferrer"&gt;tphakala/BirdNET-v2.4&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Libraries:&lt;/strong&gt; llama.cpp and llama.rn (MIT), react-native-fast-tflite (MIT), react-native-audio-api by Software Mansion (MIT).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prior art:&lt;/strong&gt; &lt;a href="https://github.com/birdnet-team/birdnet-live-app" rel="noopener noreferrer"&gt;BirdNET Live&lt;/a&gt; and &lt;a href="https://github.com/woheller69/whoBIRD" rel="noopener noreferrer"&gt;whoBIRD&lt;/a&gt; already do offline bird ID on phones. Trail Notebook adds the journal, the NEXT nudge and the React Native stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Field test: coming this weekend
&lt;/h2&gt;

&lt;p&gt;Everything above was tested indoors. The plan for this weekend is to take Trail Notebook outside with the phone unplugged and in airplane mode, and log real birds through the phone mic. I want to know three things: how often the species matches what I can confirm, how the notes read for real moments, and how much battery a walk costs.&lt;/p&gt;

&lt;p&gt;I'll update this post with what happened, including what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Gemma:&lt;/strong&gt; Gemma 3 1B IT runs fully on the phone through llama.rn and writes every journal entry and NEXT nudge, online or offline. The prompt was tuned on the same GGUF the phone runs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>reactnative</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I scanned 23 open-source Expo apps before they hit the App Store. Here's what a pre-flight check caught.</title>
      <dc:creator>Attay Rasool</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:18:49 +0000</pubDate>
      <link>https://dev.to/attayr/i-scanned-23-open-source-expo-apps-before-they-hit-the-app-store-heres-what-a-pre-flight-check-fb6</link>
      <guid>https://dev.to/attayr/i-scanned-23-open-source-expo-apps-before-they-hit-the-app-store-heres-what-a-pre-flight-check-fb6</guid>
      <description>&lt;p&gt;Every Expo team has the same Friday afternoon: you run &lt;code&gt;eas build&lt;/code&gt;, wait twenty minutes, and the build fails on something that was knowable before you started. Or worse, the build succeeds, the store review rejects it three days later, and the reason was a missing line in &lt;code&gt;app.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;expo-doctor&lt;/code&gt; checks your dependencies. Nothing I could find checks the things that actually get builds and reviews rejected: usage strings, permissions, &lt;code&gt;eas.json&lt;/code&gt;, update channels, committed secrets. So I built a small CLI to do it, and then pointed it at real apps to see whether the problems are real.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo-preflight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads &lt;code&gt;app.json&lt;/code&gt; / &lt;code&gt;app.config.*&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;eas.json&lt;/code&gt; and &lt;code&gt;.gitignore&lt;/code&gt;, never runs your app and never prints secret values. It's MIT licensed: &lt;a href="https://github.com/AttayR/expo-preflight" rel="noopener noreferrer"&gt;https://github.com/AttayR/expo-preflight&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;I ran it, read-only, against 23 apps from 19 popular open-source repos (boilerplates, Expo's own examples, and real apps with thousands of stars). I cloned them, ran the tool, and did not open issues or PRs on any of them. No names below: these are mostly well-maintained projects and the point is the pattern, not the people.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong first
&lt;/h2&gt;

&lt;p&gt;My first run produced 36 errors and 56 warnings, and roughly half were wrong. I read the findings against the real configs and found the tool was noisy in predictable ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It flagged the standard React Native &lt;code&gt;debug.keystore&lt;/code&gt; as a leaked secret.&lt;/li&gt;
&lt;li&gt;It didn't understand EAS server-side environments or &lt;code&gt;process.env.X ?? "default"&lt;/code&gt;, so it reported env vars as undefined.&lt;/li&gt;
&lt;li&gt;It only read the app folder's &lt;code&gt;.gitignore&lt;/code&gt;, so monorepos looked like they ignored nothing.&lt;/li&gt;
&lt;li&gt;Its Android permission table was missing things like &lt;code&gt;WRITE_CALENDAR&lt;/code&gt; for &lt;code&gt;expo-calendar&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the unglamorous lesson: a linter you can't trust gets ignored. I fixed all of these and wrote regression tests for each (72 tests now), and the error+warning count on the same 23 apps dropped from 83 to 29 (7 errors and 22 warnings on the published 0.1.2; 13 of the 23 apps still had at least one finding). The findings that survived are the ones below, and I'll be upfront that it is still not perfect: I spot-checked six of them against the real code, and four were right and two were wrong (see "Limits").&lt;/p&gt;

&lt;h2&gt;
  
  
  What was real
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Missing iOS usage strings (4 of the 6 apps with &lt;code&gt;expo-image-picker&lt;/code&gt; or &lt;code&gt;expo-media-library&lt;/code&gt; installed, on both 0.1.1 and the stricter 0.1.2).&lt;/strong&gt;&lt;br&gt;
Apps using &lt;code&gt;expo-image-picker&lt;/code&gt; or &lt;code&gt;expo-media-library&lt;/code&gt; without the matching &lt;code&gt;NSPhotoLibraryUsageDescription&lt;/code&gt;, &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; or &lt;code&gt;NSPhotoLibraryAddUsageDescription&lt;/code&gt;. Without them iOS terminates the app when the picker opens, and review can reject it. This is the classic one because it's invisible in development builds that use Expo Go's own strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A tracked &lt;code&gt;.env&lt;/code&gt; in git (2 of 23 apps).&lt;/strong&gt;&lt;br&gt;
Two apps commit a &lt;code&gt;.env&lt;/code&gt;. In one it holds only a public Sentry DSN, which is low risk, but &lt;code&gt;.gitignore&lt;/code&gt; didn't ignore &lt;code&gt;.env&lt;/code&gt;, so the next real secret goes in the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Update channels that don't exist (2 of the 6 apps with both &lt;code&gt;expo-updates&lt;/code&gt; and an &lt;code&gt;eas.json&lt;/code&gt;).&lt;/strong&gt;&lt;br&gt;
One app had &lt;code&gt;expo-updates&lt;/code&gt; installed, but its &lt;code&gt;preview&lt;/code&gt; and &lt;code&gt;production&lt;/code&gt; build profiles never set a &lt;code&gt;channel&lt;/code&gt;. Those builds can never receive an EAS Update. Nothing fails; the OTA pipeline just silently does nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;cli.appVersionSource&lt;/code&gt; unset (4 of the 13 apps with an &lt;code&gt;eas.json&lt;/code&gt;).&lt;/strong&gt;&lt;br&gt;
Four apps leave it unset, which leaves it ambiguous where the app version comes from (EAS recommends setting it explicitly).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Play-restricted permissions (1 of 23).&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;REQUEST_INSTALL_PACKAGES&lt;/code&gt; declared in &lt;code&gt;app.json&lt;/code&gt; needs a Play Console declaration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this tells me
&lt;/h2&gt;

&lt;p&gt;Almost none of this is exotic. It's configuration that's correct in each file and wrong across files: a plugin in &lt;code&gt;app.json&lt;/code&gt; that doesn't match a package in &lt;code&gt;package.json&lt;/code&gt;, a profile in &lt;code&gt;eas.json&lt;/code&gt; that doesn't match an &lt;code&gt;expo-updates&lt;/code&gt; install. Humans are bad at cross-file consistency and machines are good at it, which is the entire argument for a pre-flight check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo-preflight            &lt;span class="c"&gt;# pretty output&lt;/span&gt;
npx expo-preflight &lt;span class="nt"&gt;--format&lt;/span&gt; github   &lt;span class="c"&gt;# Markdown for a PR comment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--format github&lt;/code&gt; prints the report as Markdown, ready to paste into a PR comment. Rules can be toggled or re-leveled in &lt;code&gt;.preflightrc.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It's static analysis. For &lt;code&gt;app.config.js/ts&lt;/code&gt; it extracts values best-effort rather than evaluating them, and says so.&lt;/li&gt;
&lt;li&gt;"Package never imported" detection is a regex over source files, not a full analysis.&lt;/li&gt;
&lt;li&gt;Precision wasn't where I wanted it. In a six-finding spot check of the published 0.1.1, 4 were correct. Both wrong ones were iOS usage-string errors raised because a package is &lt;em&gt;installed&lt;/em&gt;, not because the sensitive API is &lt;em&gt;called&lt;/em&gt; (an app using only the accelerometer doesn't need a motion string; one that only opens the photo library never needs the camera string). Version 0.1.2 fixes this for the packages where I mapped the triggering APIs (image picker, camera, media library, location, sensors): it now errors only when the API is actually called, and downgrades the rest to info. Other packages (contacts, local authentication, BLE and so on) are still flagged just for being installed; mapping those is next. Those two findings flipped to info on re-scan, and none of the real ones disappeared. That's still only six spot-checks, not a benchmark.&lt;/li&gt;
&lt;li&gt;23 apps is a small sample, and six spot-checks is smaller. Treat the proportions as indicative, not statistics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it flags something wrong on your project, please open an issue with the (redacted) config. That's how 0.1.1 and 0.1.2 got better, by running the tool on real code, and I'd rather fix a false positive than have it erode trust.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/AttayR/expo-preflight" rel="noopener noreferrer"&gt;https://github.com/AttayR/expo-preflight&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>expo</category>
    </item>
  </channel>
</rss>
