<?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: ROCI</title>
    <description>The latest articles on DEV Community by ROCI (@rocisapps).</description>
    <link>https://dev.to/rocisapps</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%2F1115082%2F0cbca8f4-907b-4522-a217-4120285c6926.jpg</url>
      <title>DEV Community: ROCI</title>
      <link>https://dev.to/rocisapps</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rocisapps"/>
    <language>en</language>
    <item>
      <title>Shalom Home: my grandpa's phone launcher, rebuilt to stop annoying him.</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sun, 04 Oct 2026 12:38:15 +0000</pubDate>
      <link>https://dev.to/rocisapps/shalom-home-my-grandpas-phone-launcher-rebuilt-to-stop-annoying-him-3o6j</link>
      <guid>https://dev.to/rocisapps/shalom-home-my-grandpas-phone-launcher-rebuilt-to-stop-annoying-him-3o6j</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;My grandfather Shalom calls me a lot. Usually it is not to chat. It is because his phone moved something.&lt;/p&gt;

&lt;p&gt;His WhatsApp icon "jumped" to another page. The shortcut he uses to call my mom, his daughter, disappeared. He did nothing wrong: a long press that lasted a second too long, a swipe that started on an icon instead of the background. Android home screens are built for people who know that icons can be dragged, deleted and stacked into folders. Shalom does not know that, and he should not have to.&lt;/p&gt;

&lt;p&gt;So I built him &lt;strong&gt;Shalom Home&lt;/strong&gt;: an Android launcher (the app that &lt;em&gt;is&lt;/em&gt; your home screen) built on three ideas.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nothing moves.&lt;/strong&gt; One page of big tiles: WhatsApp, Photos, the people he calls. No dragging, no deleting, no folders, no accidental pages. Every change happens in a setup screen hidden behind five taps on the clock and a PIN that only I know. Three wrong PINs lock it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;He can just ask.&lt;/strong&gt; A big button at the bottom says "דבר איתי" ("talk to me"). He says "תתקשר לבת שלי" ("call my daughter"), or in French, "Appelle ma fille", and the phone does it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It never surprises him.&lt;/strong&gt; Every voice action shows a 3-second countdown with a giant red "ביטול" (cancel) button and says out loud what it is about to do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Around that core, the things he is used to still work, just without the chaos:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Up to 30 people he can call by voice&lt;/strong&gt;, each with the words &lt;em&gt;he&lt;/em&gt; uses for them ("הבת שלי", "ma fille", a nickname).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;His own wallpaper&lt;/strong&gt; shows behind the tiles, so the grandkids' photo stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second page&lt;/strong&gt; for the Contacts "Direct dial" shortcuts and widgets he relies on. I add them from a picker with real previews. If any app tries to put something on his screen, it waits for my approval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup in Hebrew or English&lt;/strong&gt;, so anyone in the family can manage it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/ejpUQlZ4W6c" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A 36-second walkthrough: the locked home screen, a Hebrew voice request with its countdown, the second page, and the PIN-protected setup.&lt;/em&gt; The APK is on the &lt;a href="https://github.com/RoeeIlouz/shalom-home/releases/tag/v1.0" rel="noopener noreferrer"&gt;GitHub release page&lt;/a&gt; if you want to try it on an Android phone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Home, on his wallpaper&lt;/th&gt;
&lt;th&gt;Second page&lt;/th&gt;
&lt;th&gt;Adding a widget&lt;/th&gt;
&lt;th&gt;Setup, in Hebrew&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&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%2F8pvk7tlgpte841vmnriw.png" alt="Home" width="800" height="1778"&gt;&lt;/td&gt;
&lt;td&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%2Fe78z6tytnqvo01gagodw.png" alt="Second page" width="800" height="1778"&gt;&lt;/td&gt;
&lt;td&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%2F8e6wi9w1vnq5zvck4qqy.png" alt="Widget picker" width="800" height="1778"&gt;&lt;/td&gt;
&lt;td&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%2F90i8mrm89r5o0vde7lsu.png" alt="Setup" width="800" height="1778"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Results on my test phrases
&lt;/h3&gt;

&lt;p&gt;I generated Hebrew and French test phrases with a neural TTS voice and ran them through the full pipeline, with a test contact called "Michal" (number 000) whom he calls "הבת שלי", my daughter. In test mode the app only logs what it would do; it never dials.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;He says&lt;/th&gt;
&lt;th&gt;Whisper heard&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;תתקשר למיכל (call Michal)&lt;/td&gt;
&lt;td&gt;תתקשר למי חל&lt;/td&gt;
&lt;td&gt;Michal, keywords&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;אני רוצה לדבר עם הבת שלי (I want to talk to my daughter)&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;Michal, keywords&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;תחייג לילדה (dial the girl)&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;Michal, Gemma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appelle ma fille (call my daughter)&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;Michal, Gemma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;איפה התמונות שלי (where are my photos)&lt;/td&gt;
&lt;td&gt;איפות מונות שלי&lt;/td&gt;
&lt;td&gt;Photos, keywords&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;מה השעה עכשיו (what time is it)&lt;/td&gt;
&lt;td&gt;מה שעכשיו&lt;/td&gt;
&lt;td&gt;nothing: Gemma decided it is not a request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the emulator, keyword requests finish in about a second and Gemma requests in 2 to 6 seconds. On a real Samsung phone it is pretty fast.&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/RoeeIlouz" rel="noopener noreferrer"&gt;
        RoeeIlouz
      &lt;/a&gt; / &lt;a href="https://github.com/RoeeIlouz/shalom-home" rel="noopener noreferrer"&gt;
        shalom-home
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A locked, voice-driven Android home screen for my grandpa. Whisper + Gemma run on-device, in Hebrew.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;
  &lt;tbody&gt;
  &lt;tr&gt;
    &lt;th&gt;ontology&lt;/th&gt;
    &lt;td&gt;true&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;th&gt;type&lt;/th&gt;
    &lt;td&gt;readme&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;th&gt;domain&lt;/th&gt;
    &lt;td&gt;saba-home&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;th&gt;status&lt;/th&gt;
    &lt;td&gt;active&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;th&gt;tags&lt;/th&gt;
    &lt;td&gt;
