<?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: Ayanda Phaketsi</title>
    <description>The latest articles on DEV Community by Ayanda Phaketsi (@pastdaking).</description>
    <link>https://dev.to/pastdaking</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%2F4098211%2F4d6dc125-c23e-4993-875c-ff6912c44aac.png</url>
      <title>DEV Community: Ayanda Phaketsi</title>
      <link>https://dev.to/pastdaking</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pastdaking"/>
    <language>en</language>
    <item>
      <title>How I built a floating dictation orb on Android, and why the accessibility service is the whole app</title>
      <dc:creator>Ayanda Phaketsi</dc:creator>
      <pubDate>Fri, 28 Aug 2026 03:57:49 +0000</pubDate>
      <link>https://dev.to/pastdaking/how-i-built-a-floating-dictation-orb-on-android-and-why-the-accessibility-service-is-the-whole-app-1gbd</link>
      <guid>https://dev.to/pastdaking/how-i-built-a-floating-dictation-orb-on-android-and-why-the-accessibility-service-is-the-whole-app-1gbd</guid>
      <description>&lt;p&gt;I talk in two languages at once. Not alternately, in the same sentence. English and my home language, mixed, the way most people around me speak.&lt;/p&gt;

&lt;p&gt;Every dictation tool I tried broke on that. Wispr Flow is genuinely good software and I am not pretending otherwise, but like the others it picks a language and pushes everything through it, and what comes back is something I have to retype. Gemini was the first model that gave me my actual sentences back. That was the trigger for building RMBLR, an Android app that puts a dictation orb over whatever you are typing in.&lt;/p&gt;

&lt;p&gt;The interesting part turned out not to be the transcription. It was knowing when to appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: which app owns the cursor?
&lt;/h2&gt;

&lt;p&gt;I did not want another floating bubble living on the home screen. The orb should exist when a text field has focus and not otherwise.&lt;/p&gt;

&lt;p&gt;Android gives you exactly one API that can answer "is there a caret in an editable field right now" for an app you did not write: an &lt;code&gt;AccessibilityService&lt;/code&gt;. Nothing else sees another process's view tree.&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="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;currentFieldIsEditable&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;node&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;findFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AccessibilityNodeInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FOCUS_INPUT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;getOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;editable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEditable&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"EditText"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;editable&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FOCUS_INPUT&lt;/code&gt; is the important constant. &lt;code&gt;FOCUS_ACCESSIBILITY&lt;/code&gt; is where TalkBack's green box is, which is not the same thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isEditable&lt;/code&gt; alone is not enough in practice. Plenty of older views and WebView inputs report false while behaving like text boxes, so the &lt;code&gt;EditText&lt;/code&gt; class-name check earns its place. Recycle the node: you run this on every event, and leaking &lt;code&gt;AccessibilityNodeInfo&lt;/code&gt; will bite you.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;res/xml/accessibility_service_config.xml&lt;/code&gt; I subscribe to focus, click, text changed, text selection changed, window state and window content, with &lt;code&gt;android:notificationTimeout="80"&lt;/code&gt;. Higher and the orb visibly lags behind the keyboard. Lower and you re-walk the tree constantly for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overlay must not want focus
&lt;/h2&gt;

&lt;p&gt;Second half of the same problem. The orb is a &lt;code&gt;TYPE_APPLICATION_OVERLAY&lt;/code&gt; window, and if it takes focus, the text field loses the caret, and now &lt;code&gt;findFocus(FOCUS_INPUT)&lt;/code&gt; returns null and the orb hides itself. It fights itself out of existence.&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="nc"&gt;WindowManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LayoutParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_NOT_FOCUSABLE&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt;
    &lt;span class="nc"&gt;WindowManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LayoutParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_LAYOUT_NO_LIMITS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FLAG_NOT_FOCUSABLE&lt;/code&gt; keeps the caret where it was. &lt;code&gt;FLAG_LAYOUT_NO_LIMITS&lt;/code&gt; lets you park the orb under the status bar cutout instead of being shoved inside the safe area. When the overlay genuinely needs typed input, clear the flag, call &lt;code&gt;updateViewLayout&lt;/code&gt;, and set it again afterwards.&lt;/p&gt;

