<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Blog - EDM115</title>
        <link>https://edm115.eu.org/blog</link>
        <description>Blog posts from edm115.dev</description>
        <lastBuildDate>Sun, 15 Feb 2026 20:46:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <ttl>60</ttl>
        <atom:link href="https://edm115.eu.org/feeds/blog.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[How I made a budget tracker for my gf because she kept complaining about Google Sheets]]></title>
            <link>https://edm115.eu.org/blog/2026/02/15/how-i-made-spendly</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2026/02/15/how-i-made-spendly</guid>
            <pubDate>Sun, 15 Feb 2026 20:46:00 GMT</pubDate>
            <description><![CDATA[A breakdown of how Spendly grew from a spreadsheet replacement into a full web app with charts, exports, and shared budgets, all while using GitHub Copilot CLI to supercharge development and ship features at lightning speed.]]></description>
            <content:encoded><![CDATA[<h1 id="how-i-made-a-budget-tracker-for-my-gf-because-she-kept-complaining-about-google-sheets" tabindex="-1"><a class="header-anchor" href="#how-i-made-a-budget-tracker-for-my-gf-because-she-kept-complaining-about-google-sheets">How I made a budget tracker for my gf because she kept complaining about Google Sheets</a></h1><div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">Note</p><p>This post was originally made as a submission for the <a href="https://dev.to/challenges/github-2026-01-21" target="_blank" rel="noopener noreferrer">GitHub Copilot CLI Challenge</a> by DEV.<br>I ended up being part of the 25 runner-ups among 400+ submissions (which was very unexpected), and <a href="https://dev.to/devteam/congrats-to-the-github-copilot-cli-challenge-winners-2240" target="_blank" rel="noopener noreferrer">I won</a> a badge and 1 year of GitHub Copilot Pro+ (worth 390$).<br>The entire post was mostly centered around Copilot due to the challenge, which explains in part the glaze. However I do mean what I said. No cashprize or reward will shift my honesty.<br>You can find the original link <a href="https://dev.to/edm115/how-i-made-a-budget-tracker-for-my-gf-because-she-kept-complaining-about-google-sheets-49l3" target="_blank" rel="noopener noreferrer">here</a>.</p></div><h2 id="quick-background" tabindex="-1"><a class="header-anchor" href="#quick-background">Quick background</a></h2><p>I’m a French dev who finished my studies, and I build random projects to keep sharpening my skills while I look for a job. As usual, my gf had some kind of issue and as a dev, I can cook something up to help. I already did something similar with <a href="https://github.com/EDM115/better-maps" target="_blank" rel="noopener noreferrer"><code class="hljs"><span class="hljs-attribute">Better Maps</span></code></a>, a webapp that uses the Google Maps API to display custom pinpoints on top of the map. I created it because we were moving in together to a city neither of us knew well.</p><h2 id="what-i-built-and-how" tabindex="-1"><a class="header-anchor" href="#what-i-built-and-how">What I built (and how)</a></h2><p>This time, she wanted a way to manage her finances. So far, she’d been doing it in a Google Sheet, which isn’t a bad idea (I mean, accounting is one of the main reason spreadsheets exist, right ?) but isn’t very practical, and for a few reasons :</p><ul><li><strong>Overkill</strong> : Google Sheets has a load of features that she’ll never use</li><li><strong>Not mobile-friendly</strong> : Although the Sheets app on Android is sleek and works well (props to the Google engineers for that 🙌), it isn’t as good as on desktop. And like with <code class="hljs"><span class="hljs-attribute">Better Maps</span></code>, she was also going to use it on her phone, so making the webapp responsive was a top priority</li><li><strong>No stats, no analysis</strong> : Sure, you <em>can</em> create formulas to compute what you need and display graphs, you have to set it all up manually, update them when needed, and duplicate all that work for every month’s sheet</li></ul><p>So naturally, she asked me to do the same kind of thing I did with <code class="hljs"><span class="hljs-attribute">Better Maps</span></code>. Fortunately, that meant that I could reuse the codebase ☺️ (which I’d already partly reused back then from my website and a school project), do a few minor tweaks to host the data, and be done with it.<br>At least, that’s what I <em>thought</em> would happen…</p><h2 id="wanna-see-a-demo" tabindex="-1"><a class="header-anchor" href="#wanna-see-a-demo">Wanna see a demo ?</a></h2><p>If you want to skip the ramble, you can check the app at <a href="https://spendly.edm115.dev" target="_blank" rel="noopener noreferrer">spendly.edm115.dev</a>. There’s a link to a fully fledged demo with sample data so you can see what’s possible, along with a complete landing page (Google OAuth validation team said it wasn’t complete enough… so I gave them all the details !). The app is public and ready to use, feel free to create an account, try it and give me feedback :)<br>The source code is available at <a href="https://github.com/EDM115/Spendly" target="_blank" rel="noopener noreferrer">EDM115/Spendly</a>.</p><h2 id="what-i-initially-planned-to-do" tabindex="-1"><a class="header-anchor" href="#what-i-initially-planned-to-do">What I initially planned to do</a></h2><p>The webapp is built with <a href="https://nuxt.com" target="_blank" rel="noopener noreferrer">Nuxt</a>, a <a href="https://vuejs.org/" target="_blank" rel="noopener noreferrer">Vue.js</a> full-stack framework that solves a lot of pain points with a batteries-included approach (SSR, API, routing, crazy good DX, …).<br>Personal preference, but I prefer Vue over React, I feel like I can ship faster with it, and AI models give much more consistent results thanks to the way the ecosystem is structured (wanna do X ? here’s the Y official solution that everyone uses and that’s well documented !).<br>The app is built and bundled in a Docker container, and it’s linked to a volume containing an SQLite DB, because I couldn’t be bothered to use either a cloud DB or run a separate MySQL server.<br>So <a href="https://github.com/EDM115/spendly/commit/5a2ab5dbc68df897fb48cfe572958013360b2ad8" target="_blank" rel="noopener noreferrer">3 months ago</a>, I ported the code from <code class="hljs"><span class="hljs-attribute">Better Maps</span></code> over to <code class="hljs"><span class="hljs-attribute">Spendly</span></code>… and then didn’t do anything for a month because I had other projects to work on.<br>On December 2nd, 2025, I finally decided to sketch how the app would look, what the features would be (and to calm down my gf’s unrealistic expectations lol), and… once again, other projects took priority.<br>December 29th was the day where I finally locked in and created the first “working-ish” version of <code class="hljs"><span class="hljs-attribute">Spendly</span></code>.<br>Over January, I improved the app a bit, added a landing page (<code class="hljs"><span class="hljs-attribute">Better Maps</span></code> didn’t had one and threw you straight into the login page because it was only meant for private usage, the Google Maps API ain’t free 😭 but here I wanted to make it public so landing page it is !), refined the visual style (I struggled quite some time with this 😅), some perf improvements, …</p><h2 id="what-does-the-app-even-do-anyway" tabindex="-1"><a class="header-anchor" href="#what-does-the-app-even-do-anyway">What does the app even do anyway ?</a></h2><p>With <code class="hljs"><span class="hljs-attribute">Spendly</span></code>, you can create “budgets”. They can represent an actual bank account where you track your expenses, a trip you want to plan, a project with strict budgeting, …<br>Each budget has its own “categories”, with a color and an icon, so you can (you guessed it) categorize expenses.<br>Then you simply add the transactions (expense or income), their amount, associated category and date.<br>You can then freely sort and search for them, filter by any date range, and most importantly get 4 pre-made charts to analyze the data (with a fully customizable time range as well) :</p><ul><li><strong>Evolution</strong> : The balance over time. See how expenses/income/balance evolves over time</li><li><strong>Repartition by category</strong>: Check which category represents most of your expenses/income</li><li><strong>Comparison</strong> : Review whether you spent more money than you earned</li><li><strong>Distribution</strong> : The cashflow overview. Check the percentage of savings/deficit you have compared to income/expenses</li></ul><p>You can also export your data at any time :</p><ul><li>Transactions : CSV/JSON</li><li>Charts : SVG/PNG/PDF</li></ul><p>Each budget can be shared with any number of other users, with roles (kinda like Google Drive) : viewer, editor, admin, and owner.<br>Finally, the app has desktop and mobile layouts, a light &amp; dark theme, and is available in both English and French.</p><h2 id="how-the-git-hub-copilot-cli-helped-massively-and-got-me-carried-away" tabindex="-1"><a class="header-anchor" href="#how-the-git-hub-copilot-cli-helped-massively-and-got-me-carried-away">How the GitHub Copilot CLI helped massively (and got me carried away)</a></h2><p>So far, <code class="hljs"><span class="hljs-attribute">Spendly</span></code> was developed in VS Code like usual, with the help of Copilot (I’ve been a very early adopter, part of the Technical Preview on April 2nd 2022, Copilot in the CLI on March 22nd 2023 and Copilot Chat private beta on May 26th 2023).<br>On January 22nd, a friend told me about this challenge. I figured it was a good time to finally learn how to use the Copilot CLI, since I hadn’t really tried any TUI-based AI tool. Time to catch up, I guess !<br>And boy, did I ship.<br>Before that date, the project had <strong>+20 126 LoC</strong> since inception, tho to be real it’s closer to <strong>+7 468 / -2 062 LoC</strong> if you remove the <code class="hljs"><span class="hljs-attribute">Better Maps</span></code> base, and that in 2 months.<br>In comparison, in just 24 days, there were <strong>+29 595 / -5 143 LoC</strong>, and that’s not just lockfile changes 😉 A lot of major features landed that I wouldn’t have been able to implement in time without AI.<br>To give you an overview of what shipped since I started using the Copilot CLI :</p><ul><li><strong>Better on mobile</strong> : Virtualization, cards instead of a table, simplified charts, …</li><li><strong>Demo</strong> : Add a complete set of demo data in 2 languages, covering 17 believable categories and 150 transactions over the course of 10 months</li><li><strong>Icon search</strong> : Rather than expecting users to know what MDI icons are, they can just search for what they need (with support for categories and aliases), yes, even <code class="hljs"><span class="hljs-attribute">baguette</span></code> 🥖</li><li><strong>Proper charts exports</strong> : This was a pain point. Exports were low-res on image, and the SVG just embedded the PNG… Now it generates a proper SVG, and renders ultra high-quality images from that same SVG !</li><li><strong>Database change (Drizzle ORM)</strong> : Instead of raw SQL queries everywhere, we now have an ORM, migrations, easier queries, and more. This finally lets me do core changes to the app without resetting the DB lol. This change was massive and instrumental for the next change :</li><li><strong>Auth change (Better Auth)</strong> : The auth system used to be hand-rolled (bad idea, I know) : username/password login, JWT sessions and some requests validation. That was it. Now, thanks to this (massive too) change, we have proper username/email auth, OAuth (Google/GitHub), proper session management (no more random disconnects), proper admin management, captcha support (Cloudflare Turnstile), … and most importantly : <strong>the ability to signup</strong> !</li><li><strong>Emails (Resend)</strong> : We can now send emails, for example on forgotten password, or even for admin actions (an user requests their data or want to delete their account)</li><li><strong>Account page</strong> : Since we have a proper auth, we can allow users to manage their account in a centralized place. It’s also the place where they can donate if they want to 🫶</li><li><strong>Admin rework</strong> : After the DB &amp; Auth changes it was broken, but Copilot restored it and improved it with a centralized place to handle user requests</li><li><strong>PWA</strong> : Finally, my gf wanted to “install” the app on her phone, so a PWA was the best choice. Copilot helped tremendously to fix some pesky install quirks and cache invalidation issues !</li></ul><h3 id="models-used" tabindex="-1"><a class="header-anchor" href="#models-used">Models used</a></h3><p>I love tinkering with models to find out what they’re good at (I might even have a <a href="https://edm115.dev/blog" target="_blank" rel="noopener noreferrer">blog post</a> coming that compares all of them against a deceptively hard prompt… 🫣). Overall I used 3 models for <code class="hljs"><span class="hljs-attribute">Spendly</span></code> :</p><ul><li>🥇 <strong>OpenAI’s GPT 5.2-Codex</strong> : The best one overall. Versatile, eager to tackle hard tasks, cheap, large context window, loves reading docs like me, reasons well and follows instructions very closely.</li><li>🥈 <strong>Anthropic’s Claude Opus 4.5</strong> : Although I got hit with the <code class="hljs"><span class="hljs-keyword">x</span><span class="hljs-number">3</span></code> multiplier 🥲 this model is really powerful. I mostly used it as a scaffolder for Codex : generate plans, implementation guidelines, the AI guidance, potential perf improvements, … then handed off to Codex for the actual code changes.</li><li>🥉 <strong>Google’s Gemini 3 Pro</strong> : I didn’t used it a lot, only for UI changes (pick a style, refactor all components to match it, unify everything and make it look good on mobile). I found it to be better at UI/UX overall.</li></ul><h3 id="what-helped-along-the-way" tabindex="-1"><a class="header-anchor" href="#what-helped-along-the-way">What helped along the way</a></h3><ul><li><strong><code class="hljs">AGENTS.<span class="hljs-built_in">md</span></code></strong> : I added this pretty late to the project, but having a centralized file that guides AI agents is a must. You can define your own rules, document the codebase so the LLM doesn’t waste tokens exploring blindly, note gotchas or unique bits of the project, … I already had some experience writing <a href="https://github.com/EDM115/website/blob/master/AGENTS.md" target="_blank" rel="noopener noreferrer">one for my website</a>, you can find <code class="hljs"><span class="hljs-attribute">Spendly</span></code>’s one <a href="https://github.com/EDM115/spendly/blob/master/AGENTS.md" target="_blank" rel="noopener noreferrer">here</a> (they all had been largely inspired by <a href="https://github.com/oxc-project/oxc/blob/main/AGENTS.md" target="_blank" rel="noopener noreferrer">Oxc’s AI guidelines</a>).</li><li><strong>Skills</strong> : Same story, I added them quite late. At first I didn’t bought into the hype, because I thought (like MCPs) they’d eventually get used to fix problems they weren’t meant for. But HOLY was I wrong. With a few simple skills (libraries, tool calling, search, brainstorming, …) I got <em>crazy good</em> results with much less prompting ! Also thanks to the Copilot CLI team for supporting the <code class="hljs">~/.agents</code> standard, so I don’t have duplicated files everywhere 🙏</li></ul><h3 id="an-example-wide-event-logs" tabindex="-1"><a class="header-anchor" href="#an-example-wide-event-logs">An example : wide event logs</a></h3><p>This is the perfect example of a feature that I didn’t need, but got carried away because Copilot is too good 🐐<br>So the app basically got no logs from the start (as <code class="hljs"><span class="hljs-attribute">Better Maps</span></code> didn’t either, since it was targeted at just me, my gf and my mom). But if I wanted the app to be public, I needed some insight, especially for failures or slow aah requests.<br>Weeks prior, I read the excellent <a href="https://loggingsucks.com/" target="_blank" rel="noopener noreferrer">Logging Sucks</a> article by Boris Tane, and it described exactly what I needed : wide event logs.<br>Fortunately, he also provided a <a href="https://github.com/boristane/agent-skills/blob/main/skills/logging-best-practices/SKILL.md" target="_blank" rel="noopener noreferrer">skill</a> that made implementing it much easier.<br>Here’s how I proceeded :</p><ol><li><strong>Prepare</strong>. I used ChatGPT’s website with 5.2 Thinking (extended) &amp; web search to review the website/skill, then explained my webapp and asked it to generate a complete prompt to hand off to an AI agent. I could’ve used the Plan mode inside the Copilot CLI (and I did for other complex tasks), but here I wanted sources checked first—and ChatGPT’s web search is great for that.</li><li><strong>Implement</strong>. GPT 5.2-Codex handled that. Thanks to the complex but detailed prompt + skills + tools, it was able to <em>one-shot it</em> (1 750 LoC changes across 32 files), all while staying under the context window !</li><li><strong>Refine</strong>. Although it worked perfectly, I asked later to add guidance in the <code class="hljs">AGENTS.<span class="hljs-built_in">md</span></code>, add implementation notes for features that weren’t ready yet (like emails), … Small touches on a working product.</li><li><strong>Over-obsess</strong>. This is the part where I got carried away… 😅 After seeing how well it went, I asked for completely out-of-scope and unnecessary tools to accompany it. First a CLI tool to parse the logs and extract insights. And when it did make it in only 2 back-and-forth prompts, I turned the difficulty up and asked it to make a whole-ass TUI to dynamically browse logs and view metrics. Will I ever use it ? Probably not, but boy was it fun !</li></ol><h3 id="my-overall-thoughts-about-ai-usage-in-this-project" tabindex="-1"><a class="header-anchor" href="#my-overall-thoughts-about-ai-usage-in-this-project">My overall thoughts about AI usage in this project</a></h3><p>I’ve used Copilot since the start of this project, just like with <code class="hljs"><span class="hljs-attribute">Better Maps</span></code> before it, which let me move fast, iterate quickly, ship often, and explore ideas I wouldn’t bother to consider otherwise.<br>Switching to Copilot CLI was a breath of fresh air. Even though Copilot is very well integrated into VS Code, I had 2 big issues :</p><ul><li><strong>Performance</strong> : VS Code is an Electron-based app. On my potato laptop, running Chrome + Discord + Spotify + VS Code + a dev server with HMR reloading on every keystroke doesn’t help. Dropping one Electron app can make a real difference, and Copilot CLI is very well optimized, low on resources and quite snappy !</li><li><strong>Code-first approach</strong> : When I’m in VS Code, I’m a lot tempted to watch changes live and obsess over them. In a TUI, I only checked the diff and intervened when necessary. That more laid-back approach helped me relax a bit and stress less (though I wouldn’t necessarily do this on other projects, here the result matters more than code quality).</li></ul><p>Now, was Copilot (or, the 3 models I used) perfect ? No.<br>I had some pain points that even prompting, reference files, documentation links, skills and <code class="hljs">AGENTS.<span class="hljs-built_in">md</span></code> couldn’t solve. For instance, the PNG generated from charts was around a megabyte. The PDF generated from the same chart (which just embeds that PNG) was… 77 Mb ! Copilot tried all sorts of techniques to reduce it, while all it needed was to enable 2 config flags in the library’s config (that I found by reading the docs myself).<br>So I still had to do some cleanup passes after AI edits, and I also enjoyed building some parts entirely by hand, but Copilot handled the heavy lifting and boilerplate.</p><h2 id="app-screenshots" tabindex="-1"><a class="header-anchor" href="#app-screenshots">App screenshots</a></h2><p><strong>The homepage</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-homepage.webp" alt="Homepage" loading="lazy"><br><strong>Budget selector</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-budget-selector.webp" alt="Budget selector" loading="lazy"><br><strong>Transactions list</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-transactions-list.webp" alt="Transactions list" loading="lazy"><br><strong>Categories</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-categories.webp" alt="Categories" loading="lazy"><br><strong>Evolution chart</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-evolution-chart.webp" alt="Evolution chart" loading="lazy"><br><strong>Repartition chart</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-repartition-chart.webp" alt="Repartition chart" loading="lazy"><br><strong>Comparison chart</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-comparison-chart.webp" alt="Comparison chart" loading="lazy"><br><strong>Distribution chart</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-distribution-chart.webp" alt="Distribution chart" loading="lazy"><br><strong>Login page</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-login.webp" alt="Login page" loading="lazy"><br><strong>Account page</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-account.webp" alt="Account page" loading="lazy"><br><strong>Light theme</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-light.webp" alt="Light theme" loading="lazy"><br><strong>Transactions on mobile</strong><br><img src="/img/blog/2026/02-15-how-i-made-spendly-mobile.webp" alt="Transactions on mobile" loading="lazy"><br><strong>The overkill Logs viewer TUI</strong></p><iframe width="960" height="540" src="https://www.youtube.com/embed/1QqQuLsBC0I?disablekb=1&rel=0" title="Spendly Logs viewer TUI - EDM115" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>]]></content:encoded>
            <category>project</category>
            <category>copilot</category>
            <category>devchallenge</category>
            <category>nuxt</category>
        </item>
        <item>
            <title><![CDATA[The 5 "levels" of optimization (no, it didn't drive me insane !)]]></title>
            <link>https://edm115.eu.org/blog/2025/12/22/the-5-levels-of-optimization</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2025/12/22/the-5-levels-of-optimization</guid>
            <pubDate>Mon, 22 Dec 2025 14:00:00 GMT</pubDate>
            <description><![CDATA[A journey through the 5 stages of optimization I went through while optimizing a CLI tool I made, because apparently I love to over-engineer things.]]></description>
            <content:encoded><![CDATA[<h1 id="the-5-levels-of-optimization-no-it-didnt-drive-me-insane" tabindex="-1"><a class="header-anchor" href="#the-5-levels-of-optimization-no-it-didnt-drive-me-insane">The 5 “levels” of optimization (no, it didn’t drive me insane !)</a></h1><p>This blog post will detail the 5 <s>stages of grief</s> levels I went through when trying to optimize a function for a CLI tool I built (<a href="https://github.com/EDM115/monorepo-hash" target="_blank" rel="noopener noreferrer"><code class="hljs">monorepo-<span class="hljs-built_in">hash</span></code></a>).</p><div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">Note</p><p>I’m not claiming these are the 5 sacred commandments of performance engineering, nor am I saying that any of these accurately represent their associated “career level”, they’re just depicted as quick landmarks.<br>This is just the path I ended up taking, with a couple of wrong turns, a bit of ego, and a decent amount of “surely this will be faster”, followed by immediate regret.<br>Also note that a good chunk of the CLI has been made with the assistance of AI models, so that’s an easy way to spot mistakes and things to optimize 😄</p></div><p>Is the title clickbait ? If you think so, it means that it worked anyway :)</p><h2 id="yet-another-cli-tool" tabindex="-1"><a class="header-anchor" href="#yet-another-cli-tool">Yet another CLI tool ?</a></h2><p>Yeah, oopsies… 😅<br>I made this tool during my last internship, more details can be found in its README.<br>All that you should know is :</p><ol><li>It generates hashes for the different workspaces of your monorepo, with support for internal transitive dependencies</li><li>It was just a quick script written to solve an issue but I wanted to turn it into its own thing</li><li>It was more of an excuse for me to make a CLI tool, mess with some interesting new tech (rolldown, bun, …) and try to see how far I could optimize it (with benchmarks backing it up)</li></ol><p>As such, it needs to be able to process potentially lots of files, fast enough so it’s not visible to the user (ex when used as a pre-commit hook).<br>Implementing it in TypeScript is… <em>debatable</em> if performance is the whole point, but that’s another fight 🤭.</p><h2 id="our-focus-today" tabindex="-1"><a class="header-anchor" href="#our-focus-today">Our focus today</a></h2><p>Since the code spans nearly <code class="hljs"><span class="hljs-number">1</span>k <span class="hljs-keyword">LoC</span></code> (excluding comments), we won’t cover everything here. We will only focus on the code that computes per-file hashes and returns them.<br>There are some subtleties tho, that won’t change between each implementation :</p><ol><li>The returned paths should be POSIX-style. We use a custom function for this that’s been simplified here (we have a cache in the actual script)</li><li>It’s async and exported for programmatic usage</li><li>We initialize the returned record to <code class="hljs"><span class="hljs-keyword">Object</span>.<span class="hljs-keyword">create</span>(<span class="hljs-keyword">null</span>)</code> instead of <code class="hljs"><span class="hljs-template-variable">{}</span></code> to skip proto initialization</li><li>We exit early if the list is empty</li></ol><p>The shell of that function looks like this :</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { sep } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-comment">/**&#10; * Normalize a path for display purposes (always POSIX-style separators)&#10; * <span class="hljs-doctag">@param</span> p The path to normalize&#10; * <span class="hljs-doctag">@returns</span> The normalized path&#10; */</span>&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">displayPath</span>(<span class="hljs-params"><span class="hljs-attr">p</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-built_in">string</span> {&#10;  <span class="hljs-keyword">return</span> sep === <span class="hljs-string">&quot;/&quot;</span>&#10;    ? p&#10;    : p.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/\\/g</span>, <span class="hljs-string">&quot;/&quot;</span>)&#10;}&#10;&#10;<span class="hljs-comment">/**&#10; * For a given `dir` and list of relative file paths (`fileList`), compute per-file SHA-256 on (normalizedPath + rawContent)&#10; * Always returns a map : { &quot;posix/rel/path&quot;: &quot;hex&quot; }&#10; * <span class="hljs-doctag">@param</span> dir The absolute path to the directory containing the files&#10; * <span class="hljs-doctag">@param</span> fileList An array of relative file paths within the directory&#10; * <span class="hljs-doctag">@returns</span> A promise that resolves to a record mapping POSIX relative paths to their SHA-256 hex hashes&#10; */</span>&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt; = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> result&#10;  }&#10;&#10;  <span class="hljs-comment">// process...</span>&#10;&#10;  <span class="hljs-keyword">return</span> result&#10;}&#10;</code></pre><h2 id="level-1-sequential-processing-new-grad" tabindex="-1"><a class="header-anchor" href="#level-1-sequential-processing-new-grad">Level 1 : Sequential processing (new grad)</a></h2><p>Pretty straightforward, just iterate over the files and compute their hashes bro :</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:crypto&quot;</span>&#10;<span class="hljs-keyword">import</span> { readFile } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:fs/promises&quot;</span>&#10;<span class="hljs-keyword">import</span> { join } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt; = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> result&#10;  }&#10;&#10;  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> file <span class="hljs-keyword">of</span> fileList) {&#10;    <span class="hljs-keyword">const</span> norm = <span class="hljs-title function_">displayPath</span>(file)&#10;    <span class="hljs-keyword">const</span> fullPath = <span class="hljs-title function_">join</span>(dir, file)&#10;    <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> <span class="hljs-title function_">readFile</span>(fullPath)&#10;    <span class="hljs-keyword">const</span> fileHash = <span class="hljs-title function_">createHash</span>(<span class="hljs-string">&quot;sha256&quot;</span>)&#10;      .<span class="hljs-title function_">update</span>(norm)&#10;      .<span class="hljs-title function_">update</span>(content)&#10;      .<span class="hljs-title function_">digest</span>(<span class="hljs-string">&quot;hex&quot;</span>)&#10;&#10;    result[norm] = fileHash&#10;  }&#10;&#10;  <span class="hljs-keyword">return</span> result&#10;}&#10;</code></pre><p>Indeed, very simple. But you can already smell the issue : it’s slow. <em>Very</em> slow 🐌.<br>The more bytes you have to read &amp; hash, the longer it takes (roughly <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="script">O</mi><mo stretchy="false">(</mo><mi>t</mi><mi>o</mi><mi>t</mi><mi>a</mi><mi>l</mi><mi>B</mi><mi>y</mi><mi>t</mi><mi>e</mi><mi>s</mi><mi>R</mi><mi>e</mi><mi>a</mi><mi>d</mi><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">\mathcal{O}(totalBytesRead)</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathcal" style="margin-right:0.0278em;">O</span><span class="mopen">(</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mord mathnormal" style="margin-right:0.0502em;">B</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span><span class="mord mathnormal">t</span><span class="mord mathnormal">es</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal">d</span><span class="mclose">)</span></span></span></span> + some overhead).</p><h2 id="level-2-streaming-like-we-re-netflix-the-vibe-coder" tabindex="-1"><a class="header-anchor" href="#level-2-streaming-like-we-re-netflix-the-vibe-coder">Level 2 : Streaming like we’re Netflix (the vibe coder)</a></h2><p>A new foe has entered the chat !<br>The vibe coder ran the program and it was slow as shit, so he asked his buddy Claude Code to “make it faster, no mistakes plz ! 🥺”.<br>And this is where the <em>false</em> good idea comes in : <strong>streaming the files</strong>.<br>You see, one bottleneck here could be that we’re waiting for the file to be entirely loaded into memory before we can compute its hash. And if there’s a <strong>massive</strong> file to process, loading it from disk into memory could take some time, slowing the program down. In that case, streaming can absolutely help with memory and responsiveness, Node.js streams are literally built for chunked processing.<br>So here’s what <s>the vibe coder</s> Claudy came up with :</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:crypto&quot;</span>&#10;<span class="hljs-keyword">import</span> { createReadStream } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:fs&quot;</span>&#10;<span class="hljs-keyword">import</span> { join } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt; = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> result&#10;  }&#10;&#10;  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> file <span class="hljs-keyword">of</span> fileList) {&#10;    <span class="hljs-keyword">const</span> norm = <span class="hljs-title function_">displayPath</span>(file)&#10;    <span class="hljs-keyword">const</span> fullPath = <span class="hljs-title function_">join</span>(dir, file)&#10;    <span class="hljs-keyword">const</span> h = <span class="hljs-title function_">createHash</span>(<span class="hljs-string">&quot;sha256&quot;</span>)&#10;&#10;    h.<span class="hljs-title function_">update</span>(norm)&#10;&#10;    <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">void</span>&gt;(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {&#10;      <span class="hljs-keyword">const</span> stream = <span class="hljs-title function_">createReadStream</span>(fullPath)&#10;&#10;      stream.<span class="hljs-title function_">on</span>(<span class="hljs-string">&quot;data&quot;</span>, <span class="hljs-function">(<span class="hljs-params">chunk</span>) =&gt;</span> h.<span class="hljs-title function_">update</span>(chunk))&#10;      stream.<span class="hljs-title function_">on</span>(<span class="hljs-string">&quot;error&quot;</span>, reject)&#10;      stream.<span class="hljs-title function_">on</span>(<span class="hljs-string">&quot;end&quot;</span>, <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">resolve</span>())&#10;    })&#10;&#10;    result[norm] = h.<span class="hljs-title function_">digest</span>(<span class="hljs-string">&quot;hex&quot;</span>)&#10;  }&#10;&#10;  <span class="hljs-keyword">return</span> result&#10;}&#10;</code></pre><p>When presented with the opportunity to make the code faster, nearly all AI models will suggest file streaming (GPT models more aggressively than others btw).<br>But why is this a bad idea ? Well it would be better <em>if</em> we were processing <strong>massive files</strong>. However the majority of them are code files, so text, and at best we have some fonts, images, maybe 1 or 2 demo videos laying around in the repo… So the event/chunk overhead isn’t “free”, and definitely not enough to be beneficial.</p><div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">Note</p><p>Btw, <a href="https://github.com/EDM115/monorepo-hash#rocket-benchmarks" target="_blank" rel="noopener noreferrer">benchmarks</a> show that the extra time it takes to create a stream of data and then feeding it to the hashing function is overall worse every time, and gets even worse on very large repos, up to twice as slow (check version <code class="hljs"><span class="hljs-attribute">1</span>.<span class="hljs-number">2</span>.<span class="hljs-number">0</span></code>).</p></div><h2 id="level-3-parallelism-ftw-junior-dev" tabindex="-1"><a class="header-anchor" href="#level-3-parallelism-ftw-junior-dev">Level 3 : Parallelism ftw ! (junior dev)</a></h2><p>The junior dev is tasked with making this function faster, and he thinks (rightfully) that it’s the perfect time to implement something he learned recently : <strong>parallelism</strong>.<br>Instead of processing files sequentially, we will process <strong>all of them</strong> at the same time !</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:crypto&quot;</span>&#10;<span class="hljs-keyword">import</span> { readFile } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:fs/promises&quot;</span>&#10;<span class="hljs-keyword">import</span> { join } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;  }&#10;&#10;  <span class="hljs-keyword">const</span> entries = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>(&#10;    fileList.<span class="hljs-title function_">map</span>(<span class="hljs-title function_">async</span> (file) =&gt; {&#10;      <span class="hljs-keyword">const</span> norm = <span class="hljs-title function_">displayPath</span>(file)&#10;      <span class="hljs-keyword">const</span> fullPath = <span class="hljs-title function_">join</span>(dir, file)&#10;      <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> <span class="hljs-title function_">readFile</span>(fullPath)&#10;&#10;      <span class="hljs-keyword">const</span> fileHash = <span class="hljs-title function_">createHash</span>(<span class="hljs-string">&quot;sha256&quot;</span>)&#10;        .<span class="hljs-title function_">update</span>(norm)&#10;        .<span class="hljs-title function_">update</span>(content)&#10;        .<span class="hljs-title function_">digest</span>(<span class="hljs-string">&quot;hex&quot;</span>)&#10;&#10;      <span class="hljs-keyword">return</span> [norm, fileHash] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>&#10;    }),&#10;  )&#10;&#10;  <span class="hljs-keyword">return</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(entries) <span class="hljs-keyword">as</span> <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&#10;}&#10;</code></pre><p>And indeed, this <strong>is</strong> way faster !<br>However, it comes with some issues :</p><ul><li>We <em>might</em> completely saturate the I/O queue of Node.js. You see, you can queue up a ridiculous amount of filesystem work. In Node.js, filesystem operations are backed by libuv’s <a href="https://docs.libuv.org/en/latest/threadpool.html" target="_blank" rel="noopener noreferrer"><code class="hljs"><span class="hljs-attribute">threadpool</span></code></a> (with a default size of 4), so you can easily create pressure without actually getting infinite throughput</li><li>The disk’s cache could be quickly exhausted, drastically affecting <s>fishing season</s> read performance</li><li>With enough files being processed/things running outside of this script, we can just fill all the available memory, causing the program to crash, let alone running into file descriptor limits (hello <code class="hljs"><span class="hljs-attribute">EMFILE</span></code> 👋)</li></ul><h2 id="level-4-batching-our-way-in-mid-level-dev" tabindex="-1"><a class="header-anchor" href="#level-4-batching-our-way-in-mid-level-dev">Level 4 : Batching our way in (mid-level dev)</a></h2><p>The project grew and the mid-level dev noticed the script crashing in the pipeline, so he decided to take matters into his own hands.<br>Instead of raw parallelism, we will process <strong>batches</strong> of files :</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:crypto&quot;</span>&#10;<span class="hljs-keyword">import</span> { readFile } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:fs/promises&quot;</span>&#10;<span class="hljs-keyword">import</span> { join } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt; = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;  <span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CONCURRENCY</span> = <span class="hljs-number">100</span>&#10;&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> result&#10;  }&#10;&#10;  <span class="hljs-comment">// Pre-normalize paths to avoid repeated split/join</span>&#10;  <span class="hljs-keyword">const</span> normalized = fileList.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">rel</span>) =&gt;</span> [&#10;    rel,&#10;    <span class="hljs-title function_">displayPath</span>(rel),&#10;  ])&#10;&#10;  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; normalized.<span class="hljs-property">length</span>; i += <span class="hljs-variable constant_">CONCURRENCY</span>) {&#10;    <span class="hljs-keyword">const</span> batch = normalized.<span class="hljs-title function_">slice</span>(i, i + <span class="hljs-variable constant_">CONCURRENCY</span>)&#10;&#10;    <span class="hljs-comment">// oxlint-disable-next-line no-await-in-loop : Needed to not blow up memory with too many concurrent reads</span>&#10;    <span class="hljs-keyword">const</span> partial = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>(batch.<span class="hljs-title function_">map</span>(<span class="hljs-title function_">async</span> ([ rel, norm ]) =&gt; {&#10;      <span class="hljs-keyword">const</span> fullPath = <span class="hljs-title function_">join</span>(dir, rel)&#10;      <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> <span class="hljs-title function_">readFile</span>(fullPath)&#10;      <span class="hljs-keyword">const</span> fileHash = <span class="hljs-title function_">createHash</span>(<span class="hljs-string">&quot;sha256&quot;</span>)&#10;        .<span class="hljs-title function_">update</span>(norm)&#10;        .<span class="hljs-title function_">update</span>(content)&#10;        .<span class="hljs-title function_">digest</span>(<span class="hljs-string">&quot;hex&quot;</span>)&#10;&#10;      <span class="hljs-keyword">return</span> [ norm, fileHash ] <span class="hljs-keyword">as</span> [<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>]&#10;    }))&#10;&#10;    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [ norm, partialHash ] <span class="hljs-keyword">of</span> partial) {&#10;      result[norm] = partialHash&#10;    }&#10;  }&#10;&#10;  <span class="hljs-keyword">return</span> result&#10;}&#10;</code></pre><p>Here we have the <strong>best</strong> of both worlds : process X files at once, but don’t load way too many files from that damn repo into memory !<br>Now where does that magic number <code class="hljs">100</code> come from ? Saying that I pulled it out of my ass wouldn’t be <em>that</em> far-fetched, but it comes down mostly to tests. Too low and you hurt perf, too high and you blow up memory. It felt like a decent compromise after multiple test runs.<br>Does pre-normalizing the paths actually help ? <em>Who knows</em> 🤷‍♂️.<br>Is there a cleaner way to write this ? <em>Probably</em> 🫣.</p><h2 id="level-5-proper-concurrency-senior-dev" tabindex="-1"><a class="header-anchor" href="#level-5-proper-concurrency-senior-dev">Level 5 : Proper concurrency (senior dev)</a></h2><p>During runs, the senior dev notices some inconsistencies in execution times and decides to take a look at the script. And he has an idea to make the process even faster : a <strong>worker pool</strong>.<br>Basically, instead of taking 100 files out of the list, processing them all, <em>then</em> taking the next 100 files and repeating over and over again, we create 100 queues of files (workers) to be processed with only 1 spot available. Then, each file on the list gets assigned to the first free spot.<br>In other words, instead of “100 at a time, then wait for the slowest one”, we keep <strong>100 workers</strong> busy. When one finishes, it grabs the next file :</p><pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:crypto&quot;</span>&#10;<span class="hljs-keyword">import</span> { readFile } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:fs/promises&quot;</span>&#10;<span class="hljs-keyword">import</span> { join } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;node:path&quot;</span>&#10;&#10;<span class="hljs-comment">/**&#10; * Map over an array with a concurrency limit&#10; * <span class="hljs-doctag">@param</span> items The array of items to process&#10; * <span class="hljs-doctag">@param</span> limit The maximum number of concurrent operations&#10; * <span class="hljs-doctag">@param</span> fn The async function to apply to each item&#10; * <span class="hljs-doctag">@returns</span> A promise that resolves to an array of results&#10; */</span>&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> mapLimit&lt;T, R&gt;(&#10;  <span class="hljs-attr">items</span>: T[],&#10;  <span class="hljs-attr">limit</span>: <span class="hljs-built_in">number</span>,&#10;  <span class="hljs-attr">fn</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">item</span>: T</span>) =&gt;</span> <span class="hljs-title class_">Promise</span>&lt;R&gt;,&#10;): <span class="hljs-title class_">Promise</span>&lt;R[]&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">results</span>: R[] = <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>({ <span class="hljs-attr">length</span>: items.<span class="hljs-property">length</span> })&#10;  <span class="hljs-keyword">let</span> idx = <span class="hljs-number">0</span>&#10;&#10;  <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">worker</span>(<span class="hljs-params"></span>) {&#10;    <span class="hljs-keyword">while</span> (idx &lt; items.<span class="hljs-property">length</span>) {&#10;      <span class="hljs-keyword">const</span> current = idx++&#10;&#10;      <span class="hljs-comment">// oxlint-disable-next-line no-await-in-loop</span>&#10;      results[current] = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fn</span>(items[current])&#10;    }&#10;  }&#10;&#10;  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>(<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>({ <span class="hljs-attr">length</span>: <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">min</span>(limit, items.<span class="hljs-property">length</span>) }, worker))&#10;&#10;  <span class="hljs-keyword">return</span> results&#10;}&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">computePerFileHashes</span>(<span class="hljs-params">&#10;  <span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span>,&#10;  <span class="hljs-attr">fileList</span>: <span class="hljs-built_in">string</span>[],&#10;</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;&gt; {&#10;  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt; = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">create</span>(<span class="hljs-literal">null</span>)&#10;  <span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CONCURRENCY</span> = <span class="hljs-number">100</span>&#10;&#10;  <span class="hljs-keyword">if</span> (fileList.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {&#10;    <span class="hljs-keyword">return</span> result&#10;  }&#10;&#10;  <span class="hljs-keyword">const</span> entries = <span class="hljs-keyword">await</span> <span class="hljs-title function_">mapLimit</span>(fileList, <span class="hljs-variable constant_">CONCURRENCY</span>, <span class="hljs-title function_">async</span> (file) =&gt; {&#10;    <span class="hljs-keyword">const</span> norm = <span class="hljs-title function_">displayPath</span>(file)&#10;    <span class="hljs-keyword">const</span> fullPath = <span class="hljs-title function_">join</span>(dir, file)&#10;    <span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> <span class="hljs-title function_">readFile</span>(fullPath)&#10;    <span class="hljs-keyword">const</span> fileHash = <span class="hljs-title function_">createHash</span>(<span class="hljs-string">&quot;sha256&quot;</span>)&#10;      .<span class="hljs-title function_">update</span>(norm)&#10;      .<span class="hljs-title function_">update</span>(content)&#10;      .<span class="hljs-title function_">digest</span>(<span class="hljs-string">&quot;hex&quot;</span>)&#10;&#10;    <span class="hljs-keyword">return</span> [ norm, fileHash ] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>&#10;  })&#10;&#10;	<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [ norm, partialHash ] <span class="hljs-keyword">of</span> entries) {&#10;		result[norm] = partialHash&#10;	}&#10;&#10;  <span class="hljs-keyword">return</span> result&#10;}&#10;</code></pre><p>This is, so far, the best I could do.<br>In level 4, if one file in the batch takes forever to be processed, the other 99 are done and the next batch is just… waiting for <em>that one guy</em> 😤.<br>If that happens here, that file will clog <strong>one</strong> worker, but the rest keep going through the queue as if nothing ever happened.</p><h2 id="conclusion" tabindex="-1"><a class="header-anchor" href="#conclusion">Conclusion</a></h2><p>So those “5 levels” were basically :</p><ul><li><strong>Level 1</strong> : I did a <code class="hljs"><span class="hljs-attribute">for</span></code> loop and I’m proud of it ^^ (good boy 🫳)</li><li><strong>Level 2</strong> : STREAMS !!! (great for memory/huge files, not automatically faster for lots of small ones)</li><li><strong>Level 3</strong> : <code class="hljs">Promise.<span class="hljs-keyword">all</span>()</code> go brrrr (until your machine starts sweating 😰)</li><li><strong>Level 4</strong> : Oh shit, let’s not spawn thousands of reads at once (batching to do damage control)</li><li><strong>Level 5</strong> : Keep X workers busy and stop waiting for the slowest guy in the batch (worker pool/concurrency-limited queue)</li></ul><p>The main lesson for me : <strong>optimization is mostly about picking the right bottleneck to bully</strong>.<br>Sometimes the bottleneck is “you’re doing things one by one”, sometimes it’s “you’re doing <em>too much</em> at once”, and sometimes it’s “you added complexity because it felt fast”, which is apparently what I’m really good at doing 🥹.<br>Also : streaming is not a magic “go faster” button. If your files are mostly small text blobs, you might just be paying overhead to feel productive 🤡.<br>If you take one thing from this post : <strong>measure first, meme later</strong> (ok fine, measure <em>and</em> meme, but in that order 😉).<br>Anyway, the current version (level 5) is where I landed, and it’s the best combo I’ve found so far between “fast”, “doesn’t explode in CI”, and “I can still read this code without crying” (sorta kinda 🥲).<br>If you want the actual numbers/setup, the repo has benchmarks and the rest of the context. And if you’ve got a better trick… send it, I’m ready to over-optimize this again for no reason whatsoever 😎.</p>]]></content:encoded>
            <category>project</category>
            <category>optimization</category>
            <category>performance</category>
            <category>cli</category>
            <category>typescript</category>
        </item>
        <item>
            <title><![CDATA[How I migrated the company monorepo to Zod v4 during my internship]]></title>
            <link>https://edm115.eu.org/blog/2025/05/30/how-i-migrated-to-zod-4</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2025/05/30/how-i-migrated-to-zod-4</guid>
            <pubDate>Fri, 30 May 2025 12:00:00 GMT</pubDate>
            <description><![CDATA[Here's a quick 0 bs guide on the main takeaways from my experience migrating a large monorepo to Zod v4 during my internship, including practical tips and code examples.]]></description>
            <content:encoded><![CDATA[<h1 id="how-i-migrated-the-company-monorepo-to-zod-v4-during-my-internship" tabindex="-1"><a class="header-anchor" href="#how-i-migrated-the-company-monorepo-to-zod-v4-during-my-internship">How I migrated the company monorepo to Zod v4 during my internship</a></h1><p>We all know <a href="https://zod.dev/?id=introduction" target="_blank" rel="noopener noreferrer">Zod</a>, an awesome library to validate data by type-checking, pattern-matching and more.<br>And recently the long-awaited version 4 has been released, and as the repo I worked on during my internship used it, I thought I’ll take care to do this migration before the end of my internship, and learn Zod in the process !<br>You know, I believe that the companies I work at could easily call me <strong>the migrator</strong>, as during my 2 internships, my first task was to migrate all dependencies to their latest versions (often migrating whole frameworks and dealing with lots of deprecations).<br>I don’t mind doing this, and as I’m very methodic with the upgrades (always checking the release notes/changelogs/diffs if there’s nothing) and I thoughtfully test changes, so everything goes buttery smooth. I also leave notes and easy tips for migration in the other dev’s branches.<br>Anyway, the monorepo I’m talking about uses Zod in 2 main ways :</p><ul><li>types definition</li><li>schema validation</li></ul><p>For the first one, they simply define types in a shared <code class="hljs"><span class="hljs-attribute">types</span></code> package, and these types can later be reused in the backends, frontend and more. For example :</p><pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> deviceDetails = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">id</span>: z.<span class="hljs-title function_">string</span>(),&#10;  <span class="hljs-attr">name</span>: z.<span class="hljs-title function_">string</span>(),&#10;  <span class="hljs-attr">createdAt</span>: z.<span class="hljs-title function_">date</span>(),&#10;  <span class="hljs-attr">lastMessage</span>: z.<span class="hljs-title function_">date</span>().<span class="hljs-title function_">nullable</span>().<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">siteId</span>: z.<span class="hljs-title function_">number</span>(),&#10;  <span class="hljs-attr">enable</span>: z.<span class="hljs-title function_">boolean</span>(),&#10;  <span class="hljs-attr">serialNumber</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">nullable</span>().<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">deviceType</span>: z&#10;    .<span class="hljs-title function_">object</span>({&#10;      <span class="hljs-attr">id</span>: z.<span class="hljs-title function_">number</span>(),&#10;      <span class="hljs-attr">name</span>: z.<span class="hljs-title function_">string</span>(),&#10;    })&#10;    .<span class="hljs-title function_">nullable</span>()&#10;    .<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">deviceStatus</span>: z&#10;    .<span class="hljs-title function_">object</span>({&#10;      <span class="hljs-attr">id</span>: z.<span class="hljs-title function_">number</span>(),&#10;      <span class="hljs-attr">name</span>: z.<span class="hljs-title function_">string</span>(),&#10;    })&#10;    .<span class="hljs-title function_">nullable</span>()&#10;    .<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">health</span>: z&#10;    .<span class="hljs-title function_">object</span>({&#10;      <span class="hljs-attr">network</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;      <span class="hljs-attr">status</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;      <span class="hljs-attr">config</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;    })&#10;    .<span class="hljs-title function_">optional</span>(),&#10;})&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> <span class="hljs-title class_">DeviceDetailsType</span> = z.<span class="hljs-property">infer</span>&lt;<span class="hljs-keyword">typeof</span> deviceDetails&gt;&#10;</code></pre><p>This way, we have a programmatic way to define a type, and we have a handy Zod object that goes along with it.<br>The second way basically references the Zod object (<code class="hljs"><span class="hljs-attribute">deviceDetails</span></code>) as a way to validate an API route (either input or output) with <code class="hljs"><span class="hljs-attribute">fastify-zod</span></code>.</p><h2 id="part-0-the-upgrades" tabindex="-1"><a class="header-anchor" href="#part-0-the-upgrades">Part 0 : The upgrades</a></h2><p>The release of Zod v4 came weeks after I already did a massive pass of upgrades so I didn’t had much else to look out for (and I regularly kept dependencies up to date each week).<br>Upon reading the release notes and migration guide, I instantly saw that Zod now supports generating <a href="https://zod.dev/json-schema" target="_blank" rel="noopener noreferrer">JSON Schemas</a> ! This is a great news, because it allows us to drop the unmaintained <a href="https://github.com/elierotenberg/fastify-zod" target="_blank" rel="noopener noreferrer"><code class="hljs"><span class="hljs-attribute">fastify-zod</span></code></a> library (and we would finally drop the override on <code class="hljs"><span class="hljs-attribute">fastify</span></code> 😅).<br><br></p><p>Here’s how API validation worked before :</p><ol><li>Define your schema</li></ol><pre><code class="hljs language-typescript"><span class="hljs-comment">// packages/types/src/thing.ts</span>&#10;<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;zod&quot;</span>&#10;&#10;<span class="hljs-keyword">const</span> somethingSchema = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">value</span>: z.<span class="hljs-title function_">string</span>(),&#10;  <span class="hljs-attr">other_value</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;})&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> thingSchemas = {&#10;  <span class="hljs-comment">// ...</span>&#10;  somethingSchema,&#10;}&#10;</code></pre><ol start="2"><li>Grab your schemas for your individual route</li></ol><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/modules/thing/thing.schema.ts</span>&#10;<span class="hljs-keyword">import</span> { buildJsonSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;fastify-zod&quot;</span>&#10;&#10;<span class="hljs-keyword">import</span> { thingSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;@company/types/thing&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> { <span class="hljs-attr">schemas</span>: refThingSchemas, $ref } = <span class="hljs-title function_">buildJsonSchemas</span>(&#10;  {&#10;    ...thingSchemas,&#10;  },&#10;  { <span class="hljs-attr">$id</span>: <span class="hljs-string">&quot;thingSchemas&quot;</span> },&#10;)&#10;</code></pre><ol start="3"><li>Register your schemas for the server</li></ol><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/server.ts</span>&#10;<span class="hljs-keyword">import</span> { refThingSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;./modules/thing/thing.schema&quot;</span>&#10;&#10;<span class="hljs-comment">// Init Fastify and all...</span>&#10;<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> schema <span class="hljs-keyword">of</span> [&#10;  <span class="hljs-comment">// ...</span>&#10;  refThingSchemas,&#10;]) {&#10;  fastify.<span class="hljs-title function_">addSchema</span>(schema)&#10;}&#10;</code></pre><ol start="4"><li>Use in your route</li></ol><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/modules/thing/thing.route.ts</span>&#10;<span class="hljs-keyword">import</span> { $ref <span class="hljs-keyword">as</span> refThingSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;./thing.schema&quot;</span>&#10;&#10;<span class="hljs-keyword">const</span> <span class="hljs-attr">thingRoutes</span>: <span class="hljs-title class_">FastifyPluginCallback</span>&lt;{ <span class="hljs-attr">prefix</span>: <span class="hljs-built_in">string</span> }&gt; = <span class="hljs-function">(<span class="hljs-params">&#10;  fastify,&#10;  _options,&#10;  done,&#10;</span>) =&gt;</span> {&#10;  fastify.<span class="hljs-title function_">get</span>(<span class="hljs-string">&quot;/&quot;</span>, {&#10;    <span class="hljs-attr">schema</span>: {&#10;      <span class="hljs-attr">response</span>: {&#10;        <span class="hljs-number">200</span>: <span class="hljs-title function_">refThingSchemas</span>(<span class="hljs-string">&quot;somethingSchema&quot;</span>),&#10;      },&#10;    },&#10;    <span class="hljs-attr">handler</span>: getThingHandler,&#10;  })&#10;}&#10;</code></pre><p>It works, but we can do better.</p><h2 id="part-1-a-new-package" tabindex="-1"><a class="header-anchor" href="#part-1-a-new-package">Part 1 : A new package</a></h2><p>Well, not exactly.<br>To use Zod v4, all you have to do is to use the latest version, and change your imports :</p><pre><code class="hljs language-diff"><span class="hljs-deletion">- import { z } from &quot;zod&quot;</span>&#10;<span class="hljs-addition">+ import { z } from &quot;zod/v4&quot;</span>&#10;</code></pre><p>This will yield all deprecation warnings and errors so fix them first :)</p><div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">Note</p><p>Although it is in a subpath, it isn’t possible to keep 2 versions of Zod on the same codebase.</p></div><p>Now that we’re done with this, it’s time to use the new JSON Schema converter !</p><h2 id="part-2-building-the-schemas" tabindex="-1"><a class="header-anchor" href="#part-2-building-the-schemas">Part 2 : Building the schemas</a></h2><p>Surprisingly, the amount of code to change for it to work isn’t that big.<br>But first off, we have to talk about another new feature of Zod v4 : <a href="https://zod.dev/metadata" target="_blank" rel="noopener noreferrer">Metadata and registries</a>.<br>When Fastify wants to validate a schema, it needs a way to identify it. It is done through the <code class="hljs"><span class="hljs-meta"><span class="hljs-keyword">$id</span></span></code> property in the schema itself. To add this information, adding metadata seems the best way to go about it.<br><br></p><p>Here’s how you do it :</p><pre><code class="hljs language-typescript"><span class="hljs-comment">// packages/types/src/thing.ts</span>&#10;<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;zod&quot;</span>&#10;&#10;<span class="hljs-keyword">const</span> somethingSchema = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">value</span>: z.<span class="hljs-title function_">string</span>(),&#10;  <span class="hljs-attr">other_value</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;}).<span class="hljs-title function_">meta</span>({ <span class="hljs-attr">$id</span>: <span class="hljs-string">&quot;something&quot;</span> }) <span class="hljs-comment">// I preferred to drop the &quot;schema&quot; part of the name to remove redundancy in the code</span>&#10;&#10;<span class="hljs-keyword">export</span> thingSchemas = [&#10;  <span class="hljs-comment">// ...</span>&#10;  somethingSchema,&#10;] <span class="hljs-comment">// Note how we switch from an object to an array, this will allow for easier parsing down the line, but if you needed to access thingSchemas.somethingSchema, just export somethingSchema directly from now on</span>&#10;</code></pre><p>Easy, <em>right</em> ?<br>Well, <strong>no</strong>.<br>The <code class="hljs"><span class="hljs-title">.meta()</span></code> method is a shorthand for <code class="hljs"><span class="hljs-selector-class">.register</span>(z<span class="hljs-selector-class">.globalRegistry</span>, { ... })</code>, which means that the metadata you pass in is registered <em>globally</em>, and as such, must be <strong>unique</strong>.<br>You cannot have 2 schemas with the same <code class="hljs"><span class="hljs-meta"><span class="hljs-keyword">$id</span></span></code>, even if they’re in different files and used in different backends.</p><div class="markdown-alert markdown-alert-tip"><p class="markdown-alert-title">Tip</p><p>To circumvent this, a better approach is to create a registry per service, or even per route !<br>You can do it like this, I just didn’t bothered :</p><pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> backendRegistry = z.<span class="hljs-property">registry</span>&lt;{ <span class="hljs-attr">$id</span>: <span class="hljs-built_in">string</span> }&gt;()&#10;&#10;backendRegistry.<span class="hljs-title function_">add</span>(somethingSchema, { <span class="hljs-attr">$id</span>: <span class="hljs-string">&quot;something&quot;</span> })&#10;</code></pre></div><div class="markdown-alert markdown-alert-caution"><p class="markdown-alert-title">Caution</p><p>Also, here’s a fun thing (is it a bug ?) : <strong>A schema with metadata can’t contain another schema with metadata</strong>.<br>Or, Zod won’t complain, but Fastify will when registering them, as it will deeply check the schemas, and will try to register the inner schema after it already has been declared.<br>The only way I found to fix it is to create a variant with no metadata :</p><pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> somethingSchemaNoMeta = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">value</span>: z.<span class="hljs-title function_">string</span>(),&#10;  <span class="hljs-attr">other_value</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">optional</span>(),&#10;})&#10;<span class="hljs-keyword">const</span> somethingSchema = somethingSchemaNoMeta.<span class="hljs-title function_">meta</span>({ <span class="hljs-attr">$id</span>: <span class="hljs-string">&quot;something&quot;</span> })&#10;&#10;<span class="hljs-keyword">const</span> nestedSchema = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">id</span>: z.<span class="hljs-title function_">number</span>(),&#10;  <span class="hljs-attr">something</span>: somethingSchemaNoMeta,&#10;})&#10;</code></pre></div><p>Once your schemas are ready to be used, you have to, well, use them.</p><h2 id="part-3-using-the-schemas" tabindex="-1"><a class="header-anchor" href="#part-3-using-the-schemas">Part 3 : Using the schemas</a></h2><p>Remember when we had to use <code class="hljs"><span class="hljs-attribute">fastify-zod</span></code> ?<br>Well no more, here’s how simple it gets (I recommend to create a helper function like I did) :</p><pre><code class="hljs language-typescript"><span class="hljs-comment">// packages/types/src/schemaHelper.ts</span>&#10;<span class="hljs-keyword">import</span> { toJSONSchema, <span class="hljs-keyword">type</span> <span class="hljs-title class_">ZodType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;zod/v4&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">zodSchemasToJSONSchema</span>(<span class="hljs-params"><span class="hljs-attr">schemas</span>: <span class="hljs-title class_">ZodType</span>[]</span>) {&#10;  <span class="hljs-keyword">const</span> jsonSchemas = schemas.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">schema</span>) =&gt;</span> {&#10;    <span class="hljs-keyword">return</span> <span class="hljs-title function_">toJSONSchema</span>(schema, {&#10;      <span class="hljs-attr">target</span>: <span class="hljs-string">&quot;draft-7&quot;</span>, <span class="hljs-comment">// Fastify acccepts this format only, and it isn&#x27;t the default for Zod</span>&#10;      <span class="hljs-attr">unrepresentable</span>: <span class="hljs-string">&quot;any&quot;</span>, <span class="hljs-comment">// Accepts some types impossible to represent, check the docs for more info</span>&#10;    })&#10;  })&#10;&#10;  <span class="hljs-keyword">return</span> jsonSchemas&#10;}&#10;</code></pre><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/modules/thing/thing.schema.ts</span>&#10;<span class="hljs-keyword">import</span> { zodSchemasToJSONSchema } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;@company/types/schemaHelper&quot;</span>&#10;&#10;<span class="hljs-keyword">import</span> { thingSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;@company/types/thing&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> refThingSchemas = <span class="hljs-title function_">zodSchemasToJSONSchema</span>(thingSchemas)&#10;</code></pre><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/server.ts</span>&#10;<span class="hljs-keyword">import</span> { refThingSchemas } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;./modules/thing/thing.schema&quot;</span>&#10;&#10;<span class="hljs-comment">// Init Fastify and all...</span>&#10;<span class="hljs-keyword">const</span> schemasList = [&#10;  <span class="hljs-comment">// ...</span>&#10;  ...<span class="hljs-title class_">Object</span>.<span class="hljs-title function_">values</span>(refThingSchemas),&#10;]&#10;&#10;<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> schema <span class="hljs-keyword">of</span> schemasList) {&#10;  fastify.<span class="hljs-title function_">addSchema</span>(schema)&#10;}&#10;</code></pre><pre><code class="hljs language-typescript"><span class="hljs-comment">// services/backend/src/modules/thing/thing.route.ts</span>&#10;<span class="hljs-keyword">const</span> <span class="hljs-attr">thingRoutes</span>: <span class="hljs-title class_">FastifyPluginCallback</span>&lt;{ <span class="hljs-attr">prefix</span>: <span class="hljs-built_in">string</span> }&gt; = <span class="hljs-function">(<span class="hljs-params">&#10;  fastify,&#10;  _options,&#10;  done,&#10;</span>) =&gt;</span> {&#10;  fastify.<span class="hljs-title function_">get</span>(<span class="hljs-string">&quot;/&quot;</span>, {&#10;    <span class="hljs-attr">schema</span>: {&#10;      <span class="hljs-attr">response</span>: {&#10;        <span class="hljs-number">200</span>: { <span class="hljs-attr">$ref</span>: <span class="hljs-string">&quot;something&quot;</span> }, <span class="hljs-comment">// no more imports !</span>&#10;      },&#10;    },&#10;    <span class="hljs-attr">handler</span>: getThingHandler,&#10;  })&#10;}&#10;</code></pre><h2 id="part-4-final-thoughts" tabindex="-1"><a class="header-anchor" href="#part-4-final-thoughts">Part 4 : Final thoughts</a></h2><p>As I was doing this migration, several things happened in Zod issues (when you have a package that’s this much used, obviously <a href="https://xkcd.com/1172/" target="_blank" rel="noopener noreferrer">changes will cause issues somewhere</a>), so here’s stuff I had to deal with :</p><ul><li>For some time, the <code class="hljs"><span class="hljs-meta"><span class="hljs-keyword">$schema</span></span></code> identifier was wrong, so I had to work around it :<pre><code class="hljs language-typescript"><span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> schema <span class="hljs-keyword">of</span> schemasList) {&#10;  fastify.<span class="hljs-title function_">addSchema</span>({&#10;    ...schema,&#10;    <span class="hljs-comment">// hack until https://github.com/colinhacks/zod/issues/4412 is fixed</span>&#10;    <span class="hljs-attr">$schema</span>: <span class="hljs-string">&quot;http://json-schema.org/draft-07/schema#&quot;</span>,&#10;  })&#10;}&#10;</code></pre></li><li>A change to how additional properties are handled caused an issue in one of the services of the monorepo, as Zod was used there merely to check some “required” properties in order to sort the processing depending on which “provider” sent us the data, but obviously there’s a ton of extra props that might be sent as well in the object, and they change regularly.<br>Here’s how to fix it :<pre><code class="hljs language-typescript"><span class="hljs-keyword">import</span> { core, toJSONSchema, <span class="hljs-keyword">type</span> <span class="hljs-title class_">ZodType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;zod/v4&quot;</span>&#10;&#10;<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">zodSchemasToJSONSchema</span>(<span class="hljs-params"><span class="hljs-attr">schemas</span>: <span class="hljs-title class_">ZodType</span>[]</span>) {&#10;  <span class="hljs-keyword">const</span> jsonSchemas = schemas.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">schema</span>) =&gt;</span> {&#10;    <span class="hljs-keyword">return</span> <span class="hljs-title function_">toJSONSchema</span>(schema, {&#10;      <span class="hljs-comment">// Allow to accept additional properties in objects</span>&#10;      <span class="hljs-title function_">override</span>(<span class="hljs-params">ctx</span>) {&#10;        <span class="hljs-keyword">const</span> def = (ctx.<span class="hljs-property">zodSchema</span> <span class="hljs-keyword">as</span> core.<span class="hljs-property">$ZodTypes</span>).<span class="hljs-property">_zod</span>.<span class="hljs-property">def</span>&#10;        <span class="hljs-keyword">if</span> (def.<span class="hljs-property">type</span> === <span class="hljs-string">&quot;object&quot;</span> &amp;&amp; !def.<span class="hljs-property">catchall</span>) {&#10;          ;(&#10;            ctx.<span class="hljs-property">jsonSchema</span> <span class="hljs-keyword">as</span> core.<span class="hljs-property">JSONSchema</span>.<span class="hljs-property">ObjectSchema</span>&#10;          ).<span class="hljs-property">additionalProperties</span> = <span class="hljs-literal">true</span>&#10;        }&#10;      },&#10;      <span class="hljs-comment">// another way is to set io: &quot;input&quot;, tho I&#x27;m unsure about this</span>&#10;      <span class="hljs-attr">target</span>: <span class="hljs-string">&quot;draft-7&quot;</span>,&#10;      <span class="hljs-attr">unrepresentable</span>: <span class="hljs-string">&quot;any&quot;</span>,&#10;    })&#10;  })&#10;&#10;  <span class="hljs-keyword">return</span> jsonSchemas&#10;}&#10;</code></pre></li><li>I encountered an interesting issue with Fastify : apparently it can’t get right tuples with “limits”. Here’s an example :<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> baseObject = z.<span class="hljs-title function_">object</span>({&#10;  <span class="hljs-attr">surface</span>: z&#10;    .<span class="hljs-title function_">object</span>({ <span class="hljs-attr">value</span>: z.<span class="hljs-title function_">number</span>(), <span class="hljs-attr">unitSource</span>: z.<span class="hljs-title function_">string</span>() })&#10;    .<span class="hljs-title function_">nullable</span>()&#10;    .<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">timezone</span>: z.<span class="hljs-title function_">string</span>().<span class="hljs-title function_">nullable</span>().<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">presenceHours</span>: z&#10;    .<span class="hljs-title function_">object</span>({&#10;      <span class="hljs-attr">timezone</span>: z.<span class="hljs-title function_">string</span>(),&#10;      <span class="hljs-attr">hours</span>: z.<span class="hljs-title function_">record</span>(&#10;        z.<span class="hljs-title function_">enum</span>([<span class="hljs-string">&quot;0&quot;</span>, <span class="hljs-string">&quot;1&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, <span class="hljs-string">&quot;3&quot;</span>, <span class="hljs-string">&quot;4&quot;</span>, <span class="hljs-string">&quot;5&quot;</span>, <span class="hljs-string">&quot;6&quot;</span>]),&#10;        z&#10;          .<span class="hljs-title function_">array</span>(&#10;            z.<span class="hljs-title function_">object</span>({&#10;              <span class="hljs-attr">start</span>: z.<span class="hljs-title function_">tuple</span>([&#10;                z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">0</span>).<span class="hljs-title function_">max</span>(<span class="hljs-number">23</span>),&#10;                z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">0</span>).<span class="hljs-title function_">max</span>(<span class="hljs-number">59</span>),&#10;              ]),&#10;              <span class="hljs-attr">stop</span>: z.<span class="hljs-title function_">tuple</span>([&#10;                z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">0</span>).<span class="hljs-title function_">max</span>(<span class="hljs-number">23</span>),&#10;                z.<span class="hljs-title function_">number</span>().<span class="hljs-title function_">min</span>(<span class="hljs-number">0</span>).<span class="hljs-title function_">max</span>(<span class="hljs-number">59</span>),&#10;              ]),&#10;            })&#10;          )&#10;          .<span class="hljs-title function_">min</span>(<span class="hljs-number">1</span>)&#10;      ),&#10;    })&#10;    .<span class="hljs-title function_">optional</span>(),&#10;  <span class="hljs-attr">reducedHours</span>: z.<span class="hljs-title function_">array</span>(z.<span class="hljs-title function_">tuple</span>([z.<span class="hljs-title function_">string</span>(), z.<span class="hljs-title function_">string</span>()])).<span class="hljs-title function_">optional</span>(),&#10;})&#10;&#10;<span class="hljs-keyword">const</span> extendedObject = baseObject&#10;  .<span class="hljs-title function_">extend</span>({&#10;    <span class="hljs-attr">siteId</span>: z.<span class="hljs-title function_">string</span>(),&#10;    <span class="hljs-attr">organizationId</span>: z.<span class="hljs-title function_">string</span>(),&#10;  })&#10;  .<span class="hljs-title function_">meta</span>({ <span class="hljs-attr">$id</span>: <span class="hljs-string">&quot;extendedObject&quot;</span> })&#10;</code></pre>This will yield the following error if your Fastify server is in strict TypeScript mode :<pre><code class="hljs language-logs">strict mode: &quot;items&quot; is 2-tuple, but minItems or maxItems/additionalItems are not specified or different at path &quot;extendedObject/properties/presenceHours/properties/hours/additionalProperties/items/properties/start&quot;&#10;strict mode: &quot;items&quot; is 2-tuple, but minItems or maxItems/additionalItems are not specified or different at path &quot;extendedObject/properties/presenceHours/properties/hours/additionalProperties/items/properties/stop&quot;&#10;strict mode: &quot;items&quot; is 2-tuple, but minItems or maxItems/additionalItems are not specified or different at path &quot;extendedObject/properties/reducedHours/items&quot;&#10;</code></pre>If this happens to you, add this in your Schema generator function before the return :<pre><code class="hljs language-typescript"><span class="hljs-comment">// oxlint-disable-next-line no-explicit-any (or eslint)</span>&#10;<span class="hljs-keyword">function</span> <span class="hljs-title function_">enforceTuples</span>(<span class="hljs-params"><span class="hljs-attr">obj</span>: <span class="hljs-built_in">any</span></span>) {&#10;  <span class="hljs-keyword">if</span> (obj &amp;&amp; <span class="hljs-keyword">typeof</span> obj === <span class="hljs-string">&quot;object&quot;</span>) {&#10;    <span class="hljs-keyword">if</span> (<span class="hljs-string">&quot;items&quot;</span> <span class="hljs-keyword">in</span> obj &amp;&amp; <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(obj.<span class="hljs-property">items</span>)) {&#10;      <span class="hljs-keyword">const</span> len = obj.<span class="hljs-property">items</span>.<span class="hljs-property">length</span>&#10;      obj.<span class="hljs-property">minItems</span> = len&#10;      obj.<span class="hljs-property">maxItems</span> = len&#10;    }&#10;&#10;    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> key <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">keys</span>(obj)) {&#10;      <span class="hljs-title function_">enforceTuples</span>(obj[key])&#10;    }&#10;  }&#10;}&#10;&#10;<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> schema <span class="hljs-keyword">of</span> jsonSchemas) {&#10;  <span class="hljs-title function_">enforceTuples</span>(schema)&#10;}&#10;</code></pre></li><li>This is not related to Zod but for posterity I wanted to mention it : Fastify will drop any schema in <code class="hljs"><span class="hljs-attribute">oneOf</span></code> and <code class="hljs"><span class="hljs-attribute">allOf</span></code> validations when they are too similar.<br>You can find more info about it here : <a href="https://github.com/fastify/fastify/issues/6133" target="_blank" rel="noopener noreferrer">https://github.com/fastify/fastify/issues/6133</a></li><li>Migrating to Zod v4 for JSON Schema creation and validation wasn’t hard and actually eases a lot of things !</li></ul>]]></content:encoded>
            <category>tutorial</category>
            <category>migration</category>
            <category>zod</category>
            <category>typescript</category>
        </item>
        <item>
            <title><![CDATA[How to (actually) send DTMF on Android without being the default call app]]></title>
            <link>https://edm115.eu.org/blog/2025/01/22/how-to-send-dtmf-on-android</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2025/01/22/how-to-send-dtmf-on-android</guid>
            <pubDate>Wed, 22 Jan 2025 12:00:00 GMT</pubDate>
            <description><![CDATA[Discover why sending DTMF tones on Android isn't as straightforward as it seems, and follow me on an adventure to the final workaround I made while developing an app for disabled users as a school project.]]></description>
            <content:encoded><![CDATA[<h1 id="how-to-actually-send-dtmf-on-android-without-being-the-default-call-app" tabindex="-1"><a class="header-anchor" href="#how-to-actually-send-dtmf-on-android-without-being-the-default-call-app">How to (actually) send DTMF on Android without being the default call app</a></h1><h3 id="what" tabindex="-1"><a class="header-anchor" href="#what">What ?</a></h3><p>Today, I will share my solution to a problem I recently encountered : <strong>sending DTMF inputs during a call</strong>.<br>Despite it seeming trivial, there’s actually <strong>no</strong> built-in solution for my use case, and I had to write something from scratch to make it work.<br>But, what <em>is</em> the use case ?</p><h2 id="a-bit-of-context-sir" tabindex="-1"><a class="header-anchor" href="#a-bit-of-context-sir">A bit of context, sir</a></h2><p>Recently, I worked on <a href="https://github.com/lifecompanionaac/lifecompanion" target="_blank" rel="noopener noreferrer">LifeCompanion</a>, an open-source, free, and highly customizable digital assistant. It supports individuals with motor, sensory, or cognitive disabilities by offering features like speech synthesis, virtual keyboards, pictographic communication, and compatibility with assistive devices. Developed since 2015, it promotes autonomy and social participation.<br>With some colleagues at my school, we were tasked to add a plugin to handle communication with an Android phone (send and read SMS, and take calls). This is a great force of LifeCompanion : being able to extend itself through plugins.</p><blockquote><p>To clarify, 2 teams of students made the original plugins a year prior (messages and calls parts), and we were tasked to merge them into a singular plugin.<br>Still, many features were missing, and DTMF was one of them.<br>Also, the code of the Android app is written in Kotlin but the implementation can be transferred to Java code.</p></blockquote><h2 id="but-what-is-dtmf" tabindex="-1"><a class="header-anchor" href="#but-what-is-dtmf">But what is DTMF ?</a></h2><p><a href="https://en.wikipedia.org/wiki/DTMF" target="_blank" rel="noopener noreferrer">DTMF</a>, aka <em>Dual-tone multi-frequency signaling</em> is a system that uses specific frequencies to convey a character. I won’t go over the Wikipedia page (y’all can read), but all you should know is that this system is still in use <strong>to this day</strong> when composing on the Keypad during a call (ex : when you’re prompted to enter your client number or to re listen your voice message).<br>And while this is an essential part of calling apps, the team that worked on the call part of the plugin didn’t implement it. So naturally, I decided to <a href="https://github.com/EDM115-org/lifecompanion/issues/22" target="_blank" rel="noopener noreferrer">do it</a>. What could possibly go wrong ?</p><h2 id="turns-out-theres-no-api-for-it" tabindex="-1"><a class="header-anchor" href="#turns-out-theres-no-api-for-it">Turns out, there’s no API for it</a></h2><p>Or, is it ?<br>Well, the class <code class="hljs">android.telecom.<span class="hljs-built_in">Call</span></code> have a method <a href="https://developer.android.com/reference/kotlin/android/telecom/Call#playdtmftone" target="_blank" rel="noopener noreferrer"><code class="hljs"><span class="hljs-function"><span class="hljs-title">playDtmfTone</span><span class="hljs-params">()</span></span></code></a> (and obviously <code class="hljs"><span class="hljs-function"><span class="hljs-title">stopDtmfTone</span><span class="hljs-params">()</span></span></code>). But to use these functions, you need to have access to the current Call object, and to do so you need to <a href="https://developer.android.com/reference/kotlin/android/telecom/InCallService#becoming-the-default-phone-app" target="_blank" rel="noopener noreferrer"><strong>be the default phone app</strong></a>. Although feasible, it would require to implement all features that users require from a regular call app (dialer, contacts list, call history, voicemail, …), and honestly we didn’t had the time nor the motivation (and imagine all the edge cases to handle !).<br>Obviously, I didn’t catched that so I started to write code that would <em>not</em> work…</p><blockquote><p>At this moment, it would be great for you to understand quickly how the app we created works.<br>Basically, the phone is connected with the PC through a cable, and we communicate via ADB.<br>When we need to request or send data to the phone, we start an intent with extra data, which is a Base64-ed JSON with fields explaining what we want to get/send.<br>The app then proceeds to pass the data to the relevant controller, which will do its magic, and if needed, output JSON to a specific folder on the phone that LifeCompanion will poll regularly to know if the response is ready to obtain.<br>So for example, if we want to send the DTMF “<code class="hljs">8</code>”, we will send from the pc <code class="hljs">adb shell am start-foreground-service -<span class="hljs-selector-tag">a</span> org<span class="hljs-selector-class">.lifecompanion</span><span class="hljs-selector-class">.phonecontrolapp</span><span class="hljs-selector-class">.services</span><span class="hljs-selector-class">.JSONProcessingService</span> <span class="hljs-attr">--es</span> extra_data eyJzZW5kZXIiOiAicGMiLCJ0eXBlIjogImNhbGwiLCJzdWJ0eXBlIjoibnVtcGFkX2lucHV0IiwiZGF0YSI6eyJkdG1mIjogIjgifX0=</code>, the extra data symbolizing this :</p><pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>&#10;  <span class="hljs-attr">&quot;sender&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;pc&quot;</span><span class="hljs-punctuation">,</span>&#10;  <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;call&quot;</span><span class="hljs-punctuation">,</span>&#10;  <span class="hljs-attr">&quot;subtype&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;numpad_input&quot;</span><span class="hljs-punctuation">,</span>&#10;  <span class="hljs-attr">&quot;data&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>&#10;    <span class="hljs-attr">&quot;dtmf&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;8&quot;</span>&#10;  <span class="hljs-punctuation">}</span>&#10;<span class="hljs-punctuation">}</span>&#10;</code></pre><p>The app will then process this, and pass the data to the <code class="hljs">services.C<span class="hljs-literal">all</span>Controller</code>, which will call the right function. Also at this point we don’t care if the DTMF has been sent correctly, so there’s no <code class="hljs"><span class="hljs-attribute">request_id</span></code> in the JSON, we don’t check if it has worked or not.<br>Finally, file paths for Kotlin files are relative to <code class="hljs">src<span class="hljs-regexp">/main/</span>java<span class="hljs-regexp">/org/</span>lifecompanion/phonecontrolapp</code> unless specified otherwise (ressource files for example).</p></blockquote><p>So, in the following code examples I will only focus on the DTMF-related pieces, leaving the rest of the Call and JSON processing logic aside. You can find more about the app itself <a href="https://github.com/EDM115-org/lifecompanion/tree/main/lifecompanion-plugins/lc-phonecontrol-plugin/android/" target="_blank" rel="noopener noreferrer">on the repo</a>.</p><h2 id="oh-the-misery" tabindex="-1"><a class="header-anchor" href="#oh-the-misery">Oh, the misery</a></h2><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/CallService.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;&#10;<span class="hljs-keyword">import</span> android.telecom.Call&#10;<span class="hljs-keyword">import</span> org.lifecompanion.phonecontrolapp.services.CallStateListener&#10;<span class="hljs-comment">// ...</span>&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">CallService</span> : <span class="hljs-type">Service</span>(), CallStateListener {&#10;    <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> currentCall: Call? = <span class="hljs-literal">null</span>&#10;    <span class="hljs-comment">// ...</span>&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onStartCommand</span><span class="hljs-params">(intent: <span class="hljs-type">Intent</span>, flags: <span class="hljs-type">Int</span>, startId: <span class="hljs-type">Int</span>)</span></span>: <span class="hljs-built_in">Int</span> {&#10;        <span class="hljs-comment">// ...</span>&#10;        CallWatcher.callStateListener = <span class="hljs-keyword">this</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCallStateChanged</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>?, isIncoming: <span class="hljs-type">Boolean</span>, isActive: <span class="hljs-type">Boolean</span>, phoneNumber: <span class="hljs-type">String</span>?)</span></span> {&#10;        currentCall = call&#10;        <span class="hljs-comment">// ...</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendDtmf</span><span class="hljs-params">(dtmf: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">if</span> (currentCall == <span class="hljs-literal">null</span>) {&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        <span class="hljs-keyword">try</span> {&#10;            currentCall?.playDtmfTone(dtmf)&#10;            <span class="hljs-comment">// Pause between tones to ensure proper transmission</span>&#10;            Thread.sleep(<span class="hljs-number">300</span>)&#10;            currentCall?.stopDtmfTone()&#10;        } <span class="hljs-keyword">catch</span> (e: Exception) {&#10;            <span class="hljs-comment">// womp womp</span>&#10;        }&#10;    }&#10;&#10;    <span class="hljs-comment">// ...</span>&#10;</code></pre><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/CallStateListener.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;<span class="hljs-keyword">import</span> android.telecom.Call&#10;&#10;<span class="hljs-keyword">interface</span> <span class="hljs-title class_">CallStateListener</span> {&#10;    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCallStateChanged</span><span class="hljs-params">(&#10;        call: <span class="hljs-type">Call</span>?,&#10;        isIncoming: <span class="hljs-type">Boolean</span>,&#10;        isActive: <span class="hljs-type">Boolean</span>,&#10;        phoneNumber: <span class="hljs-type">String</span>?&#10;    )</span></span>&#10;}&#10;</code></pre><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/CallWatcher.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;&#10;<span class="hljs-keyword">import</span> android.telecom.Call&#10;<span class="hljs-keyword">import</span> android.telecom.InCallService&#10;<span class="hljs-keyword">import</span> org.lifecompanion.phonecontrolapp.services.CallStateListener&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">CallWatcher</span> : <span class="hljs-type">InCallService</span>() {&#10;    <span class="hljs-keyword">companion</span> <span class="hljs-keyword">object</span> {&#10;        <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> <span class="hljs-keyword">val</span> TAG = <span class="hljs-string">&quot;CallWatcher&quot;</span>&#10;        <span class="hljs-keyword">var</span> callStateListener: CallStateListener? = <span class="hljs-literal">null</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCallAdded</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>)</span></span> {&#10;        <span class="hljs-keyword">super</span>.onCallAdded(call)&#10;        call.registerCallback(callStateCallback)&#10;        notifyStateChange(call)&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCallRemoved</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>)</span></span> {&#10;        <span class="hljs-keyword">super</span>.onCallRemoved(call)&#10;        call.unregisterCallback(callStateCallback)&#10;        notifyStateChange(<span class="hljs-literal">null</span>)&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> callStateCallback = <span class="hljs-keyword">object</span> : Call.Callback() {&#10;        <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onStateChanged</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>, state: <span class="hljs-type">Int</span>)</span></span> {&#10;            <span class="hljs-keyword">super</span>.onStateChanged(call, state)&#10;            notifyStateChange(call)&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">getPhoneNumber</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>?)</span></span>: String? {&#10;        <span class="hljs-keyword">return</span> call?.details?.handle?.schemeSpecificPart&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">notifyStateChange</span><span class="hljs-params">(call: <span class="hljs-type">Call</span>?)</span></span> {&#10;        <span class="hljs-keyword">val</span> isIncoming = call?.details?.state == Call.STATE_RINGING&#10;        <span class="hljs-keyword">val</span> isActive = call?.details?.state == Call.STATE_ACTIVE&#10;        <span class="hljs-keyword">val</span> phoneNumber = getPhoneNumber(call)&#10;        callStateListener?.onCallStateChanged(call, isIncoming, isActive, phoneNumber)&#10;    }&#10;}&#10;</code></pre><p>While this code <em>should</em> work (probably not), as you can see we extend <code class="hljs"><span class="hljs-function"><span class="hljs-title">InCallService</span><span class="hljs-params">()</span></span></code>, and that would require us to be the default phone app.</p><h2 id="everybody-wants-to-be-my-enemy" tabindex="-1"><a class="header-anchor" href="#everybody-wants-to-be-my-enemy">Everybody wants to be my enemy</a></h2><p>So because it didn’t worked, I decided to do everyone’s favorite activity : searching for documentation :)<br>What I found is crazy : devs <a href="https://issuetracker.google.com/issues/36906273" target="_blank" rel="noopener noreferrer">asked for this feature</a> all the way back in 2008 ! It was marked as <em>Won’t Fix (Obsolete)</em> in 2014, and even the <a href="https://android-review.googlesource.com/c/platform/frameworks/base/+/32820" target="_blank" rel="noopener noreferrer">patches</a> submitted by some devs were rejected in 2021.<br>From what I was able to <a href="https://groups.google.com/g/discuss-webrtc/c/9W-gsv4pARU" target="_blank" rel="noopener noreferrer">read</a>, it apparently is possible for an app to send DTMF tones but <a href="https://stackoverflow.com/a/10748408/18644204" target="_blank" rel="noopener noreferrer">only over VoIP</a>. But guess what ? It is <a href="https://developer.android.com/reference/kotlin/android/net/sip/SipAudioCall#senddtmf" target="_blank" rel="noopener noreferrer">deprecated</a> by now !<br><br></p><p>So, time for more searches ! And I browsed everyone’s second favorite website : StackOverflow (I come from a time where LLMs weren’t the meta).<br>And across all the related questions, I haven’t found any single answer :(</p><ul><li><a href="https://stackoverflow.com/questions/8870488/android-dtmf-send-tone-overriding/12986066" target="_blank" rel="noopener noreferrer">This one</a> suggests that we can send it at call time, but not <em>during</em> a call (and also <a href="https://stackoverflow.com/questions/2542014/how-do-i-send-dtmf-tones-and-pauses-using-android-action-call-intent-with-commas" target="_blank" rel="noopener noreferrer">that one</a>).</li><li><a href="https://stackoverflow.com/questions/10513233/working-with-dtmf-tones-in-android" target="_blank" rel="noopener noreferrer">This one</a> was about another technology (<a href="https://stackoverflow.com/questions/10754335/send-dtmf-tones-in-ongoing-call" target="_blank" rel="noopener noreferrer">that one</a> is the same question but with other answers that confirms what I said previously).</li><li><a href="https://stackoverflow.com/questions/5343756/problem-with-sending-dtmf-tones-from-android-app-over-an-active-call" target="_blank" rel="noopener noreferrer">This one</a> tells us that we can “fake” it by playing the frequency ourselves (but that work only when the speakerphone is enabled).</li><li><a href="https://stackoverflow.com/questions/34763971/how-to-send-dtmf-tone-programmatically-during-a-live-call-in-android" target="_blank" rel="noopener noreferrer">This</a> is the post that confirmed the <code class="hljs"><span class="hljs-function"><span class="hljs-title">playDtmfTone</span><span class="hljs-params">()</span></span></code> method.</li><li>And finally <a href="https://stackoverflow.com/questions/6342236/sending-dtmf-tones-over-the-uplink-in-call" target="_blank" rel="noopener noreferrer">this one</a> is a very interesting approach to the problem, although not working.</li></ul><p>Okay, so I guess it’s time for more broad searches, right ?</p><ul><li>A <a href="https://docs.pjsip.org/en/2.14/specific-guides/sip/dtmf.html" target="_blank" rel="noopener noreferrer">random library</a> that I found but for a completely separate project.</li><li>An <a href="https://github.com/rajeshincorp/UD_SendDtmfToneOverActiveCall" target="_blank" rel="noopener noreferrer">API</a> that someone tried to create but that doesn’t work sadly.</li><li>I <strong>know</strong> that we would need to <a href="https://www.b4x.com/android/forum/threads/send-dtmf-on-active-cellular-phone-call-fixed.76637/" target="_blank" rel="noopener noreferrer">be the default phone app</a> !</li><li>A lot of <a href="https://4x5mg.net/2019/11/17/sending-dtmf-with-your-smartphone/" target="_blank" rel="noopener noreferrer">tutorials</a> have suggested to use a separate app to emit the according frequencies (or for us, to embed them in the app).</li></ul><p>Hmm… What does LLMs have to say about this ?<br>Well all of them had either the brilliant idea to suggest <code class="hljs">android<span class="hljs-selector-class">.telecom</span><span class="hljs-selector-class">.Call</span><span class="hljs-selector-class">.startDtmfTone</span>()</code> but <strong>without</strong> telling me that we need to be the default phone app, or they would straight up hallucinate methods. Even GPT o1 (SOTA at the time) had trouble helping me in this task or provide alternate ways to handle it.</p><h2 id="the-revelation" tabindex="-1"><a class="header-anchor" href="#the-revelation">The revelation</a></h2><p>At this point I was convinced that it wasn’t feasible. <em>If</em> it wasn’t for <a href="https://stackoverflow.com/a/65868662/18644204" target="_blank" rel="noopener noreferrer">Andriy Antonov’s solution</a> !<br>This guy had a brilliant idea : Android phones have accessibility services, which allows to emulate clicks on the screen. Why not using them to click on the keypad buttons directly !<br>However, his solution had some flaws, notably the fact that we needed the screen coordinates of the buttons, and our app needed to work on all kind of Android devices (including tablets), which made this impossible. But hey, that’s nothing that code couldn’t solve… :)</p><div class="markdown-alert markdown-alert-note"><p class="markdown-alert-title">Note</p><p><strong>Update :</strong> After the publication of this blog post, Andriy shared more details about the backstory of his implementation in a <a href="https://www.linkedin.com/posts/andriiantonov_edm115-french-devstudentgamermusic-producer-activity-7288106936519049216-7V5w/?utm_source=edm115.dev" target="_blank" rel="noopener noreferrer">LinkedIn post</a>.</p></div><h2 id="time-to-lock-in" tabindex="-1"><a class="header-anchor" href="#time-to-lock-in">Time to lock in</a></h2><p>Here’s the very first implementation that I made :</p><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/DTMFAccessibilityService.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;&#10;<span class="hljs-keyword">import</span> android.accessibilityservice.AccessibilityService&#10;<span class="hljs-keyword">import</span> android.util.Log&#10;<span class="hljs-keyword">import</span> android.view.accessibility.AccessibilityEvent&#10;<span class="hljs-keyword">import</span> android.view.accessibility.AccessibilityNodeInfo&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">DTMFAccessibilityService</span> : <span class="hljs-type">AccessibilityService</span>() {&#10;    <span class="hljs-keyword">companion</span> <span class="hljs-keyword">object</span> {&#10;        <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> <span class="hljs-keyword">val</span> TAG = <span class="hljs-string">&quot;DTMFAccessibilityService&quot;</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onAccessibilityEvent</span><span class="hljs-params">(event: <span class="hljs-type">AccessibilityEvent</span>?)</span></span> {&#10;        <span class="hljs-keyword">if</span> (event == <span class="hljs-literal">null</span> || event.source == <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span>&#10;&#10;        Log.i(TAG, <span class="hljs-string">&quot;Accessibility event received: <span class="hljs-subst">${event.eventType}</span>&quot;</span>)&#10;        <span class="hljs-keyword">val</span> rootNode = rootInActiveWindow ?: <span class="hljs-keyword">return</span>&#10;&#10;        <span class="hljs-keyword">if</span> (isInCallScreen(rootNode)) {&#10;            Log.i(TAG, <span class="hljs-string">&quot;In call screen detected&quot;</span>)&#10;&#10;            <span class="hljs-keyword">if</span> (!isKeypadOpen(rootNode)) {&#10;                openKeypad(rootNode)&#10;            }&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isInCallScreen</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-comment">// Check for specific elements that indicate the call screen</span>&#10;        <span class="hljs-keyword">return</span> node.packageName?.contains(<span class="hljs-string">&quot;dialer&quot;</span>, ignoreCase = <span class="hljs-literal">true</span>) == <span class="hljs-literal">true</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isKeypadOpen</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-comment">// Check for the presence of specific keypad elements that are unique to the keypad</span>&#10;        <span class="hljs-keyword">val</span> keypadElements = listOf(<span class="hljs-string">&quot;1&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, <span class="hljs-string">&quot;3&quot;</span>, <span class="hljs-string">&quot;4&quot;</span>, <span class="hljs-string">&quot;5&quot;</span>, <span class="hljs-string">&quot;6&quot;</span>, <span class="hljs-string">&quot;7&quot;</span>, <span class="hljs-string">&quot;8&quot;</span>, <span class="hljs-string">&quot;9&quot;</span>, <span class="hljs-string">&quot;0&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>, <span class="hljs-string">&quot;#&quot;</span>)&#10;&#10;        <span class="hljs-keyword">return</span> keypadElements.any { element -&gt;&#10;            node.findAccessibilityNodeInfosByText(element).isNotEmpty()&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">openKeypad</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span> {&#10;        <span class="hljs-comment">// Find and click the button to open the keypad</span>&#10;        <span class="hljs-keyword">val</span> keypadButtonTexts = listOf(<span class="hljs-string">&quot;Keypad&quot;</span>, <span class="hljs-string">&quot;Clavier&quot;</span>)&#10;&#10;        <span class="hljs-keyword">for</span> (text <span class="hljs-keyword">in</span> keypadButtonTexts) {&#10;            <span class="hljs-keyword">val</span> keypadButton = node.findAccessibilityNodeInfosByText(text).firstOrNull()&#10;&#10;            <span class="hljs-keyword">if</span> (keypadButton != <span class="hljs-literal">null</span>) {&#10;                keypadButton.performAction(AccessibilityNodeInfo.ACTION_CLICK)&#10;                Log.i(TAG, <span class="hljs-string">&quot;Keypad opened with text: <span class="hljs-variable">$text</span>&quot;</span>)&#10;&#10;                <span class="hljs-keyword">return</span>&#10;            }&#10;        }&#10;&#10;        Log.i(TAG, <span class="hljs-string">&quot;Keypad button not found&quot;</span>)&#10;    }&#10;&#10;    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">pressKeypadButton</span><span class="hljs-params">(buttonText: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">val</span> rootNode = rootInActiveWindow ?: <span class="hljs-keyword">return</span>&#10;&#10;        <span class="hljs-keyword">if</span> (isInCallScreen(rootNode)) {&#10;            <span class="hljs-keyword">if</span> (!isKeypadOpen(rootNode)) {&#10;                openKeypad(rootNode)&#10;            }&#10;&#10;            searchAndClick(rootNode, buttonText)&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">searchAndClick</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>, buttonText: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">if</span> (node.text?.toString() == buttonText &amp;&amp; node.isClickable) {&#10;            node.performAction(AccessibilityNodeInfo.ACTION_CLICK)&#10;            Log.i(TAG, <span class="hljs-string">&quot;Clicked button: <span class="hljs-variable">$buttonText</span>&quot;</span>)&#10;&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until node.childCount) {&#10;            node.getChild(i)?.let { searchAndClick(it, buttonText) }&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onInterrupt</span><span class="hljs-params">()</span></span> {&#10;        Log.i(TAG, <span class="hljs-string">&quot;Accessibility Service Interrupted&quot;</span>)&#10;    }&#10;}&#10;</code></pre><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/CallService.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;&#10;<span class="hljs-keyword">import</span> android.content.Intent&#10;<span class="hljs-comment">// ...</span>&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">CallService</span> : <span class="hljs-type">Service</span>() {&#10;    <span class="hljs-comment">// ...</span>&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendDtmf</span><span class="hljs-params">(dtmf: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">val</span> intent = Intent(<span class="hljs-keyword">this</span>, DTMFAccessibilityService::<span class="hljs-keyword">class</span>.java)&#10;        startService(intent)&#10;&#10;        <span class="hljs-keyword">val</span> dtmfService = DTMFAccessibilityService()&#10;        dtmfService.pressKeypadButton(dtmf)&#10;    }&#10;}&#10;</code></pre><p>Obviously we need to ask for accessibility service privilege :</p><pre><code class="hljs language-kotlin"><span class="hljs-comment">// MainActivity.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp&#10;&#10;<span class="hljs-keyword">import</span> android.app.Activity&#10;<span class="hljs-keyword">import</span> android.app.AlertDialog&#10;<span class="hljs-keyword">import</span> android.content.Intent&#10;<span class="hljs-keyword">import</span> android.os.Bundle&#10;<span class="hljs-keyword">import</span> android.provider.Settings&#10;<span class="hljs-keyword">import</span> org.lifecompanion.phonecontrolapp.services.DTMFAccessibilityService&#10;<span class="hljs-comment">// ...</span>&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">MainActivity</span> : <span class="hljs-type">Activity</span>() {&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCreate</span><span class="hljs-params">(savedInstanceState: <span class="hljs-type">Bundle</span>?)</span></span> {&#10;        <span class="hljs-keyword">super</span>.onCreate(savedInstanceState)&#10;        setContentView(R.layout.activity_main)&#10;&#10;        <span class="hljs-comment">// ...</span>&#10;&#10;        promptEnableAccessibilityService()&#10;    }&#10;&#10;    <span class="hljs-comment">// ...</span>&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isAccessibilityServiceEnabled</span><span class="hljs-params">(service: <span class="hljs-type">Class</span>&lt;*&gt;)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-keyword">val</span> enabledServices = Settings.Secure.getString(&#10;            contentResolver,&#10;            Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES&#10;        ) ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>&#10;        <span class="hljs-keyword">val</span> colonSplitter = enabledServices.split(<span class="hljs-string">&quot;:&quot;</span>)&#10;        <span class="hljs-keyword">val</span> serviceName = componentName.flattenToString().replace(packageName, service.name)&#10;&#10;        <span class="hljs-keyword">return</span> colonSplitter.any { it.equals(serviceName, ignoreCase = <span class="hljs-literal">true</span>) }&#10;    }&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">promptEnableAccessibilityService</span><span class="hljs-params">()</span></span> {&#10;        <span class="hljs-keyword">if</span> (!isAccessibilityServiceEnabled(DTMFAccessibilityService::<span class="hljs-keyword">class</span>.java)) {&#10;            AlertDialog.Builder(<span class="hljs-keyword">this</span>)&#10;                .setTitle(<span class="hljs-string">&quot;Enable Accessibility service&quot;</span>)&#10;                .setMessage(<span class="hljs-string">&quot;This app requires the Accessibility service to emulate keyboard inputs during calls. Please enable it in the Accessibility settings. You may need to do this on every restart.&quot;</span>)&#10;                .setPositiveButton(<span class="hljs-string">&quot;Open settings&quot;</span>) { _, _ -&gt;&#10;                    <span class="hljs-keyword">val</span> intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)&#10;                    startActivity(intent)&#10;                }&#10;                .setNegativeButton(<span class="hljs-string">&quot;Already done&quot;</span>, <span class="hljs-literal">null</span>)&#10;                .show()&#10;        }&#10;    }&#10;}&#10;</code></pre><p>And finally, to declare the accessibility service itself :</p><pre><code class="hljs language-xml"><span class="hljs-comment">&lt;!-- src/main/res/values/strings.xml --&gt;</span>&#10;<span class="hljs-tag">&lt;<span class="hljs-name">resources</span>&gt;</span>&#10;    <span class="hljs-comment">&lt;!-- ... --&gt;</span>&#10;    <span class="hljs-tag">&lt;<span class="hljs-name">string</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;accessibility_service_description&quot;</span>&gt;</span>Accessibility service for the LifeCompanion app<span class="hljs-tag">&lt;/<span class="hljs-name">string</span>&gt;</span>&#10;<span class="hljs-tag">&lt;/<span class="hljs-name">resources</span>&gt;</span>&#10;</code></pre><pre><code class="hljs language-xml"><span class="hljs-comment">&lt;!-- src/main/res/xml/accessibility_service_config.xml --&gt;</span>&#10;<span class="hljs-meta">&lt;?xml version=<span class="hljs-string">&quot;1.0&quot;</span> encoding=<span class="hljs-string">&quot;utf-8&quot;</span>?&gt;</span>&#10;<span class="hljs-tag">&lt;<span class="hljs-name">accessibility-service</span>&#10;    <span class="hljs-attr">xmlns:android</span>=<span class="hljs-string">&quot;http://schemas.android.com/apk/res/android&quot;</span>&#10;    <span class="hljs-attr">android:description</span>=<span class="hljs-string">&quot;@string/accessibility_service_description&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityEventTypes</span>=<span class="hljs-string">&quot;typeWindowContentChanged|typeViewClicked&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityFeedbackType</span>=<span class="hljs-string">&quot;feedbackGeneric&quot;</span>&#10;    <span class="hljs-attr">android:notificationTimeout</span>=<span class="hljs-string">&quot;100&quot;</span>&#10;    <span class="hljs-attr">android:canRetrieveWindowContent</span>=<span class="hljs-string">&quot;true&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityFlags</span>=<span class="hljs-string">&quot;flagReportViewIds&quot;</span> /&gt;</span>&#10;</code></pre><p>So, what the hell are we actually doing here ?</p><ol><li>We declare an accessibility service that is triggered everytime the content of a window changes or when a view is clicked, and ask to get the view IDs.</li><li>On the <code class="hljs"><span class="hljs-attribute">CallService</span></code>, we create the <code class="hljs"><span class="hljs-attribute">DTMFAccessibilityService</span></code> and call its function.</li><li>On the Accessibility service, whenever we receive an event we check if it’s from the active window. If it is, we check if we’re in the call screen and then open the keypad if it isn’t.</li><li><code class="hljs"><span class="hljs-function"><span class="hljs-title">isInCallScreen</span><span class="hljs-params">()</span></span></code> is very primitive and only checks the presence of <code class="hljs"><span class="hljs-attribute">dialer</span></code> in the window’s package name.</li><li><code class="hljs"><span class="hljs-function"><span class="hljs-title">isKeypadOpen</span><span class="hljs-params">()</span></span></code> checks if any of the keypad elements (<code class="hljs"><span class="hljs-attribute">0</span>-<span class="hljs-number">9</span></code>, <code class="hljs"><span class="hljs-comment">*</span></code> and <code class="hljs"><span class="hljs-meta">#</span></code>) is present on the node (which represents the active window in a tree). At first it returned <code class="hljs"><span class="hljs-literal">true</span></code> only if <strong>all</strong> of them were present but it didn’t worked so I used <code class="hljs"><span class="hljs-built_in">any</span></code> instead.</li><li><code class="hljs"><span class="hljs-function"><span class="hljs-title">openKeypad</span><span class="hljs-params">()</span></span></code> searches for a button in the node that contains in its text “Keypad” or its french version, “Clavier” (the app was made mainly for french users so I had to include it), and clicks on it.</li><li>The public <code class="hljs"><span class="hljs-function"><span class="hljs-title">pressKeypadButton</span><span class="hljs-params">()</span></span></code> does all of the above and calls <code class="hljs"><span class="hljs-function"><span class="hljs-title">searchAndClick</span><span class="hljs-params">()</span></span></code> which clicks on any node that matches exactly the input string and is clickable.</li></ol><p>Obviously, it goes without saying that this is <em>not</em> optimal, and far from working :</p><ul><li>Due to the incessant calls to <code class="hljs"><span class="hljs-function"><span class="hljs-title">openKeypad</span><span class="hljs-params">()</span></span></code> on the accessibility events, and the barely working check functions, the keypad was constantly opening and closing (which was funny to stare at).</li><li>The logic to check if we’re in a call screen and if the keypad is open are very barebones and not flexible.</li><li>The call screen possesses the phone number of the callee, which obviously will always match our <code class="hljs"><span class="hljs-built_in">any</span></code> clause.</li><li>Overall it wasn’t even able to click on the keypad buttons…</li></ul><p>So, it is time for fixes.</p><h2 id="make-it-workey-pretty-please-pray" tabindex="-1"><a class="header-anchor" href="#make-it-workey-pretty-please-pray">Make it workey pretty please 🙏</a></h2><p>The first edit I did was to comment the code in <code class="hljs"><span class="hljs-function"><span class="hljs-title">onAccessibilityEvent</span><span class="hljs-params">()</span></span></code> to avoid the keypad from flickering in my screen.<br>Then, I edited the logic in the <code class="hljs"><span class="hljs-function"><span class="hljs-title">isKeypadOpen</span><span class="hljs-params">()</span></span></code> method :</p><pre><code class="hljs language-kotlin"><span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isKeypadOpen</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;    <span class="hljs-comment">// Count the number of keypad elements on the screen</span>&#10;    <span class="hljs-keyword">val</span> initialCount = countKeypadElements(node)&#10;    <span class="hljs-comment">// Trigger openKeypad and count again</span>&#10;    openKeypad(node)&#10;    <span class="hljs-keyword">val</span> afterOpenCount = countKeypadElements(node)&#10;    <span class="hljs-comment">// Determine if the keypad is open based on the counts</span>&#10;    <span class="hljs-keyword">val</span> isOpen = afterOpenCount &gt; initialCount&#10;    <span class="hljs-comment">// Revert the state by triggering openKeypad again</span>&#10;    openKeypad(node)&#10;&#10;    <span class="hljs-keyword">return</span> isOpen&#10;}&#10;&#10;<span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">countKeypadElements</span><span class="hljs-params">(node: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Int</span> {&#10;    <span class="hljs-keyword">val</span> keypadElements = listOf(<span class="hljs-string">&quot;1&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, <span class="hljs-string">&quot;3&quot;</span>, <span class="hljs-string">&quot;4&quot;</span>, <span class="hljs-string">&quot;5&quot;</span>, <span class="hljs-string">&quot;6&quot;</span>, <span class="hljs-string">&quot;7&quot;</span>, <span class="hljs-string">&quot;8&quot;</span>, <span class="hljs-string">&quot;9&quot;</span>, <span class="hljs-string">&quot;0&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>, <span class="hljs-string">&quot;#&quot;</span>)&#10;    <span class="hljs-keyword">return</span> keypadElements.sumBy { element -&gt;&#10;        node.findAccessibilityNodeInfosByText(element).size&#10;    }&#10;}&#10;</code></pre><p>The logic is simple : we count the number of each element that should be present on the keypad. Then, we click on its button and count again.<br>If there’s more elements, it means it is open. If not, we closed it, so we click on it again.<br>I thought I was clever writing this, but honestly it was still bad : because we call this function anyway at each <code class="hljs"><span class="hljs-function"><span class="hljs-title">pressKeypadButton</span><span class="hljs-params">()</span></span></code> call, we would close and reopen the keypad at every click.<br>Or we open only once per phone call, but if the user closed the keypad by accident, then we couldn’t reopen it and any button presses would fail.<br>So it is time for…</p><h2 id="the-working-solution" tabindex="-1"><a class="header-anchor" href="#the-working-solution">The working solution</a></h2><p>After detailing the goddamn implementation I did and our motive, GPT o1 came in clutch and was finally able to help in this task. Here’s the code changes that we did based off some of its recommendations :</p><pre><code class="hljs language-xml"><span class="hljs-comment">&lt;!-- src/main/res/xml/accessibility_service_config.xml --&gt;</span>&#10;<span class="hljs-meta">&lt;?xml version=<span class="hljs-string">&quot;1.0&quot;</span> encoding=<span class="hljs-string">&quot;utf-8&quot;</span>?&gt;</span>&#10;<span class="hljs-tag">&lt;<span class="hljs-name">accessibility-service</span>&#10;    <span class="hljs-attr">xmlns:android</span>=<span class="hljs-string">&quot;http://schemas.android.com/apk/res/android&quot;</span>&#10;    <span class="hljs-attr">android:description</span>=<span class="hljs-string">&quot;@string/accessibility_service_description&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityEventTypes</span>=<span class="hljs-string">&quot;typeWindowContentChanged|typeViewClicked|typeWindowStateChanged&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityFlags</span>=<span class="hljs-string">&quot;flagReportViewIds|flagIncludeNotImportantViews&quot;</span>&#10;    <span class="hljs-attr">android:accessibilityFeedbackType</span>=<span class="hljs-string">&quot;feedbackGeneric&quot;</span>&#10;    <span class="hljs-attr">android:notificationTimeout</span>=<span class="hljs-string">&quot;100&quot;</span>&#10;    <span class="hljs-attr">android:canRetrieveWindowContent</span>=<span class="hljs-string">&quot;true&quot;</span> /&gt;</span>&#10;</code></pre><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/DTMFAccessibilityService.kt</span>&#10;<span class="hljs-keyword">package</span> org.lifecompanion.phonecontrolapp.services&#10;&#10;<span class="hljs-keyword">import</span> android.accessibilityservice.AccessibilityService&#10;<span class="hljs-keyword">import</span> android.content.Context&#10;<span class="hljs-keyword">import</span> android.content.Intent&#10;<span class="hljs-keyword">import</span> android.content.pm.ResolveInfo&#10;<span class="hljs-keyword">import</span> android.util.Log&#10;<span class="hljs-keyword">import</span> android.view.accessibility.AccessibilityEvent&#10;<span class="hljs-keyword">import</span> android.view.accessibility.AccessibilityNodeInfo&#10;&#10;<span class="hljs-keyword">object</span> DTMFAccessibilityServiceSingleton {&#10;    <span class="hljs-keyword">var</span> instance: DTMFAccessibilityService? = <span class="hljs-literal">null</span>&#10;}&#10;&#10;<span class="hljs-keyword">class</span> <span class="hljs-title class_">DTMFAccessibilityService</span> : <span class="hljs-type">AccessibilityService</span>() {&#10;    <span class="hljs-keyword">companion</span> <span class="hljs-keyword">object</span> {&#10;        <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> <span class="hljs-keyword">val</span> TAG = <span class="hljs-string">&quot;LC-DTMFAccessibilityService&quot;</span>&#10;&#10;        <span class="hljs-comment">// Known dialer package names, more may be needed depending of the default call app</span>&#10;        <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> KNOWN_DIALER_PACKAGES: List&lt;String&gt; <span class="hljs-keyword">by</span> lazy {&#10;            getPackagesOfDialerApps().apply {&#10;                <span class="hljs-keyword">val</span> additionalPackages = listOf(&#10;                    <span class="hljs-string">&quot;com.google.android.dialer&quot;</span>,&#10;                    <span class="hljs-string">&quot;com.android.dialer&quot;</span>,&#10;                    <span class="hljs-string">&quot;com.samsung.android.incallui&quot;</span>&#10;                )&#10;                additionalPackages.forEach { pkg -&gt;&#10;                    <span class="hljs-keyword">if</span> (!<span class="hljs-keyword">this</span>.contains(pkg)) {&#10;                        <span class="hljs-keyword">this</span>.add(pkg)&#10;                    }&#10;                }&#10;            }&#10;        }&#10;&#10;        <span class="hljs-comment">// Common text or contentDescriptions for opening the dialpad</span>&#10;        <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> DIALPAD_TOGGLE_KEYWORDS = listOf(<span class="hljs-string">&quot;Keypad&quot;</span>, <span class="hljs-string">&quot;Clavier&quot;</span>, <span class="hljs-string">&quot;Dial pad&quot;</span>, <span class="hljs-string">&quot;Show dial pad&quot;</span>)&#10;&#10;        <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">getPackagesOfDialerApps</span><span class="hljs-params">()</span></span>: MutableList&lt;String&gt; {&#10;            <span class="hljs-keyword">val</span> packageNames = mutableListOf&lt;String&gt;()&#10;            <span class="hljs-keyword">val</span> context = DTMFAccessibilityServiceSingleton.instance?.applicationContext&#10;&#10;            <span class="hljs-keyword">if</span> (context != <span class="hljs-literal">null</span>) {&#10;                <span class="hljs-keyword">val</span> intent = Intent(Intent.ACTION_DIAL)&#10;                <span class="hljs-keyword">val</span> resolveInfos: List&lt;ResolveInfo&gt; = context.packageManager.queryIntentActivities(intent, <span class="hljs-number">0</span>)&#10;&#10;                <span class="hljs-keyword">for</span> (resolveInfo <span class="hljs-keyword">in</span> resolveInfos) {&#10;                    <span class="hljs-keyword">val</span> activityInfo = resolveInfo.activityInfo&#10;                    packageNames.add(activityInfo.applicationInfo.packageName)&#10;                }&#10;            } <span class="hljs-keyword">else</span> {&#10;                Log.e(TAG, <span class="hljs-string">&quot;Context is null, cannot get dialer packages&quot;</span>)&#10;            }&#10;&#10;            <span class="hljs-keyword">return</span> packageNames&#10;        }&#10;    }&#10;&#10;    <span class="hljs-comment">// Keep a reference to the latest root node</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> _rootNode: AccessibilityNodeInfo? = <span class="hljs-literal">null</span>&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onCreate</span><span class="hljs-params">()</span></span> {&#10;        <span class="hljs-keyword">super</span>.onCreate()&#10;        DTMFAccessibilityServiceSingleton.instance = <span class="hljs-keyword">this</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onDestroy</span><span class="hljs-params">()</span></span> {&#10;        <span class="hljs-keyword">super</span>.onDestroy()&#10;        DTMFAccessibilityServiceSingleton.instance = <span class="hljs-literal">null</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onAccessibilityEvent</span><span class="hljs-params">(event: <span class="hljs-type">AccessibilityEvent</span>?)</span></span> {&#10;        <span class="hljs-keyword">if</span> (event == <span class="hljs-literal">null</span>) {&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        <span class="hljs-comment">// Whenever there&#x27;s a window content/state change, update our root node reference</span>&#10;        <span class="hljs-keyword">if</span> (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {&#10;            _rootNode = rootInActiveWindow&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onServiceConnected</span><span class="hljs-params">()</span></span> {&#10;        <span class="hljs-keyword">super</span>.onServiceConnected()&#10;        DTMFAccessibilityServiceSingleton.instance = <span class="hljs-keyword">this</span>&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onStartCommand</span><span class="hljs-params">(intent: <span class="hljs-type">Intent</span>?, flags: <span class="hljs-type">Int</span>, startId: <span class="hljs-type">Int</span>)</span></span>: <span class="hljs-built_in">Int</span> {&#10;        intent?.let {&#10;            <span class="hljs-keyword">val</span> action = it.getStringExtra(<span class="hljs-string">&quot;action&quot;</span>)&#10;            <span class="hljs-keyword">val</span> buttonText = it.getStringExtra(<span class="hljs-string">&quot;button_text&quot;</span>)&#10;&#10;            <span class="hljs-keyword">if</span> (action == <span class="hljs-string">&quot;press_keypad_button&quot;</span> &amp;&amp; buttonText != <span class="hljs-literal">null</span>) {&#10;                pressKeypadButton(buttonText)&#10;            }&#10;        }&#10;&#10;        <span class="hljs-keyword">return</span> START_NOT_STICKY&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * Heuristically checks if the current screen belongs to a known dialer or in-call UI.&#10;     */</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isInCallScreen</span><span class="hljs-params">(rootNode: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-keyword">val</span> pkg = rootNode.packageName?.toString() ?: <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>&#10;&#10;        <span class="hljs-keyword">return</span> KNOWN_DIALER_PACKAGES.any { pkg.contains(it, ignoreCase = <span class="hljs-literal">true</span>) }&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * Check if the dial pad is open by searching for all digits 0-9 plus * and # in the hierarchy.&#10;     */</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">areAllDigitsVisible</span><span class="hljs-params">(rootNode: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-keyword">val</span> required = listOf(<span class="hljs-string">&quot;1&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, <span class="hljs-string">&quot;3&quot;</span>, <span class="hljs-string">&quot;4&quot;</span>, <span class="hljs-string">&quot;5&quot;</span>, <span class="hljs-string">&quot;6&quot;</span>, <span class="hljs-string">&quot;7&quot;</span>, <span class="hljs-string">&quot;8&quot;</span>, <span class="hljs-string">&quot;9&quot;</span>, <span class="hljs-string">&quot;0&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>, <span class="hljs-string">&quot;#&quot;</span>)&#10;&#10;        <span class="hljs-keyword">for</span> (digit <span class="hljs-keyword">in</span> required) {&#10;            <span class="hljs-keyword">val</span> nodesWithDigit = rootNode.findAccessibilityNodeInfosByText(digit)&#10;&#10;            <span class="hljs-keyword">if</span> (nodesWithDigit.isNullOrEmpty()) {&#10;                <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>&#10;            }&#10;        }&#10;&#10;        <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * Attempt to find a toggle button (by text or contentDescription) to open the dialpad.&#10;     * If we already see digits on the screen, we skip toggling.&#10;     */</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">openDialPadIfNeeded</span><span class="hljs-params">(rootNode: <span class="hljs-type">AccessibilityNodeInfo</span>)</span></span> {&#10;        <span class="hljs-comment">// If we already see the digits 0-9, #, * in the node tree, the dial pad is probably open</span>&#10;        <span class="hljs-keyword">val</span> allDigitsPresent = areAllDigitsVisible(rootNode)&#10;&#10;        <span class="hljs-keyword">if</span> (allDigitsPresent) {&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        <span class="hljs-comment">// Otherwise, BFS to find a dialpad toggle</span>&#10;        <span class="hljs-keyword">val</span> queue = ArrayDeque&lt;AccessibilityNodeInfo&gt;()&#10;        queue.add(rootNode)&#10;&#10;        <span class="hljs-keyword">while</span> (queue.isNotEmpty()) {&#10;            <span class="hljs-keyword">val</span> node = queue.removeFirst()&#10;            <span class="hljs-comment">// Check text &amp; contentDescription</span>&#10;            <span class="hljs-keyword">val</span> textStr = node.text?.toString() ?: <span class="hljs-string">&quot;&quot;</span>&#10;            <span class="hljs-keyword">val</span> descStr = node.contentDescription?.toString() ?: <span class="hljs-string">&quot;&quot;</span>&#10;&#10;            <span class="hljs-comment">// If either text or contentDescription matches known keywords</span>&#10;            <span class="hljs-keyword">if</span> (DIALPAD_TOGGLE_KEYWORDS.any { keyword -&gt;&#10;                    textStr.contains(keyword, ignoreCase = <span class="hljs-literal">true</span>) ||&#10;                    descStr.contains(keyword, ignoreCase = <span class="hljs-literal">true</span>)&#10;                }&#10;            ) {&#10;                <span class="hljs-keyword">if</span> (node.isClickable) {&#10;                    node.performAction(AccessibilityNodeInfo.ACTION_CLICK)&#10;                    Thread.sleep(<span class="hljs-number">300</span>)&#10;&#10;                    <span class="hljs-keyword">return</span>&#10;                }&#10;            }&#10;&#10;            <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until node.childCount) {&#10;                node.getChild(i)?.let { queue.add(it) }&#10;            }&#10;        }&#10;&#10;        Log.w(TAG, <span class="hljs-string">&quot;Could not find any dialpad toggle button in the current UI.&quot;</span>)&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * Checks if a dialer button&#x27;s text or contentDescription is relevant for this digit.&#10;     * e.g. digit = &quot;2&quot; matches &quot;2&quot;, &quot;2 ABC&quot;, &quot;2,ABC&quot;, &quot;2.&quot; ...&#10;     */</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">isDialerButtonMatch</span><span class="hljs-params">(nodeText: <span class="hljs-type">String</span>?, digit: <span class="hljs-type">String</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-keyword">if</span> (nodeText == <span class="hljs-literal">null</span> || nodeText.isEmpty()) {&#10;            <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>&#10;        }&#10;&#10;        <span class="hljs-keyword">val</span> escapedDigit = Regex.escape(digit)&#10;        <span class="hljs-keyword">val</span> pattern = Regex(<span class="hljs-string">&quot;^<span class="hljs-variable">$escapedDigit</span>[\\s,]*(.*)?$&quot;</span>, RegexOption.IGNORE_CASE)&#10;&#10;        <span class="hljs-keyword">return</span> nodeText.matches(pattern)&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * Find and click a single digit (0-9, *, #).&#10;     * Returns true if the digit was successfully clicked, false otherwise.&#10;     */</span>&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">pressKeyDigit</span><span class="hljs-params">(rootNode: <span class="hljs-type">AccessibilityNodeInfo</span>, digit: <span class="hljs-type">String</span>)</span></span>: <span class="hljs-built_in">Boolean</span> {&#10;        <span class="hljs-comment">// BFS</span>&#10;        <span class="hljs-keyword">val</span> queue = ArrayDeque&lt;AccessibilityNodeInfo&gt;()&#10;        queue.add(rootNode)&#10;&#10;        <span class="hljs-keyword">while</span> (queue.isNotEmpty()) {&#10;            <span class="hljs-keyword">val</span> node = queue.removeFirst()&#10;&#10;            <span class="hljs-comment">// Skip system UI or non-dialer packages</span>&#10;            <span class="hljs-keyword">val</span> nodePackage = node.packageName?.toString() ?: <span class="hljs-string">&quot;&quot;</span>&#10;            <span class="hljs-keyword">if</span> (!KNOWN_DIALER_PACKAGES.any { nodePackage.contains(it, ignoreCase = <span class="hljs-literal">true</span>) }) {&#10;                <span class="hljs-comment">// This node isn&#x27;t from a recognized dialer package. Skip its subtree.</span>&#10;                <span class="hljs-keyword">continue</span>&#10;            }&#10;&#10;            <span class="hljs-keyword">val</span> textStr = node.text?.toString()&#10;            <span class="hljs-keyword">val</span> descStr = node.contentDescription?.toString()&#10;&#10;            <span class="hljs-comment">// If either text or contentDescription is a partial match</span>&#10;            <span class="hljs-keyword">if</span> (isDialerButtonMatch(textStr, digit) || isDialerButtonMatch(descStr, digit)) {&#10;                <span class="hljs-keyword">if</span> (node.isClickable) {&#10;                    node.performAction(AccessibilityNodeInfo.ACTION_CLICK)&#10;                    Thread.sleep(<span class="hljs-number">300</span>)&#10;&#10;                    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>&#10;                }&#10;            }&#10;&#10;            <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until node.childCount) {&#10;                node.getChild(i)?.let { queue.add(it) }&#10;            }&#10;        }&#10;&#10;        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>&#10;    }&#10;&#10;    <span class="hljs-comment">/**&#10;     * External entry point:&#10;     * 1) Check if we are in a known dialer UI&#10;     * 2) Open the dial pad if needed&#10;     * 3) Press the requested button&#10;     */</span>&#10;    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">pressKeypadButton</span><span class="hljs-params">(dtmfString: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">val</span> rootNode = _rootNode ?: run {&#10;            Log.w(TAG, <span class="hljs-string">&quot;No root node available. Are we sure the dialer is in the foreground ?&quot;</span>)&#10;&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        <span class="hljs-keyword">if</span> (!isInCallScreen(rootNode)) {&#10;            Log.w(TAG, <span class="hljs-string">&quot;We are not in a recognized in-call/dialer screen !&quot;</span>)&#10;&#10;            <span class="hljs-keyword">return</span>&#10;        }&#10;&#10;        openDialPadIfNeeded(rootNode)&#10;&#10;        <span class="hljs-keyword">if</span> (dtmfString.matches(Regex(<span class="hljs-string">&quot;[0-9*#]&quot;</span>))) {&#10;            <span class="hljs-keyword">var</span> success = pressKeyDigit(rootNode, dtmfString)&#10;&#10;            <span class="hljs-keyword">if</span> (!success) {&#10;                <span class="hljs-comment">// We probably just opened the dial pad</span>&#10;                Thread.sleep(<span class="hljs-number">300</span>)&#10;                success = pressKeyDigit(rootNode, dtmfString)&#10;&#10;                <span class="hljs-keyword">if</span> (!success) {&#10;                    Log.e(TAG, <span class="hljs-string">&quot;Failed to press digit : <span class="hljs-variable">$dtmfString</span>&quot;</span>)&#10;                }&#10;            }&#10;        } <span class="hljs-keyword">else</span> {&#10;            Log.e(TAG, <span class="hljs-string">&quot;Invalid DTMF char : <span class="hljs-variable">$dtmfString</span>&quot;</span>)&#10;        }&#10;    }&#10;&#10;    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">onInterrupt</span><span class="hljs-params">()</span></span> {&#10;        DTMFAccessibilityServiceSingleton.instance = <span class="hljs-literal">null</span>&#10;    }&#10;}&#10;</code></pre><pre><code class="hljs language-kotlin"><span class="hljs-comment">// services/CallService.kt</span>&#10;<span class="hljs-comment">// ...</span>&#10;&#10;    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">sendDtmf</span><span class="hljs-params">(dtmf: <span class="hljs-type">String</span>)</span></span> {&#10;        <span class="hljs-keyword">val</span> intent = Intent(<span class="hljs-keyword">this</span>, DTMFAccessibilityService::<span class="hljs-keyword">class</span>.java)&#10;        startService(intent)&#10;&#10;        DTMFAccessibilityServiceSingleton.instance?.pressKeypadButton(dtmf)&#10;    }&#10;</code></pre><p>Time for some explanations once again :)</p><ol><li>We created (quickly) a singleton to avoid creating another <code class="hljs"><span class="hljs-attribute">DTMFAccessibilityService</span></code> object at each call.</li><li>We added a bit more possible names for the keypad button. <em>I’m sorry</em>, but if you want to use this code, you might need to i18n it depending on the countries you’re targeting.</li><li>The detection of the call app name is way much improved ! We first get the list of package names from all installed apps that can handle calls on the phone, and add 3 common packages just in case.</li><li>Everytime we get an accessibility event (we added when the state of a window changes, and asked also for the “non important views”), we would edit a global root node that we process. This ensures reactivity to content changes (ex the keypad just opened or closed) and also helps us working on an always up to date node tree.</li><li>We also improved the detection of “visible” keypad elements (we require <strong>all</strong> of them to be apparent on the screen).</li><li>If the keypad isn’t opened, we do a quick BFS on the root node to find the button and click on it.</li><li>When calling <code class="hljs"><span class="hljs-function"><span class="hljs-title">pressKeypadButton</span><span class="hljs-params">()</span></span></code>, we perform all required actions (as before), and check if the DTMF symbol is an accepted one (technically <code class="hljs"><span class="hljs-selector-tag">A</span>-D</code> exists but nobody uses them) with <code class="hljs"><span class="hljs-function"><span class="hljs-title">Regex</span><span class="hljs-params">(<span class="hljs-string">&quot;[0-9*#]&quot;</span>)</span></span></code>.</li><li>We then call <code class="hljs"><span class="hljs-function"><span class="hljs-title">pressKeyDigit</span><span class="hljs-params">()</span></span></code> to perform a BFS on the root node tree <em>again</em>, but this time to search for the DTMF’s button. Note that we sleep 300ms after performing any click to let the UI (and root node object) update. We also take care of skipping anything where the buttons will not be present.</li><li>To be really sure, we check in the button’s text <strong>and</strong> description for the content we search for with a good ol’ regex : <code class="hljs"><span class="hljs-string">&quot;^<span class="hljs-variable">$dtmf</span>[\\s,]*(.*)?<span class="hljs-variable">$</span>&quot;</span></code>.</li></ol><p>Obviously, the DTMF button name is in the <em>description</em>, not the text ! For example, the button 2 have a null text, and <code class="hljs">2,ABC</code> as a description.<br>But we still had to do a quick change. You see, every button press worked, except <code class="hljs"><span class="hljs-comment">*</span></code>, which hanged up the call. Well it was because we had to escape it, as it matched any text on the regex !</p><h2 id="the-end" tabindex="-1"><a class="header-anchor" href="#the-end">The end</a></h2><p>It was really fun to make the code and write this blog post, but seriously frustrating that there is no standard API to do it in a non convoluted way, 17 years after the release of Android !<br>I hope that this helped you to implement such a basic feature in your app (although working with accessibility services is a pain in the rear end), and don’t hesitate to <a href="/#social" target="_blank" rel="noopener noreferrer">follow me on random places</a> (feel free to start a convo).<br>This blog will contain random writeups like this one (technical) and more random standard things too, thoughts, …</p>]]></content:encoded>
            <category>tutorial</category>
            <category>android</category>
            <category>accessibility</category>
            <category>phone</category>
            <category>kotlin</category>
            <category>java</category>
        </item>
        <item>
            <title><![CDATA[How I successfully managed to make a Windows Education laptop free from school (so you don't struggle trying to)]]></title>
            <link>https://edm115.eu.org/blog/2024/08/20/education-to-pro</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2024/08/20/education-to-pro</guid>
            <pubDate>Tue, 20 Aug 2024 12:00:00 GMT</pubDate>
            <description><![CDATA[A complete guide on how I transformed a school-managed Windows Education laptop into a regular Windows machine, including migration steps, unlinking from school accounts and final touches to ensure full functionality.]]></description>
            <content:encoded><![CDATA[<h1 id="how-i-successfully-managed-to-make-a-windows-education-laptop-free-from-school-so-you-dont-struggle-trying-to" tabindex="-1"><a class="header-anchor" href="#how-i-successfully-managed-to-make-a-windows-education-laptop-free-from-school-so-you-dont-struggle-trying-to">How I successfully managed to make a Windows Education laptop free from school (so you don’t struggle trying to)</a></h1><h3 id="quick-backstory" tabindex="-1"><a class="header-anchor" href="#quick-backstory">Quick backstory</a></h3><p>My gf have been given a laptop by her school. When she finished her studies, she was able to keep it. But it was still linked to an education account, managed by Intune/Azure ActiveDirectory and it was Windows Education. Here’s how i turned it into a normal Windows machine :)</p><h2 id="prerequisites" tabindex="-1"><a class="header-anchor" href="#prerequisites">Prerequisites</a></h2><p>The PC has to have an admin session. If you don’t, try to find how to make yourself admin :)</p><h2 id="step-1-the-migration" tabindex="-1"><a class="header-anchor" href="#step-1-the-migration">Step 1 : The migration</a></h2><ol><li>Create a new local account and make it admin. Go on Settings =&gt; Accounts =&gt; Other users and add one. Make sure it’s a local account, decline as much as possible any Microsoft related info.</li><li>Write somewhere all the apps that are installed. Some of them are system-wide but some may be just for the current user.</li><li>Zip every important folder. Documents, Downloads, Images, … I also like to zip the Appdata folder just in case.</li><li>Open a cmd and type <code class="hljs"><span class="hljs-built_in">echo</span> <span class="hljs-variable">%COMPUTERNAME%</span></code>. Write this somewhere ! Also your current account is very probably a cloud account, so open the settings and write down the email shown at the top under your username. If it doesn’t show up, go on Settings =&gt; Accounts =&gt; Professional or School access and it should be there. If you close the session by mistake, this email will be the username to enter.</li><li>Lock the current session but DON’T CLOSE IT ! Connect to the other user, username will be <code class="hljs">COMPUTERNAME<span class="hljs-string">\username</span></code> (backslash is important). Replace with your computer name and the username you have chosen.</li><li>Copy-paste all the previously zipped files, install any software that isn’t present, re-log in if needed.</li><li>Do <code class="hljs">Win + <span class="hljs-attribute">R</span></code> and type <code class="hljs"><span class="hljs-attribute">systempropertiesadvanced</span></code>. Here, click on the “PC Name” tab, Edit, and change the PC name to something more fitting to you (mine is for example <code class="hljs">Lenovo-EDM115</code>).</li><li>Go back on the previous user and log out after you made sure you backed up anything important (ex browser passwords, apps settings, …)</li><li>If you are on Windows Pro, open the Control Panel =&gt; BitLocker Drive Encryption =&gt; Turn off BitLocker. This will take some time but your drive will be much faster afterwards</li></ol><h2 id="step-2-the-unlink" tabindex="-1"><a class="header-anchor" href="#step-2-the-unlink">Step 2 : The unlink</a></h2><ol><li>Disable Wifi (anything that connects to internet) and restart the PC</li><li>Once restarted, go on your new user (<code class="hljs">UpdatedPcName<span class="hljs-string">\ChosenUsername</span></code>), and go to Settings =&gt; Accounts =&gt; Family &amp; other users, and remove it. Go on Settings =&gt; Accounts =&gt; Professional or School access, click on the account, and disconnect. Now for some weird reason this should remove the existing user account but it doesn’t work fully, so go on <code class="hljs"><span class="hljs-symbol">C:</span>\Users</code>. Try to go on the old account and confirm so it grants you the rights. Then go back and delete it.</li><li>Go on Settings =&gt; System =&gt; Activation =&gt; Change product key option =&gt; Change, and enter <code class="hljs"><span class="hljs-attribute">VK7JG</span>-NPHTM-C97JM-<span class="hljs-number">9</span>MPGT-<span class="hljs-number">3</span>V66T</code> (make sure you’re offline !). This is a generic Windows 11 Pro key, so I guess it would be working only if you were on Windows 11 Pro Education. If your system is Windows 11 Education (check in Settings =&gt; System =&gt; System informations), use <code class="hljs"><span class="hljs-attribute">YTMG3</span>-N6DKC-DKB77-<span class="hljs-number">7</span>M9GH-<span class="hljs-number">8</span>HVX7</code> instead. Full list can be found at <a href="https://www.elevenforum.com/t/generic-product-keys-to-install-or-upgrade-windows-11-editions.3713/" target="_blank" rel="noopener noreferrer">https://www.elevenforum.com/t/generic-product-keys-to-install-or-upgrade-windows-11-editions.3713/</a></li><li>Reconnect to the internet, open a PowerShell as admin, and enter <code class="hljs">irm <span class="hljs-keyword">https</span>://<span class="hljs-built_in">get</span>.activated.win | iex</code>, then hit <code class="hljs">1</code>, wait for it to be complete, <code class="hljs"><span class="hljs-attribute">Enter</span></code>, <code class="hljs">0</code>. You now have a genuine Windows licence.</li><li>Open the Task Manager (<code class="hljs">Ctrl + <span class="hljs-built_in">Shift</span> + Esc</code>) and close anything related to Intune. Now install Revo Uninstaller and remove softwares that you didn’t installed yourself + some Intune related stuff (some are system stuff, if you see anything related to your PC manufacturer/Realtek/AMD/Intel, …, keep it !). Take also a look at Windows apps, some are hidden there. Make sure you do an advanced scan and check the “Check on all windows accounts” checkbox. Then clean some remaining trash, probably at <code class="hljs"><span class="hljs-symbol">C:</span>\Intune</code>, <code class="hljs"><span class="hljs-symbol">C:</span>\Windows\Intune</code>, and similar folders.</li><li>Now do <code class="hljs">Win + <span class="hljs-attribute">R</span></code>, <code class="hljs">gpedit.msc</code>. Go to Computer Configuration =&gt; Administrative Templates =&gt; All settings, and click on the State column. Double click on anything that is marked as Enabled or Disabled, and select “Not configured”.</li><li>Open a PowerShell as admin, and run the following lines one by one :</li></ol><pre><code class="hljs language-powershell">reg delete <span class="hljs-string">&quot;HKCU\Software\Microsoft\Windows\CurrentVersion\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKCU\Software\Microsoft\WindowsSelfHost&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKCU\Software\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\Microsoft\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\Microsoft\Windows\CurrentVersion\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\Microsoft\Windows\CurrentVersion\WindowsStore\WindowsUpdate&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\Microsoft\WindowsSelfHost&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\WOW6432Node\Microsoft\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Policies&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\WindowsStore\WindowsUpdate&quot;</span> /f&#10;reg delete <span class="hljs-string">&quot;HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Enrollments&quot;</span> /f&#10;</code></pre><ol start="8"><li>Restart the PC. You should now be able to login with just the username and without specifying the PC name. Open a cmd as admin and run <code class="hljs">DSREGCMD /debug /leave</code> and then <code class="hljs">DSREGCMD /debug /cleanupaccounts</code>. This will do its best to leave linked organizations.</li><li>Do <code class="hljs">Win + <span class="hljs-attribute">R</span></code>, <code class="hljs"><span class="hljs-attribute">regedit</span></code>, and click on <code class="hljs"><span class="hljs-attribute">HKEY_CURRENT_USER</span></code> (so the search will start from here. click again on this when doing another search). You will now have to search for anything related to “Intune”, “Enrollment” or the domain part of your school email that you noted prior (ex if it was <code class="hljs"><span class="hljs-symbol">pc123456@</span>school.country.com</code>, search for <code class="hljs">school<span class="hljs-selector-class">.country</span>.com</code>). Delete the keys found (use common sense tho) to remove the last bits of organization links. You can backup the registry before if you think you will mess this up.</li></ol><h2 id="step-3-final-touches-and-quick-remarks" tabindex="-1"><a class="header-anchor" href="#step-3-final-touches-and-quick-remarks">Step 3 : Final touches and quick remarks</a></h2><ol><li>Open Windows Update an check for any updates available. Install them and restart as necessary. If some fail to install, it’s probably because when on Intune, they restricted which versions could be installed (ex for my gf she could only install cumulative updates of every 3 month). To avoid further issues, open a cmd as admin and run the following (then check for updates again) :</li></ol><pre><code class="hljs language-cmd"><span class="hljs-built_in">net</span> stop wuauserv&#10;<span class="hljs-built_in">net</span> stop cryptSvc&#10;<span class="hljs-built_in">net</span> stop bits&#10;<span class="hljs-built_in">net</span> stop msiserver&#10;<span class="hljs-built_in">rd</span> /SQ &quot;C:\Windows\SoftwareDistribution&quot;&#10;<span class="hljs-built_in">rd</span> /SQ &quot;C:\Windows\System32\catroot2&quot;&#10;<span class="hljs-built_in">net</span> <span class="hljs-built_in">start</span> wuauserv&#10;<span class="hljs-built_in">net</span> <span class="hljs-built_in">start</span> cryptSvc&#10;<span class="hljs-built_in">net</span> <span class="hljs-built_in">start</span> bits&#10;<span class="hljs-built_in">net</span> <span class="hljs-built_in">start</span> msiserver&#10;DISM /Online /Cleanup-Image /CheckHealth&#10;DISM /Online /Cleanup-Image /ScanHealth&#10;DISM /Online /Cleanup-Image /RestoreHealth&#10;sfc /scannow&#10;</code></pre><ol start="2"><li>If some cumulative updates still fail, or if when doing <code class="hljs">Win + <span class="hljs-attribute">R</span></code>, <code class="hljs"><span class="hljs-attribute">winver</span></code> the version is lower than the current actual one (23H2 as I write this, will soon be 24H2), let’s do an in-place upgrade. Go at <a href="https://www.microsoft.com/en-us/software-download/windows11" target="_blank" rel="noopener noreferrer">https://www.microsoft.com/en-us/software-download/windows11</a> =&gt; Download Windows 11 Disk Image (ISO) for x64 devices, select the only choice then your language and click on 64-bit download. Once downloaded, right click the .iso =&gt; Properties =&gt; Check Unblock =&gt; Ok. Then do right click =&gt; Mount, and it should appear on the left of the file explorer as a DVD. Run <code class="hljs">Setup.<span class="hljs-keyword">exe</span></code> =&gt; Next =&gt; Accept =&gt; Check that it says “Install Windows 11 (Pro)” and “Keep personal files and apps” (by default) =&gt; Install. The PC will be inaccessible and may restart several times. But it’s finally over !</li><li>By now it should be fully unlocked and all settings should be accessible. Maybe go on Settings =&gt; Network and Internet =&gt; Wi-Fi =&gt; Manage known networks and remove anything linked to your school. The only setting that was locked for my gf was to change her lock screen image. To change this, copy-paste the image you want into <code class="hljs"><span class="hljs-symbol">C:</span>\Users\<span class="hljs-keyword">Public</span>\Pictures</code> (avoid any spaces), then <code class="hljs">Win + <span class="hljs-attribute">R</span></code>, <code class="hljs"><span class="hljs-attribute">regedit</span></code>, paste on the top bar <code class="hljs">HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Personalization</code> and make the key <code class="hljs"><span class="hljs-attribute">AllowPersonalization</span></code> to 1 (if it doesn’t exist right click on Personalization folder =&gt; New =&gt; DWORD, it’s hexadecimal). Then, a folder just under should be called <code class="hljs"><span class="hljs-attribute">PersonalizationDSP</span></code> (in <code class="hljs"><span class="hljs-attribute">CurrentVersion</span></code>). Paste the full path of your wanted lock screen background in the String keys <code class="hljs"><span class="hljs-attribute">LockScreenImagePath</span></code> and <code class="hljs"><span class="hljs-attribute">LockScreenImageUrl</span></code> (ex : <code class="hljs"><span class="hljs-name">C</span>:\Users\Public\Pictures\awesome_bg.png</code>), plus set the DWORD <code class="hljs"><span class="hljs-attribute">LockScreenImageStatus</span></code> to 1</li></ol>]]></content:encoded>
            <category>tutorial</category>
            <category>education</category>
            <category>windows</category>
        </item>
        <item>
            <title><![CDATA[Google ending Edu shared drives, everything you need to know]]></title>
            <link>https://edm115.eu.org/blog/2022/05/15/google-ending-shared-drives</link>
            <guid isPermaLink="false">https://edm115.eu.org/blog/2022/05/15/google-ending-shared-drives</guid>
            <pubDate>Sun, 15 May 2022 22:53:00 GMT</pubDate>
            <description><![CDATA[Google is ending its Education Shared Drives unlimited storage offering as part of a broader storage policy change effective July 2022, a decision driven by widespread abuse and rapidly rising storage use. Find out why this happened, its implications for users and alternative cloud storage solutions.]]></description>
            <content:encoded><![CDATA[<h1 id="google-ending-edu-shared-drives-everything-you-need-to-know" tabindex="-1"><a class="header-anchor" href="#google-ending-edu-shared-drives-everything-you-need-to-know">Google ending Edu shared drives : everything you need to know</a></h1><p><strong>As you may know, Google is ending their Edu shared drive plan.</strong><br><strong>On this article, we are going to see why, what would be the consequences for you and which solutions you may use.</strong><br><img src="/img/blog/2022/05-15-google-ending-shared-drives.webp" alt="Google Shared Drives" loading="lazy"></p><h2 id="1-is-it-serious" tabindex="-1"><a class="header-anchor" href="#1-is-it-serious">1) Is it serious ?</a></h2><p>Yes it is, and to be honest it didn’t surprise me…<br>Google have <a href="https://support.google.com/a/answer/10403871#expand-all" target="_blank" rel="noopener noreferrer">announced it</a>, but it was said nearly nowhere else (that is why I’m writing this article)<br>This follow their new storage policy, which is going to take effect in July (theorically on 1st July, but it may take some days to be effective). Also, some Shared Drives may be deleted from June (Google Admin already provide a <a href="https://telegra.ph/Deleting-Shared-Drives-from-Google-Admin-panel-05-15" target="_blank" rel="noopener noreferrer">solution to delete them</a>)</p><h2 id="2-a-bit-of-chronology" tabindex="-1"><a class="header-anchor" href="#2-a-bit-of-chronology">2) A bit of chronology…</a></h2><ul><li>24th April 2014 : Creation of Google Drive, formerly known as <a href="https://chiefmarketer.com/googles-platypus-is-gdrive/" target="_blank" rel="noopener noreferrer">project Platypus</a> (<a href="http://cocaman.ch/wp/2006/07/google-testing-gdrive-codename-platypus/?/wp-content/uploads/2006/07/Platypus1152508704685.png" target="_blank" rel="noopener noreferrer">original blog post</a>)</li><li>September 2016 : Creation of Team Drives</li><li>29th April 2019 : Renaming them to Shared Drives</li><li>In the end of 2019, proof <a href="https://www.google.com/search?q=free+team+drive+generator&amp;sxsrf=ALiCzsbOIkd6ekfBu7vqC0ptClmI6xfH0w%3A1652628214232&amp;tbs=cdr%3A1%2Ccd_min%3A1%2F1%2F2019%2Ccd_max%3A12%2F31%2F2020&amp;tbm" target="_blank" rel="noopener noreferrer">here</a> : Creation of Team Drives generators</li><li>18th February 2022 : Google announcing changing their storage policy</li></ul><h2 id="3-what-are-team-shared-drives" tabindex="-1"><a class="header-anchor" href="#3-what-are-team-shared-drives">3) What are Team/Shared Drives ?</a></h2><blockquote><p>In September 2016, Google announced Team Drives, later renamed Shared Drives, as a new way for Google Workspace teams to collaborate on documents and store files. In Shared Drives, file/folder sharing and ownership are assigned to a team rather than to an individual user. Since 2020, Shared Drives had an ability to assign different access levels to files and folders to different users and teams, and an ability to share a folder publicly. Unlike individual Google Drive, Shared Drives offer unlimited storage.</p></blockquote><p>They provided unlimited cloud storage for schools and enterprises that needed it</p><h2 id="4-what-caused-this-ending" tabindex="-1"><a class="header-anchor" href="#4-what-caused-this-ending">4) What caused this ending ?</a></h2><p>People were seriously abusing it…<br>While some people needed it, using it as a backup of their data, or because they have some files that were above the 15 Gb limit ; others overused it. Among them, people that runs Telegram mirror groups. Because a lot of users mirror a lot of files to Google Drive, and because making such groups is easier than ever thanks to Heroku, the number of files uploaded to Shared Drives exponentially raised.<br>As Google saw the storage consumption growing up, and as people were only abusing Edu Drives, they decided to “end” this offer.<br>Plus, let me tell you something from my experience. For the context, I’m in over 250 shared drives that host around 5 Pb of data. If I search for one file (let’s say <code class="hljs"><span class="hljs-attribute">After</span> Effects <span class="hljs-number">2022</span></code>), I have hundred of results, most of the time the <em>exact same file</em>, just on several drives…</p><h2 id="5-which-solutions-remains-for-schools" tabindex="-1"><a class="header-anchor" href="#5-which-solutions-remains-for-schools">5) Which solutions remains for schools ?</a></h2><p>In facts, they still can use their Edu plan. But this time, the storage is pooled with a baseline of 100 TB for all users, no longer unlimited. Meaning that administrators of schools will be more attentives to what happen on their drives, and the creation of Shared Drives will seriously be resricted.<br>The real problem may come for schools like Harvard or the MIT, them needing to store huge amount of data. The solution for them could be either buying an upper Drive plan, or having ther own servers.</p><h2 id="6-what-are-the-consequences-for-the-average-user" tabindex="-1"><a class="header-anchor" href="#6-what-are-the-consequences-for-the-average-user">6) What are the consequences for the average user ?</a></h2><p>If like me, you’re in a lot of Shared Drives, they will nearly all disappear from <a href="https://drive.google.com/drive/u/0/shared-drives" target="_blank" rel="noopener noreferrer">your drive</a>.<br>If you’re running one, it would be better to inform your users that the storage may end very soon, and telling them to backup their files as soon as possible.<br>If you still need to have a cloud storage, keep reading.<br>All the links that leads to files/folders on those drives will no longer work, and mirror bots will probably no longer work.<br>TeamDrives generator such as the one of <a href="https://msgsuite.eu.org/" target="_blank" rel="noopener noreferrer">MSGsuite</a> will need to shut down their services.</p><h2 id="7-you-need-to-store-your-files-on-the-cloud" tabindex="-1"><a class="header-anchor" href="#7-you-need-to-store-your-files-on-the-cloud">7) You need to store your files on the cloud ?</a></h2><p>Here are the best free solutions for you :</p><table><thead><tr><th style="text-align:center">Name</th><th style="text-align:center">Storage offered for free</th><th style="text-align:center">Features and limits</th><th style="text-align:center">Link</th></tr></thead><tbody><tr><td style="text-align:center"><strong>BayFiles</strong></td><td style="text-align:center">20 Gb per file maximum</td><td style="text-align:center">No need to signup. The files are stored forever unless it’s reported. Check their <a href="https://bayfiles.com/faq" target="_blank" rel="noopener noreferrer">FAQ</a> for the limits</td><td style="text-align:center"><em><a href="https://bayfiles.com" target="_blank" rel="noopener noreferrer">https://bayfiles.com</a></em></td></tr><tr><td style="text-align:center"><strong>Mediafire</strong></td><td style="text-align:center">10 Gb of storage (up to 50 Gb with referrals), 4 Gb per file max</td><td style="text-align:center">Need to create an account. Ads. <a href="https://mediafire.zendesk.com/hc/en-us/" target="_blank" rel="noopener noreferrer">FAQ here</a></td><td style="text-align:center"><em><a href="https://www.mediafire.com" target="_blank" rel="noopener noreferrer">https://www.mediafire.com</a></em></td></tr><tr><td style="text-align:center"><strong>Anonfiles</strong></td><td style="text-align:center">Same as for BayFiles</td><td style="text-align:center">It’s basically BayFiles on another domain</td><td style="text-align:center"><em><a href="https://anonfiles.com" target="_blank" rel="noopener noreferrer">https://anonfiles.com</a></em></td></tr><tr><td style="text-align:center"><strong>GoFile</strong></td><td style="text-align:center">Unlimited</td><td style="text-align:center">Still on beta-staging. No need to sign up. Files are deleted after 10 days if they haven’t been downloaded. <a href="https://gofile.io/faq" target="_blank" rel="noopener noreferrer">FAQ</a></td><td style="text-align:center"><em><a href="https://gofile.io/uploadFiles" target="_blank" rel="noopener noreferrer">https://gofile.io/uploadFiles</a></em></td></tr><tr><td style="text-align:center"><strong><a href="http://Transfer.sh" target="_blank" rel="noopener noreferrer">Transfer.sh</a></strong></td><td style="text-align:center">Unlimited</td><td style="text-align:center">Command-Line service. Files stored for 2 weeks. <a href="https://github.com/dutchcoders/transfer.sh" target="_blank" rel="noopener noreferrer">Open-Source</a></td><td style="text-align:center"><em><a href="https://transfer.sh" target="_blank" rel="noopener noreferrer">https://transfer.sh</a></em></td></tr><tr><td style="text-align:center"><strong>Degoo</strong></td><td style="text-align:center">100 Gb with an account + 500 Gb of theoric referral bonus</td><td style="text-align:center">The team behind InstaBridge. 1-year timeout of inactivity. Ads</td><td style="text-align:center"><em><a href="https://degoo.com/" target="_blank" rel="noopener noreferrer">https://degoo.com/</a></em></td></tr><tr><td style="text-align:center"><strong>LetsUpload</strong></td><td style="text-align:center">15 Gb per file max</td><td style="text-align:center">Inbuilt search engine. 15 days retention. Can be used as guest</td><td style="text-align:center"><em><a href="https://letsupload.io/" target="_blank" rel="noopener noreferrer">https://letsupload.io/</a></em></td></tr><tr><td style="text-align:center"><strong>TeraBox</strong></td><td style="text-align:center">1 Tb of storage space, 4 Gb per file/300 files at a time</td><td style="text-align:center">Get more informations <a href="https://www.terabox.com/help-center?from=web_login" target="_blank" rel="noopener noreferrer">here</a></td><td style="text-align:center"><em><a href="https://www.terabox.com/" target="_blank" rel="noopener noreferrer">https://www.terabox.com/</a></em></td></tr><tr><td style="text-align:center"><strong>TeraTransfer</strong></td><td style="text-align:center">50 Gb max for files/folders</td><td style="text-align:center">Limited open-beta from TeraBox, better check their website for additional informations</td><td style="text-align:center"><em><a href="https://www.terabox.com/transfer" target="_blank" rel="noopener noreferrer">https://www.terabox.com/transfer</a></em></td></tr><tr><td style="text-align:center"><strong>1fichier</strong></td><td style="text-align:center">300 Gb per file, retention of 15-30 days for inactivity, 1 Tb storage if you have an account</td><td style="text-align:center">Can be used as guest. <a href="https://1fichier.info/en/" target="_blank" rel="noopener noreferrer">FAQ</a></td><td style="text-align:center"><em><a href="https://1fichier.com/?lg=en" target="_blank" rel="noopener noreferrer">https://1fichier.com/?lg=en</a></em></td></tr><tr><td style="text-align:center"><strong>SubyShare</strong></td><td style="text-align:center">Their offer is quite hard to understand, <a href="https://subyshare.com/premium" target="_blank" rel="noopener noreferrer">check that</a></td><td style="text-align:center">Here is their <a href="https://subyshare.com/help/faq" target="_blank" rel="noopener noreferrer">FAQ</a></td><td style="text-align:center"><em><a href="http://subyshare.com" target="_blank" rel="noopener noreferrer">http://subyshare.com</a></em></td></tr><tr><td style="text-align:center"><strong>WeTransfer</strong></td><td style="text-align:center">2 Gb per file</td><td style="text-align:center">Link expire after 7 days. <a href="https://wetransfer.zendesk.com/hc/en-us" target="_blank" rel="noopener noreferrer">FAQ here</a></td><td style="text-align:center"><em><a href="https://wetransfer.com/" target="_blank" rel="noopener noreferrer">https://wetransfer.com/</a></em></td></tr><tr><td style="text-align:center"><strong>MyAirBridge</strong></td><td style="text-align:center">20 Gb per file max</td><td style="text-align:center">Can be used as guest. Files no longer exist after 3 days. <a href="https://info.myairbridge.com/en/faq" target="_blank" rel="noopener noreferrer">FAQ</a></td><td style="text-align:center"><em><a href="https://www.myairbridge.com/en/" target="_blank" rel="noopener noreferrer">https://www.myairbridge.com/en/</a></em></td></tr><tr><td style="text-align:center"><strong>TeraShare</strong></td><td style="text-align:center">Unlimited</td><td style="text-align:center">Kinda the sucessor of <a href="http://ToutBox.fr" target="_blank" rel="noopener noreferrer">ToutBox.fr</a>, uses the torrent protocol. Files under 10 Gb are uploaded to the cloud, above it uses P2P (so keep the file on your computer). Folder support. You need to install the desktop client, but then the web interface can be used (you can check <a href="http://terashare.net/technology" target="_blank" rel="noopener noreferrer">this</a> for more informations)</td><td style="text-align:center"><em><a href="http://terashare.net/" target="_blank" rel="noopener noreferrer">http://terashare.net/</a></em></td></tr><tr><td style="text-align:center"><strong>WormHole</strong></td><td style="text-align:center">5 Gb per file/10 Gb on P2P</td><td style="text-align:center">Files are kept for 24h. <a href="https://wormhole.app/faq" target="_blank" rel="noopener noreferrer">Infos here</a></td><td style="text-align:center"><em><a href="https://wormhole.app/" target="_blank" rel="noopener noreferrer">https://wormhole.app/</a></em></td></tr><tr><td style="text-align:center"><strong>Smash</strong></td><td style="text-align:center">Unlimited</td><td style="text-align:center">Files available for 7 days. <a href="https://en.fromsmash.com/features" target="_blank" rel="noopener noreferrer">Check the features page</a></td><td style="text-align:center"><em><a href="https://fromsmash.com/" target="_blank" rel="noopener noreferrer">https://fromsmash.com/</a></em></td></tr><tr><td style="text-align:center"><strong>SwissTranfer</strong></td><td style="text-align:center">50 Gb per file max</td><td style="text-align:center">Custom retention date up to 30 days, password protection, …</td><td style="text-align:center"><em><a href="https://www.swisstransfer.com/en" target="_blank" rel="noopener noreferrer">https://www.swisstransfer.com/en</a></em></td></tr></tbody></table><h2 id="sources" tabindex="-1"><a class="header-anchor" href="#sources">Sources</a></h2><ul><li><a href="https://support.google.com/a/answer/10403871#expand-all" target="_blank" rel="noopener noreferrer">Google</a></li><li>Universities that uses Drive :<ul><li><a href="https://cc.ncku.edu.tw/p/16-1002-218008.php?Lang=en" target="_blank" rel="noopener noreferrer">NCKU</a></li><li><a href="https://it.umn.edu/planned-changes/sustainable-storage-program/google-drive-storage-changes" target="_blank" rel="noopener noreferrer">Minnesota</a></li><li><a href="https://it.uni.edu/updates/google-changes-rules-storage-google-workspace-education" target="_blank" rel="noopener noreferrer">Northern Iowa</a></li><li><a href="https://uit.stanford.edu/service/gsuite/shareddrives" target="_blank" rel="noopener noreferrer">Stanford</a></li><li><a href="https://www.uah.edu/announcements/16929-google-storage-changes" target="_blank" rel="noopener noreferrer">Alabama</a></li><li><a href="https://www.google.com/search?q=shared+drives+july+2022&amp;sxsrf=ALiCzsaGOQ212uHO3dVClcr6W5zZ0mabMg%3A1652629168902&amp;ved=0ahUKEwi487q06-H3AhUq5IUKHQFpCKYQ4dUDCA4&amp;uact=5&amp;oq=shared+drives+july+2022&amp;sclient=gws-wiz" target="_blank" rel="noopener noreferrer">a lot of others…</a></li></ul></li><li><a href="https://t.me/HashHackers" target="_blank" rel="noopener noreferrer">HashHackers</a></li></ul><h3 id="keep-in-touch-with-me-smiling-face-with-three-hearts" tabindex="-1"><a class="header-anchor" href="#keep-in-touch-with-me-smiling-face-with-three-hearts">Keep in touch with me 🥰</a></h3><p><a href="https://github.com/EDM115" target="_blank" rel="noopener noreferrer">https://github.com/EDM115</a><br><a href="https://t.me/EDM115" target="_blank" rel="noopener noreferrer">https://t.me/EDM115</a></p>]]></content:encoded>
            <category>cloud</category>
            <category>storage</category>
            <category>google</category>
        </item>
    </channel>
</rss>