&lt;table&gt;
  &lt;tbody&gt;
  &lt;tr&gt;
  &lt;td&gt;&lt;div&gt;android&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;launcher&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;whisper&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;llama-cpp&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;gemma&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;accessibility&lt;/div&gt;&lt;/td&gt;
  &lt;td&gt;&lt;div&gt;hacktoberfest&lt;/div&gt;&lt;/td&gt;
  &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;


&lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;th&gt;summary&lt;/th&gt;

    &lt;td&gt;Locked, voice-driven Android home screen for an elderly grandparent, with on-device Whisper and Gemma&lt;/td&gt;

  &lt;/tr&gt;

  &lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Shalom Home&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A home screen for my grandfather Shalom's Android phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/RoeeIlouz/shalom-home/releases/tag/v1.0" rel="noopener noreferrer"&gt;Download the APK and the demo video (v1.0)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;He kept calling me because a shortcut had moved to another page, or because the button that calls my mom had disappeared. Shalom Home replaces his launcher with one page of big tiles he cannot drag, delete or swipe away, plus a "דבר איתי" ("talk to me") button: he says what he wants in Hebrew or French, and the phone does it.&lt;/p&gt;

&lt;p&gt;All speech recognition and understanding runs on the phone with open-weight models. His voice and his contacts never leave the device, and nothing needs an internet connection after setup.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Home&lt;/th&gt;

&lt;th&gt;Second page&lt;/th&gt;

&lt;th&gt;Widget picker&lt;/th&gt;

&lt;th&gt;Setup (Hebrew)&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/RoeeIlouz/shalom-home/docs/screenshots/home.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FRoeeIlouz%2Fshalom-home%2FHEAD%2Fdocs%2Fscreenshots%2Fhome.png" alt="Home"&gt;&lt;/a&gt;&lt;/td&gt;

&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/RoeeIlouz/shalom-home/docs/screenshots/second-page.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FRoeeIlouz%2Fshalom-home%2FHEAD%2Fdocs%2Fscreenshots%2Fsecond-page.png" alt="Second page"&gt;&lt;/a&gt;&lt;/td&gt;

&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/RoeeIlouz/shalom-home/docs/screenshots/widget-picker.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FRoeeIlouz%2Fshalom-home%2FHEAD%2Fdocs%2Fscreenshots%2Fwidget-picker.png" alt="Widget picker"&gt;&lt;/a&gt;&lt;/td&gt;

&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/RoeeIlouz/shalom-home/docs/screenshots/setup-hebrew.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FRoeeIlouz%2Fshalom-home%2FHEAD%2Fdocs%2Fscreenshots%2Fsetup-hebrew.png" alt="Setup"&gt;&lt;/a&gt;&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it&lt;/h2&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/RoeeIlouz/shalom-home" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;Source, build instructions and the APK: &lt;a href="https://github.com/RoeeIlouz/shalom-home" rel="noopener noreferrer"&gt;https://github.com/RoeeIlouz/shalom-home&lt;/a&gt;&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;microphone
  -&amp;gt; Whisper small (whisper.cpp)          speech to Hebrew text, on the phone
  -&amp;gt; fuzzy Hebrew keyword matcher         instant for "call Michal", "open WhatsApp"
  -&amp;gt; Gemma 3 1B (llama.cpp), if unclear   "I want to talk to the girl" -&amp;gt; his daughter
  -&amp;gt; 3 s spoken countdown + cancel        -&amp;gt; place the call / open the app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a Kotlin and Jetpack Compose app with two small native libraries: one wraps &lt;a href="https://github.com/ggml-org/whisper.cpp" rel="noopener noreferrer"&gt;whisper.cpp&lt;/a&gt;, the other &lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp&lt;/a&gt;. The models are open weights from Hugging Face, downloaded once in setup: Whisper small (190MB) and &lt;a href="https://huggingface.co/google/gemma-3-1b-it" rel="noopener noreferrer"&gt;Gemma 3 1B&lt;/a&gt; quantized to 4 bits (800MB). Both run on the phone's CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whisper&lt;/strong&gt; does the listening. Two tricks made it usable for short commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The decoder is primed with the names of his contacts (&lt;code&gt;initial_prompt&lt;/code&gt;), so a spoken name comes out spelled the way it is saved.&lt;/li&gt;
&lt;li&gt;The encoder normally processes a fixed 30-second window. For a two-second command I shrink &lt;code&gt;audio_ctx&lt;/code&gt; to the length of the clip. On my test emulator, that took transcription from 25 to 45 seconds down to about one second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The keyword matcher&lt;/strong&gt; handles what Hebrew speech recognition gets wrong in predictable ways: prefixes glued to words (ל, ה, ו: "לאמא" is "to mom"), final-letter forms, spelling variants like אמא / אימא, and Whisper occasionally splitting one word in two ("למי חל" for "למיכל"). If the match is clear, the language model never wakes up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 3 1B&lt;/strong&gt; handles everything without a keyword: "dial the girl", "I want to talk to my daughter", "Appelle ma fille". This is the part I am proudest of, because of how it is constrained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The model may only answer with the id of one of HIS options, or "none".&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;grammar&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"root ::= \"none\" | \"call0\" | \"call1\" | \"app0\" | ..."&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;answer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"He said: \"$transcript\""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;llama.cpp's grammar sampler makes it impossible for the model to output anything else. It cannot invent a phone number, a contact, or an action. The worst it can do is pick the wrong one of &lt;em&gt;his&lt;/em&gt; people, and the countdown catches that.&lt;/p&gt;