&lt;p&gt;Getting text back in uses the same service. Paste first, because it respects the caret and the host app's own input handling, and only rewrite the field if paste is refused:&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="n"&gt;clipboard&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;setPrimaryClip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ClipData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPlainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RMBLR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;performAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AccessibilityNodeInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTION_PASTE&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;true&lt;/span&gt;
&lt;span class="c1"&gt;// otherwise splice at textSelectionStart/textSelectionEnd and ACTION_SET_TEXT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ACTION_SET_TEXT&lt;/code&gt; replaces everything in the field, so it is the fallback and not the default. Half a draft message disappearing is a much worse bug than a failed dictation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gesture arc
&lt;/h2&gt;

&lt;p&gt;Hold the orb and four tone chips fan out. There is no Composable for this and I would not use one anyway: the whole interaction is one uninterrupted finger press, so it is a single &lt;code&gt;onTouch&lt;/code&gt; on the overlay root.&lt;/p&gt;

&lt;p&gt;The rules that made it feel right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Long press fires at 300ms.&lt;/li&gt;
&lt;li&gt;Movement past 12dp before that cancels the long press and starts a drag instead. Without the slop, picking the orb up opens the menu every time.&lt;/li&gt;
&lt;li&gt;Selection is by &lt;strong&gt;angle&lt;/strong&gt;, not by quadrant or hit box. Take the angle from the touch origin to your finger, compare it against each chip's angle, pick the smallest difference. Distance stops mattering past a 46dp dead zone, so you can aim fast and sloppily and still land the chip you wanted.&lt;/li&gt;
&lt;li&gt;A flick is travel over 90dp in under 300ms, and it runs the chip in that direction without the menu ever being drawn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four chips, not seven. Every chip you add shrinks the angle each one owns, and aiming gets worse for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming, because latency lands in the worst place
&lt;/h2&gt;

&lt;p&gt;The first version recorded the clip, then on release opened a socket, uploaded everything and waited. All of that latency landed &lt;strong&gt;after&lt;/strong&gt; I stopped talking, which is exactly the moment I am staring at the screen waiting for words.&lt;/p&gt;

&lt;p&gt;Now the socket opens on press. Audio goes up in 200ms chunks as the recorder produces them, and transcript deltas come back while I am still mid-sentence. By release there is usually nothing left to do but read the buffer. Total time barely moved. Perceived time is a different app, and that is the number people actually feel.&lt;/p&gt;

&lt;p&gt;Two things cost me an evening each:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side voice detection lies to you about completion.&lt;/strong&gt; &lt;code&gt;turnComplete&lt;/code&gt; and &lt;code&gt;generationComplete&lt;/code&gt; arrive every time you pause for breath. They do not mean the user has finished. What ends the session is the input transcript going quiet for a fixed window &lt;em&gt;after&lt;/em&gt; the microphone has stopped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the WAV anyway.&lt;/strong&gt; I buffer the full audio even on the streaming path. Sockets die on flaky mobile data, and holding the bytes means a dead stream falls back to the batch endpoint instead of losing someone's dictation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Nothing, plus whatever your API provider charges. It is MIT, there is no account and no subscription, and you paste in your own key: Gemini, Groq, Mistral, OpenRouter, OpenAI, or any OpenAI-compatible endpoint you host. Nothing goes through a server I own, which is cheap for me and, more usefully, checkable by you.&lt;/p&gt;

&lt;p&gt;Source and signed APK: &lt;a href="https://github.com/Past-da-king/rmblr" rel="noopener noreferrer"&gt;github.com/Past-da-king/rmblr&lt;/a&gt;. If you are building anything overlay-plus-accessibility shaped, the two files worth reading are &lt;code&gt;FieldWatcherService.kt&lt;/code&gt; (149 lines, the whole focus-detection story) and &lt;code&gt;OrbOverlayService.kt&lt;/code&gt; for the touch handling. Happy to answer questions on either.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>android</category>
      <category>opensource</category>
      <category>a11y</category>
    </item>
  </channel>
</rss>