&lt;p&gt;A 1B model has habits, and I found them by testing. Asked "what time is it?", it confidently picked the first app in the list. The fix was a separate yes/no question first ("is this a request to call someone or open an app?"), also grammar-constrained. Now small talk is ignored instead of acted on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Things that went wrong
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;My first test dialed a number.&lt;/strong&gt; My test harness let the countdown finish, and the emulator placed a (simulated) call to the fake contact I had created. Nobody was called, but it was the most important bug of the weekend: test mode now stops before any action, and only a real tap or a confirmed voice request can dial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two native libraries, one process.&lt;/strong&gt; whisper.cpp and llama.cpp each ship their own copy of ggml. I build each into its own JNI library with ggml linked statically and its symbols hidden (&lt;code&gt;-Wl,--exclude-libs,ALL&lt;/code&gt;), so they never collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Samsung is not the emulator.&lt;/strong&gt; Two things that worked perfectly on the emulator broke on a real Galaxy. The widget picker crashed because I grouped widgets by app name, and several Samsung apps share one; grouping by package fixed it. And Samsung reported no shortcut permission even for the default home app, so "Direct dial" got a fallback that opens the Contacts shortcut screen directly. Testing on the real phone early would have saved me an evening.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I could not have built this on a closed API, and not just because of cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;His voice stays in his pocket.&lt;/strong&gt; Shalom's requests contain the names of his family. With Whisper and Gemma running on the phone, none of it is sent anywhere. After the one-time model download, the phone does not need the internet at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs nothing to run, forever.&lt;/strong&gt; He is retired. A voice assistant that needs a subscription, or that stops working when an API changes its pricing, is a voice assistant he will lose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I could constrain the model's output.&lt;/strong&gt; Grammar-constrained decoding works because I own the sampling loop. That single feature is what makes it safe to let a small model decide who to call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I can swap in a better Hebrew model.&lt;/strong&gt; The Israeli open-source project &lt;a href="https://huggingface.co/ivrit-ai" rel="noopener noreferrer"&gt;ivrit.ai&lt;/a&gt; publishes Whisper models fine-tuned on Hebrew speech. Because the app loads any whisper.cpp model file, upgrading Shalom's Hebrew recognition is a file copy, not a vendor negotiation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It runs on his phone.&lt;/strong&gt; A mid-range Galaxy A with 6GB of RAM runs both models on the CPU. No GPU, no server, no account.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;No partner categories: Shalom Home runs entirely on the phone, so there is nothing to host, no tabular data to forecast and no fine-tuning run. This is an entry for the overall prize.&lt;/p&gt;

&lt;h4&gt;
  
  
  Disclaimer: Some of the code was generated by Claude Code.
&lt;/h4&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>A Windows context menu editor in one PowerShell file</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sat, 03 Oct 2026 20:27:29 +0000</pubDate>
      <link>https://dev.to/rocisapps/a-windows-context-menu-editor-in-one-powershell-file-2mc</link>
      <guid>https://dev.to/rocisapps/a-windows-context-menu-editor-in-one-powershell-file-2mc</guid>
      <description>&lt;p&gt;The Windows right-click menu is built from several different places, and Windows 11 hides half of it behind "Show more options". Most tools I found each cover one part of it. I wanted one list for all of it, so I built &lt;a href="https://github.com/RoeeIlouz/ROCIsContextMenu-Editor" rel="noopener noreferrer"&gt;Context Menu Editor&lt;/a&gt;: a single PowerShell script with a WPF window, free and MIT licensed.&lt;/p&gt;

&lt;p&gt;You can try it without installing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://rocisapps.com/cme&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can read the source first, and the same script is attached to every GitHub release.&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%2Fuwzhuigf45fd6m57zec5.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%2Fuwzhuigf45fd6m57zec5.png" alt="Windows 11 shows a short right-click menu with " width="800" height="496"&gt;&lt;/a&gt;&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%2Falv63ve4u5jwrdl5a21i.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%2Falv63ve4u5jwrdl5a21i.png" alt="Context Menu Editor's Tweaks page: a checklist of menu clean-ups with Minimal and Standard presets and a Run tweaks button." width="799" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is how it works, and the parts that were harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of entries
&lt;/h2&gt;

&lt;p&gt;Windows builds the menu from three different places, and each one is switched off differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shell verbs&lt;/strong&gt; under &lt;code&gt;Software\Classes\&amp;lt;class&amp;gt;\shell\&amp;lt;verb&amp;gt;&lt;/code&gt;. Adding a &lt;code&gt;LegacyDisable&lt;/code&gt; value turns one off without deleting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell extension handlers&lt;/strong&gt; under &lt;code&gt;shellex\ContextMenuHandlers&lt;/code&gt; (7-Zip, antivirus, PowerToys). You turn one off by adding its CLSID to the per-user &lt;code&gt;Shell Extensions\Blocked&lt;/code&gt; list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows 11 packaged items&lt;/strong&gt;, which apps declare in their &lt;code&gt;AppxManifest.xml&lt;/code&gt; as &lt;code&gt;windows.fileExplorerContextMenus&lt;/code&gt;. They have no command line at all, only a COM handler, and they are blocked the same way as extensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading all three into one list, with the right "off" mechanism behind each switch, is most of the work.&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%2F7a1ty5bvj1ep3hvnhvaq.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%2F7a1ty5bvj1ep3hvnhvaq.png" alt="Diagram: three sources of right-click entries (shell verbs, shell extensions, Windows 11 packaged items), each turned off differently, feeding one list in Context Menu Editor." width="800" height="480"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Turn a shell extension off for the current user (restart Explorer afterwards)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$blocked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Microsoft.Win32.Registry&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;CurrentUser.CreateSubKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Software\Microsoft\Windows\CurrentVersion\Shell Extensions\Blocked'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$blocked&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'{00000000-0000-0000-0000-000000000000}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# the handler's CLSID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Do not use the registry provider
&lt;/h2&gt;

&lt;p&gt;All registry access goes through &lt;code&gt;Microsoft.Win32.Registry&lt;/code&gt;. The PowerShell registry provider treats the &lt;code&gt;*&lt;/code&gt; in &lt;code&gt;HKCU\Software\Classes\*\shell&lt;/code&gt; as a wildcard, so a path that means "all files" quietly matches other keys. The .NET API takes the path literally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$shell&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Microsoft.Win32.Registry&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;CurrentUser.OpenSubKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Software\Classes\*\shell'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$shell&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSubKeyNames&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Send to, and items with no command line
&lt;/h2&gt;

&lt;p&gt;Send to is a folder of shortcuts, so adding an app is easy. Windows 11 packaged items are not: Blip, for example, has a submenu of devices and no executable to point a shortcut at. The shortcut targets a small C# helper instead, which finds the item by its CLSID and calls it on the selected files, the way Explorer does. Submenu entries are matched by title first and by position only as a fallback, so pairing a new device does not change where an existing shortcut sends.&lt;/p&gt;

&lt;p&gt;The helper is compiled at runtime with &lt;code&gt;Add-Type&lt;/code&gt; and stored outside the script's folder, so shortcuts keep working if the editor moves.&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%2Fge6i8guei3ekdm0zrj2f.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%2Fge6i8guei3ekdm0zrj2f.png" alt="Diagram: a Send to shortcut calls a small C# helper, which calls the Explorer command by CLSID, which runs the app's menu item." width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One script, built from many files
&lt;/h2&gt;

&lt;p&gt;The source is split into small files: core functions, UI, XAML, and JSON for the tweaks list. A &lt;code&gt;Compile.ps1&lt;/code&gt; stitches them into the single script people run. Two details only showed up with the one-line launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;irm | iex&lt;/code&gt; passes a byte order mark through as code, which breaks parsing. The output is therefore pure ASCII and written without a BOM, and the compiler refuses non-ASCII characters.&lt;/li&gt;
&lt;li&gt;Windows PowerShell 5.1 reads a BOM-less file as ANSI, which is only safe because of the ASCII rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The short URL is a redirect on my own domain to the latest GitHub release asset, so a new release is live as soon as it is published.&lt;/p&gt;

&lt;h2&gt;
  
  
  Startup time
&lt;/h2&gt;

&lt;p&gt;The first version took about two seconds before any window appeared, and most of that was reading the registry. Two changes helped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open the window first.&lt;/strong&gt; It appears straight away with a "reading" state, and the scan runs once the first frame is on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$Window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add_ContentRendered&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$Window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dispatcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginInvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.Windows.Threading.DispatcherPriority&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;]{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-Scan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Update-View&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The window now appears in 0.6 to 0.8 seconds, and the full list is ready at about the same total time as before.&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%2F5v9juca90jz75ckn1s8v.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%2F5v9juca90jz75ckn1s8v.png" alt="Bar chart: time until the window appears dropped from 1.9 to 0.6 seconds on Windows PowerShell 5.1 and from 2.4 to 0.8 seconds on PowerShell 7." width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache the compiled helper.&lt;/strong&gt; Compiling the C# types cost 0.1 to 0.3 seconds on every start. Now they are compiled once and loaded from a cache file in about 15 milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Add-Type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TypeDefinition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutputAssembly&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$dll&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutputType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Library&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# first run only&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Add-Type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-LiteralPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$dll&lt;/span&gt;&lt;span class="w"&gt;                                                  &lt;/span&gt;&lt;span class="c"&gt;# every run&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An elevated run never loads that cache. An administrator process should not load code from a folder a standard user can write to, so it compiles in memory instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  PowerShell traps I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Variable names are case-insensitive, so a local &lt;code&gt;$s&lt;/code&gt; quietly replaced a global &lt;code&gt;$S&lt;/code&gt;. The shared state is now &lt;code&gt;$App&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A function that returns an array with &lt;code&gt;return ,$array&lt;/code&gt; can come out nested after &lt;code&gt;@()&lt;/code&gt; or a pipeline. Returning the array directly is safer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;R&lt;/code&gt; is an alias for &lt;code&gt;Invoke-History&lt;/code&gt;, which matters when a script defines a short helper function.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;`u{...}&lt;/code&gt; Unicode escape does not exist in Windows PowerShell 5.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing without touching your own menus
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-LibraryOnly&lt;/code&gt; loads every function without opening the window, so the scanner and every write operation can be scripted. An early test run wrote into my real Send to folder, which taught me to point tests at a temporary Send to folder and a temporary backup folder. A separate sweep opens every page of the window and fails on any error, in both PowerShell 7 and 5.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety
&lt;/h2&gt;

&lt;p&gt;Anything you edit or delete is exported to a &lt;code&gt;.reg&lt;/code&gt; file first, and the last change can be undone from the status bar. Per-user changes need no administrator rights. The script makes no network calls except downloading itself again when you choose "run as administrator".&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, and tell me what is missing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run it: &lt;code&gt;irm https://rocisapps.com/cme | iex&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Source (MIT): &lt;a href="https://github.com/RoeeIlouz/ROCIsContextMenu-Editor" rel="noopener noreferrer"&gt;https://github.com/RoeeIlouz/ROCIsContextMenu-Editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Guides: &lt;a href="https://rocisapps.com/windows-11-classic-right-click-menu.html" rel="noopener noreferrer"&gt;get the full right-click menu back in Windows 11&lt;/a&gt; and &lt;a href="https://rocisapps.com/remove-items-from-right-click-menu.html" rel="noopener noreferrer"&gt;remove items from the right-click menu&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am the author. If something breaks on your setup, or an entry is missing from the list, open an issue on GitHub. Please include your Windows version, PowerShell edition and what caused it to break.&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>windows</category>
      <category>opensource</category>
      <category>wpf</category>
    </item>
    <item>
      <title>Why Your Flutter Background Isolates Crash on Android (And How I Fixed It)</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sat, 05 Sep 2026 17:02:02 +0000</pubDate>
      <link>https://dev.to/rocisapps/why-your-flutter-background-isolates-crash-on-android-and-how-i-fixed-it-5cka</link>
      <guid>https://dev.to/rocisapps/why-your-flutter-background-isolates-crash-on-android-and-how-i-fixed-it-5cka</guid>
      <description>&lt;p&gt;Building an offline-first task manager with Flutter and native Android widgets means dancing on the edge of two worlds. Recently, while shipping update &lt;code&gt;0.2.2+63&lt;/code&gt; for &lt;strong&gt;ROCIs Tasks&lt;/strong&gt;, I hit a classic multi-threading trap that’s easy to stumble into when dealing with background isolates, platform channels, and cloud sync.&lt;/p&gt;

&lt;p&gt;Here is how I debugged it, solved it, and finally closed the loop on true two-way Google Tasks syncing.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Mystery: Background Crashes and Broken Tests
&lt;/h3&gt;

&lt;p&gt;While working on background task completion handlers, my app started throwing intermittent runtime errors on Android. The culprit? &lt;code&gt;WidgetsBinding.instance&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you've ever tried to trigger state updates, platform channel calls, or bindings initialization inside a detached background isolate, you know the pain. Flutter expects a UI binding to be initialized on the main thread. When a background handler fires asynchronously, &lt;code&gt;WidgetsBinding.instance&lt;/code&gt; can be null or completely unstable depending on the lifecycle state and platform.&lt;/p&gt;

&lt;p&gt;To make matters worse, my test suite was throwing a couple of red flags: a regex validation failure on my new version string format (&lt;code&gt;0.2.2+63&lt;/code&gt;), and a Mocktail type-casting error because I hadn't stubbed &lt;code&gt;getGoogleAccessToken()&lt;/code&gt; during startup synchronization tests. Standard indie dev Tuesday.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Root Cause
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Isolate Trap&lt;/strong&gt;: &lt;code&gt;WidgetsBinding.instance.platformDispatcher&lt;/code&gt; relies heavily on an active UI view and bound Flutter engine lifecycle. In background execution contexts (like handling quick actions or background sync triggers), that binding simply isn't guaranteed to exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mocking Blind Spots&lt;/strong&gt;: As features grow, mocking external auth tokens in unit tests becomes brittle if your initialization sequence suddenly demands them before the mock is set up.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  The Solution &amp;amp; Code Architecture
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. Ditching &lt;code&gt;WidgetsBinding&lt;/code&gt; for Background Tasks
&lt;/h4&gt;

&lt;p&gt;The fix for the background isolate issue was surprisingly clean. Instead of reaching for the UI-bound dispatcher, drop down to the global singleton &lt;code&gt;PlatformDispatcher&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (Unstable in background isolates)&lt;/span&gt;
&lt;span class="c1"&gt;// final dispatcher = WidgetsBinding.instance.platformDispatcher;&lt;/span&gt;

&lt;span class="c1"&gt;// After (Safe globally, even off-main-thread)&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;dispatcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PlatformDispatcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simple swap completely stabilized my background handlers without touching the main UI lifecycle.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Bulletproofing Google Tasks Back-Sync
&lt;/h4&gt;

&lt;p&gt;With background stability restored, I tackled true two-way sync. Users expect changes made directly inside Google Tasks (completions, uncompletions, and deletions) to reflect instantly in ROCIs Tasks.&lt;/p&gt;

&lt;p&gt;I expanded the &lt;code&gt;GoogleTasksService&lt;/code&gt; with paginated task retrieval, ensuring we pull completed and hidden items, and implemented &lt;code&gt;syncGoogleTasksToLocal()&lt;/code&gt; inside the &lt;code&gt;TaskProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;syncGoogleTasksToLocal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;GoogleTask&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;remoteTasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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;var&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;remoteTasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_localTasks&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;firstWhereOrNull&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;googleId&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&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="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_markLocalCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;remote&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="na"&gt;isCompleted&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_markLocalActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;id&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="c1"&gt;// Handle deletions: tasks present locally with a Google ID but missing remotely&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_reconcileDeletedTasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remoteTasks&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;I hooked this reconciliation logic directly into app startup (&lt;code&gt;syncWithCloud&lt;/code&gt;), manual settings triggers, and lifecycle tab-switches on both mobile and web (&lt;code&gt;home_screen.dart&lt;/code&gt; / &lt;code&gt;web_home_screen.dart&lt;/code&gt;).&lt;/p&gt;




&lt;h3&gt;
  
  
  Key Lessons for Other Devs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Beware of Isolate Assumptions&lt;/strong&gt;: Never assume UI-bound singletons like &lt;code&gt;WidgetsBinding&lt;/code&gt; are available outside the main thread. Use &lt;code&gt;PlatformDispatcher.instance&lt;/code&gt; for thread-safe global access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test Your Version Strings&lt;/strong&gt;: If you adopt build number formats like &lt;code&gt;+63&lt;/code&gt;, update your validation regexes &lt;em&gt;before&lt;/em&gt; CI/CD complains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stub Everything Early&lt;/strong&gt;: Keep your Mocktail default stubs updated when expanding authentication flows to prevent mysterious startup crashes in tests.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Try ROCIs Tasks
&lt;/h3&gt;

&lt;p&gt;ROCIs Tasks is built completely offline-first with native Kotlin home screen widgets and seamless cloud sync. If you want to check it out, grab it on Google Play or try the web version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Play&lt;/strong&gt;: &lt;a href="https://play.google.com/store/apps/details?id=com.rocisapps.tasks" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.rocisapps.tasks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web App&lt;/strong&gt;: &lt;a href="https://tasks.rocisapps.com" rel="noopener noreferrer"&gt;https://tasks.rocisapps.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>indiedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Google Tasks Sync Was Silently Failing Every Hour (And How I Fixed It)</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sat, 05 Sep 2026 16:51:51 +0000</pubDate>
      <link>https://dev.to/rocisapps/why-google-tasks-sync-was-silently-failing-every-hour-and-how-i-fixed-it-1cao</link>
      <guid>https://dev.to/rocisapps/why-google-tasks-sync-was-silently-failing-every-hour-and-how-i-fixed-it-1cao</guid>
      <description>&lt;p&gt;Building an offline-first task app sounds peaceful until you introduce cloud synchronization. Suddenly, you're knee-deep in OAuth scopes, token expirations, and mysterious silent failures that only happen when you step away from your desk for an hour.&lt;/p&gt;

&lt;p&gt;In ROCIs Tasks—my offline-first Android task and calendar app built with Flutter and native Kotlin home screen widgets—I recently ran into a multi-layered Google Tasks sync puzzle. Users were experiencing sudden sync dropouts, and worse, the app wasn't telling them why. Let's break down what went wrong and how I built a robust, self-healing auth recovery flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mystery: The 60-Minute Disappearing Act
&lt;/h3&gt;

&lt;p&gt;Everything worked great during initial testing. You log in, tasks sync beautifully between local SQLite databases and Google Tasks, and native widgets update seamlessly. But after an hour of idle time, background sync would quietly die.&lt;/p&gt;

&lt;p&gt;When looking at the logs, three distinct culprits emerged:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Cloud Console Blindspot&lt;/strong&gt;: The Google Tasks API simply wasn't enabled in the Google Cloud Console for the project. Classic rookie configuration error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 60-Minute Token Wall&lt;/strong&gt;: OAuth access tokens expire after 60 minutes. When the API threw a &lt;code&gt;GoogleTokenExpiredException&lt;/code&gt;, my &lt;code&gt;TaskProvider&lt;/code&gt; was catching it, silently ignoring it, and failing indefinitely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Web Silent Refresh Gap&lt;/strong&gt;: While mobile had some token handling, the web platform lacked proper silent scope authorization requests, forcing users to completely re-authenticate manually.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result? A broken user experience where data simply stopped syncing without any visual feedback.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution &amp;amp; Architecture
&lt;/h3&gt;

&lt;p&gt;To fix this permanently, I needed a multi-pronged approach: fixing the backend configuration, upgrading token handling, and adding graceful UI recovery.&lt;/p&gt;

&lt;p&gt;First, I enabled the API in Google Cloud. Next, I tackled the token lifecycle inside our authentication service. I implemented platform-agnostic silent scope authorization requests to ensure tokens could refresh seamlessly without throwing hard errors.&lt;/p&gt;

&lt;p&gt;To bridge the gap between the auth layer and the UI, I exposed a reactive state flag in our &lt;code&gt;AuthService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthService&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="n"&gt;ChangeNotifier&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;_isGoogleTasksTokenExpired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;isGoogleTasksTokenExpired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_isGoogleTasksTokenExpired&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="n"&gt;setTokenExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;expired&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="n"&gt;_isGoogleTasksTokenExpired&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_isGoogleTasksTokenExpired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="n"&gt;notifyListeners&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="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Platform-agnostic silent token request logic&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_silentAuthClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestAccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="n"&gt;setTokenExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;setTokenExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, instead of hiding the failure, I brought it to the user's attention gracefully. Using our Glassmorphism design system rules, I added contextual warning banners at the top of the Tasks tab on Mobile and inside the sidebar on Web. With a single tap on 'Reconnect', users trigger a self-healing re-authorization flow right where they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Lessons for Other Devs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never silently swallow auth exceptions&lt;/strong&gt;: If an API call fails due to an expired token, surface that state immediately to your state management layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for expiration&lt;/strong&gt;: OAuth tokens &lt;em&gt;will&lt;/em&gt; expire. Always build your UI assuming connectivity and auth states can drop at any moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing UI wins&lt;/strong&gt;: Instead of locking users out or showing generic error toasts, give them a prominent, one-tap path to re-authenticate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Try ROCIs Tasks
&lt;/h3&gt;

&lt;p&gt;Building ROCIs Tasks as an indie developer has been an incredible journey into bridging Flutter cross-platform power with native Android widgets. If you want to check out an offline-first productivity app with deep calendar integration, take a look at &lt;a href="https://play.google.com/store/apps/details?id=com.rocisapps.tasks" rel="noopener noreferrer"&gt;ROCIs Tasks on Google Play&lt;/a&gt; or try the web version at &lt;a href="https://tasks.rocisapps.com" rel="noopener noreferrer"&gt;tasks.rocisapps.com&lt;/a&gt;. Feedback is always welcome!&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>indiedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why My Flutter App Was Spamming Google Sign-In Prompts (And How I Fixed It)</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Sat, 05 Sep 2026 16:48:26 +0000</pubDate>
      <link>https://dev.to/rocisapps/why-my-flutter-app-was-spamming-google-sign-in-prompts-and-how-i-fixed-it-496h</link>
      <guid>https://dev.to/rocisapps/why-my-flutter-app-was-spamming-google-sign-in-prompts-and-how-i-fixed-it-496h</guid>
      <description>&lt;p&gt;As an indie developer building an offline-first task and calendar app (ROCIs Tasks) using Flutter with native Android Kotlin widgets, nothing gives you sudden micro-heart attacks quite like user bug reports about authentication loops. &lt;/p&gt;

&lt;p&gt;Recently, I hit a nasty regression: users were getting hammered with multiple Google sign-in prompts. Tap the Google Tasks sync toggle? Prompt. Tap 'Create Task'? Another prompt. It felt less like a productivity app and more like an aggressive security guard.&lt;/p&gt;

&lt;p&gt;Here is the breakdown of why this happened and how I fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mystery: Death by a Thousand Prompts
&lt;/h3&gt;

&lt;p&gt;Users trying to sync their tasks were seeing back-to-back sign-in overlays. Even worse, generic email/password users were occasionally getting swept into the credential flow. That is a textbook way to tank your app's retention on Google Play.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause Analysis
&lt;/h3&gt;

&lt;p&gt;Digging into the codebase revealed three distinct culprits conspiring against my users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decoupled Auth in Google Sign-In v7:&lt;/strong&gt; Authentication (ID token) and Authorization (Access token/scopes) are now separate steps. My code was calling &lt;code&gt;attemptLightweightAuthentication()&lt;/code&gt; inside &lt;code&gt;getGoogleAccessToken()&lt;/code&gt; whenever the cache missed. On Android, this doesn't always fail silently—it can trigger a Credential Manager overlay chooser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Amnesiac In-Memory Session:&lt;/strong&gt; The authenticated &lt;code&gt;GoogleSignInAccount&lt;/code&gt; object was never stored in memory. Every single token request started completely from scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redundant Scopes:&lt;/strong&gt; My mobile build was requesting the Google Calendar scope (&lt;code&gt;https://www.googleapis.com/auth/calendar&lt;/code&gt;). But on mobile, ROCIs Tasks uses &lt;code&gt;device_calendar&lt;/code&gt; for native OS calendar integration. Asking for the web calendar scope via Google Sign-In triggered redundant, scary permission consent prompts.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Solution &amp;amp; Architecture
&lt;/h3&gt;

&lt;p&gt;To clean this up, I refactored the &lt;code&gt;AuthService&lt;/code&gt; with a strict caching and platform-aware scope strategy.&lt;/p&gt;

&lt;p&gt;First, I introduced an in-memory session cache and hooked into the authentication event stream on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;GoogleSignInAccount&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_googleUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_googleSignIn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;authenticationEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;event&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="n"&gt;event&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;GoogleSignInAccountEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;_googleUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&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;await&lt;/span&gt; &lt;span class="n"&gt;_restoreGoogleSignInSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Smart startup restoration - only for users who actually linked Google Tasks&lt;/span&gt;
  &lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_restoreGoogleSignInSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;_userHasLinkedGoogleTasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;_googleUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_googleSignIn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attemptLightweightAuthentication&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I split the requested scopes by platform. Web needs the Calendar API; mobile handles it natively via the OS device calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;getScopesForPlatform&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="n"&gt;kIsWeb&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="p"&gt;[&lt;/span&gt;
      &lt;span class="s"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'https://www.googleapis.com/auth/tasks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'https://www.googleapis.com/auth/calendar'&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Mobile uses device_calendar for native OS integration&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;'https://www.googleapis.com/auth/tasks'&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, I unified SharedPreferences token caching across platforms so that access tokens survive app restarts without forcing unnecessary network calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Lessons for Other Devs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never assume silent auth is silent:&lt;/strong&gt; Modern Android credential managers can surface UI when you least expect it. Cache your user state locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your OAuth scopes:&lt;/strong&gt; Don't request web-only API scopes on mobile if your app handles things natively via platform channels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect your users' intent:&lt;/strong&gt; Generic email/password accounts should never touch Google OAuth logic on startup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Building ROCIs Tasks as a solo indie dev means obsessing over details like smooth auth flows so the app feels as native and polished as possible. If you want to check out an offline-first task manager with native Kotlin widgets built in Flutter, take a look at &lt;a href="https://play.google.com/store/apps/details?id=com.rocisapps.tasks" rel="noopener noreferrer"&gt;ROCIs Tasks on Google Play&lt;/a&gt; or visit &lt;a href="https://tasks.rocisapps.com" rel="noopener noreferrer"&gt;tasks.rocisapps.com&lt;/a&gt;. Happy coding!&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>android</category>
      <category>indiedev</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I Fixed a Firestore Stream Race Condition That Reverted Task Toggles</title>
      <dc:creator>ROCI</dc:creator>
      <pubDate>Thu, 18 Jun 2026 08:56:24 +0000</pubDate>
      <link>https://dev.to/rocisapps/how-i-fixed-a-firestore-stream-race-condition-that-reverted-task-toggles-1k64</link>
      <guid>https://dev.to/rocisapps/how-i-fixed-a-firestore-stream-race-condition-that-reverted-task-toggles-1k64</guid>
      <description>&lt;p&gt;I'm building an Android task manager (&lt;a href="https://play.google.com/store/apps/details?id=com.rocisapps.tasks" rel="noopener noreferrer"&gt;ROCIs Tasks&lt;/a&gt;) with Flutter. The app uses an offline-first architecture: tasks live in Hive locally and sync to Firestore when online.&lt;br&gt;
Users reported a maddening bug — they'd tap to complete a task, the checkbox would animate, and then... it would snap back to incomplete. Here's what was happening and how I fixed it.&lt;br&gt;
The Race Condition&lt;br&gt;
The app listens to a Firestore stream for real-time updates:&lt;br&gt;
_tasksSubscription = _firestoreService.getActiveTasksStream().listen(&lt;br&gt;
  (events) async {&lt;br&gt;
    for (final event in events) {&lt;br&gt;
      final cloudTask = event.task;&lt;br&gt;
      // process cloud task...&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
);&lt;br&gt;
When a user toggles a task, the app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Updates the local Hive store immediately&lt;/li&gt;
&lt;li&gt;Fires notifyListeners() so the UI updates&lt;/li&gt;
&lt;li&gt;Sends the write to Firestore asynchronously
The problem: the Firestore query filters isCompleted == false. The moment the write reaches Firestore, the task disappears from the stream — which emits a removed event. Meanwhile, the Firestore write hasn't fully propagated, so the snapshot data is stale. The listener processes the stale snapshot, overwrites the local state, and the UI reverts.
Here's the timeline:
User taps "complete"
→ Local Hive: isCompleted = true ✓
→ UI updates ✓
→ Firestore write sent (async)
→ Firestore stream emits: task removed from "active" query
→ Listener processes stale snapshot: isCompleted = false
→ Overwrites local Hive with false ✗
→ UI reverts ✗
The entire round-trip happens in milliseconds. The user sees a flash of completion followed by an instant revert.
The Fix: _pendingLocalWrites Guard
I added a map that tracks recently toggled tasks and their intended state:
final Map _pendingLocalWrites = {};
When a task is toggled, I record the intended state before writing to Firestore:
Future toggleTaskCompletion(Task task) async {
task.isCompleted = !task.isCompleted;
task.completedAt = task.isCompleted ? DateTime.now() : null;
// Record the intended state so the Firestore stream doesn't revert it
_pendingLocalWrites[task.id] = task.isCompleted;
notifyListeners();
await _source.addTask(task);
_firestoreService.updateTask(task).catchError((e, s) {
_errorHandlingService.logError(e, s,
    reason: 'Background cloud updateTask failed');
}).whenComplete(() {
// Allow stream to handle this task again after Firestore write settles
Future.delayed(const Duration(seconds: 3), () {
  _pendingLocalWrites.remove(task.id);
});
});
}
In the stream listener, I check the guard before processing each event:
final pendingState = _pendingLocalWrites[cloudTask.id];
if (pendingState != null) {
// We recently toggled this task — don't let stale data revert it
if (event.type == SyncEventType.removed &amp;amp;&amp;amp; pendingState) {
// We just completed this task — the removed event is expected.
// Update Hive to reflect completion from the latest snapshot.
final (latestTask, isMissing) =
    await _firestoreService.fetchTaskById(cloudTask.id);
if (latestTask != null &amp;amp;&amp;amp; latestTask.isCompleted) {
  await _source.addTask(latestTask);
  await _cancelTaskNotificationsById(latestTask.id);
  needsUpdate = true;
}
continue;
}
if (event.type != SyncEventType.removed &amp;amp;&amp;amp; !pendingState) {
// We just uncompleted this task — ignore the stale cloud event.
continue;
}
}
After 3 seconds (enough for Firestore to propagate), the guard is removed and normal stream processing resumes.
Why 3 Seconds?
Firestore writes typically propagate in 100-500ms. I used 3 seconds as a conservative buffer that covers:&lt;/li&gt;
&lt;li&gt;Slow network connections&lt;/li&gt;
&lt;li&gt;Firestore replication lag under load&lt;/li&gt;
&lt;li&gt;The stream batching multiple events
It's a tradeoff — during those 3 seconds, genuine cloud updates to that task from other devices are also suppressed. For a single-user task manager, that's acceptable. For a collaborative app, you'd want a more sophisticated conflict resolution strategy.
Key Takeaways&lt;/li&gt;
&lt;li&gt;Firestore streams are great, but they're not instant. The gap between a local write and the stream reflecting it is a race condition window.&lt;/li&gt;
&lt;li&gt;Offline-first needs local-first UI updates. Updating Hive before sending to Firestore ensures the UI never feels laggy. The stream is for background sync, not for driving the UI.&lt;/li&gt;
&lt;li&gt;Simple guard maps work. You don't need a full state machine or optimistic concurrency control for most cases. A Map with a TTL is enough.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. Test with bad network conditions. This bug was invisible on fast WiFi. It appeared on cellular with 200ms+ latency.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  If you're building offline-first Flutter apps with Firestore, this pattern is worth knowing. The full app is in open beta on the Play Store — happy to answer questions about the architecture.
&lt;/h2&gt;

&lt;p&gt;If you have an idea on how to optimize the process, I would love to know!&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
