<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Thomas Künneth</title>
    <description>The latest articles on DEV Community by Thomas Künneth (@tkuenneth).</description>
    <link>https://dev.to/tkuenneth</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F299234%2F73e12d18-536f-4725-bef0-bc0e7e1d4348.jpg</url>
      <title>DEV Community: Thomas Künneth</title>
      <link>https://dev.to/tkuenneth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tkuenneth"/>
    <language>en</language>
    <item>
      <title>Adaptable apps on ChromeOS: a post-mortem</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Sat, 23 May 2026 11:26:02 +0000</pubDate>
      <link>https://dev.to/tkuenneth/adaptable-apps-on-chromeos-a-post-mortem-2gl1</link>
      <guid>https://dev.to/tkuenneth/adaptable-apps-on-chromeos-a-post-mortem-2gl1</guid>
      <description>&lt;p&gt;In my previous article &lt;a href="https://dev.to/tkuenneth/building-a-custom-launcher-for-chromeos-4fb7"&gt;Building a custom launcher for ChromeOS&lt;/a&gt; I described how &lt;em&gt;Be nice&lt;/em&gt; runs on Chromebooks: not as a real default home app, because default home settings (&lt;code&gt;Settings.ACTION_HOME_SETTINGS&lt;/code&gt;) usually are not available on ChromeOS, but as a normal Android app in an ARC window. I pretended the app is the launcher in code (&lt;code&gt;detectIsHomeApp()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; on ChromeOS), worked around split-screen bugs that leave the app a black rectangle, and gave up transparent wallpaper for an opaque scaffold because ARC does not show the ChromeOS desktop behind the window the way a phone shows its wallpaper.&lt;/p&gt;

&lt;p&gt;What that article obviously could not cover is the fight that came right after. For about a week in May 2026 I tried to get rid of a system confirmation ChromeOS shows when users open the preset-size menu in the ARC window title bar and choose &lt;strong&gt;Resizable&lt;/strong&gt;, at least on Play Store builds. During my experiments, manifest changes often looked fine from Android Studio; however, on Play, the dialog stayed. I tried adding XML, asking AI assistants, including Google’s own models, for the magic flag. Here’s what I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What users see
&lt;/h2&gt;

&lt;p&gt;ChromeOS does not present Play Store Android apps the same way in every posture. On a convertible or clamshell Chromebook in laptop use, ARC usually places apps in individual windows (&lt;a href="https://chromeos.dev/en/android/window-management" rel="noopener noreferrer"&gt;chromeos.dev&lt;/a&gt;). The title bar often labels the current layout (&lt;strong&gt;Phone&lt;/strong&gt;, &lt;strong&gt;Tablet&lt;/strong&gt;, or &lt;strong&gt;Resizable&lt;/strong&gt;) and opens a menu to switch presets; choosing &lt;strong&gt;Resizable&lt;/strong&gt; is what triggers the warning below, not dragging the window frame by itself (in fact, resizing the window by dragging one of the window edges does not work until it is allowed through the dialog). In tablet mode the picture is different: apps commonly launch and stay full screen, which matches how ChromeOS has long treated touch-first use on detachables (&lt;a href="https://chromeunboxed.com/chromebooks-getting-androids-full-screen-immersive-mode/" rel="noopener noreferrer"&gt;Chrome Unboxed on immersive mode&lt;/a&gt;). The resize presets and the warning below matter most when you are in a windowed, laptop-style layout, not when an app is already filling the screen in tablet mode.&lt;/p&gt;

&lt;p&gt;From that menu, when you choose &lt;strong&gt;Resizable&lt;/strong&gt; in a windowed, laptop-style layout, ChromeOS brings up a confirmation that has been written about since around 2021 (&lt;a href="https://chromeunboxed.com/chromebook-android-app-resizing-lock-ui-first-look" rel="noopener noreferrer"&gt;Chrome Unboxed&lt;/a&gt;, &lt;a href="https://chromenerd.com/how-to-resize-android-apps-on-chromebook/" rel="noopener noreferrer"&gt;Chrome Nerd&lt;/a&gt;):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This app is designed for mobile and may not resize well. The app may experience issues or restart.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Some apps never offer Tablet or Resizable at all; they stay on Phone with “This app only supports this size”, which is the stricter case when developers set &lt;code&gt;resizeableActivity="false"&lt;/code&gt; (&lt;a href="https://www.aboutchromebooks.com/cant-resize-certain-android-apps-on-your-chromebook-heres-why/" rel="noopener noreferrer"&gt;About Chromebooks&lt;/a&gt;). &lt;em&gt;Be nice&lt;/em&gt; was not locked like that; &lt;strong&gt;Tablet&lt;/strong&gt; and &lt;strong&gt;Resizable&lt;/strong&gt; appeared in the title-bar menu, but the mobile warning still appeared when I chose &lt;strong&gt;Resizable&lt;/strong&gt; on Play installs.&lt;/p&gt;

&lt;p&gt;What made debugging miserable was &lt;strong&gt;Studio versus Play&lt;/strong&gt;. Local installs felt better; store builds did not. &lt;a href="https://support.google.com/googleplay/answer/7021273?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Play Help&lt;/a&gt; describes Phone, Tablet, and Resizable presets accessible directly from the window title bar, which matches how I used them on my Chromebook, though the exact entry point may differ by ChromeOS version. Play Help also says resize presets apply to newly downloaded apps, so I shipped many builds in quick succession. Fortunately there is an internal testing track. Never do this in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I started with
&lt;/h2&gt;

&lt;p&gt;The manifest did not spell out ChromeOS windowing. There was no explicit &lt;code&gt;resizeableActivity&lt;/code&gt;, no ChromeOS window metadata, and no optional touchscreen or PC hardware features. On ordinary Android that omission was deliberate: for apps targeting API level 24 and higher, &lt;code&gt;resizeableActivity&lt;/code&gt; defaults to &lt;code&gt;"true"&lt;/code&gt; when you leave it out (&lt;a href="https://developer.android.com/guide/topics/manifest/application-element?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;application&amp;gt;&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco#resizeableActivity" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;activity&amp;gt;&lt;/code&gt;&lt;/a&gt;). &lt;em&gt;Be nice&lt;/em&gt; targets API 37 with &lt;code&gt;minSdk&lt;/code&gt; 28, so phone and tablet builds were already resizable in multi-window terms; I had never needed the attribute in XML. What I lacked were the Chromebook-specific hints, not the baseline Android flag. The launcher already listened for size-related &lt;code&gt;configChanges&lt;/code&gt;, including &lt;code&gt;smallestScreenSize&lt;/code&gt;, but I had not told ChromeOS how I wanted freeform launch or Play classification on devices without touch. The working theory was simple: add the flags Google documents for Chromebooks, and the dialog would go away.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Declare the app resizable.&lt;/strong&gt; I set &lt;code&gt;android:resizeableActivity="true"&lt;/code&gt; on the application and told Play users I had “improved detection of the app's resize capabilities on ChromeOS.” On Android I had mostly made explicit what the target SDK already implied; it did not unlock resize on phones or tablets that was missing before. It does not remove the “designed for mobile” prompt when someone picks &lt;strong&gt;Resizable&lt;/strong&gt; on ChromeOS either. I had conflated “allowed to resize” with “ChromeOS will not warn.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tell the system I support size changes.&lt;/strong&gt; I added &lt;code&gt;android.supports_size_changes&lt;/code&gt; at the application level, later duplicated it on individual activities, and at one point switched from &lt;code&gt;&amp;lt;meta-data&amp;gt;&lt;/code&gt; to &lt;code&gt;&amp;lt;property&amp;gt;&lt;/code&gt;. The hope was that ChromeOS would treat the app as resize-aware and skip the warning. Nothing in the public docs ties that flag to suppressing the dialog; the warning outlived every variant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask for a tablet-shaped launch.&lt;/strong&gt; I set &lt;code&gt;WindowManagerPreference:FreeformWindowSize&lt;/code&gt; to &lt;code&gt;tablet&lt;/code&gt; on the home activity so the window would open in a tablet preset rather than phone-sized mobile. Changelog copy spoke of “default tablet window size.” That metadata only affects how the window &lt;strong&gt;opens&lt;/strong&gt; in freeform mode (&lt;a href="https://chromeos.dev/en/android/window-management" rel="noopener noreferrer"&gt;chromeos.dev&lt;/a&gt;), not whether the user sees the mobile warning when they switch to &lt;strong&gt;Resizable&lt;/strong&gt;. I later &lt;strong&gt;removed&lt;/strong&gt; the tablet preset while still chasing the same popup, so I had already abandoned my own hypothesis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin static launch bounds.&lt;/strong&gt; I added &lt;code&gt;&amp;lt;layout&amp;gt;&lt;/code&gt; on the launcher (and eventually on other activities) with default width and height in dp; I tried 840×640 first, then larger bounds as the experiment went on. At the same time I still had, or had just had, tablet freeform metadata on the same activity. Documentation warns that static &lt;code&gt;&amp;lt;layout&amp;gt;&lt;/code&gt; bounds and &lt;code&gt;FreeformWindowSize&lt;/code&gt; can conflict; I ran both anyway. Without success, that is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Declare Chromebook-friendly hardware.&lt;/strong&gt; Following a suggestion from Gemini, I marked &lt;code&gt;touchscreen&lt;/code&gt;, &lt;code&gt;faketouch&lt;/code&gt;, and &lt;code&gt;android.hardware.type.pc&lt;/code&gt; as not required, and added &lt;code&gt;&amp;lt;supports-screens&amp;gt;&lt;/code&gt; with every size bucket enabled. The touchscreen and PC entries are appropriate for Play on Chromebooks (&lt;a href="https://developer.android.com/develop/devices/chromeos/learn/manifest?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;manifest compatibility&lt;/a&gt;), but they address &lt;strong&gt;installability and input&lt;/strong&gt;, not the resize confirmation string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Widen &lt;code&gt;configChanges&lt;/code&gt; to avoid restarts.&lt;/strong&gt; I started with a modest list, then expanded it, then trimmed it back down. At one point the manifest listed almost every flag I could find, including locale, font scale, and color mode, on the theory that activity restarts on resize triggered bad behavior or the warning. It became unreadable, and it did not help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activity embedding override.&lt;/strong&gt; I even added &lt;code&gt;PROPERTY_ACTIVITY_EMBEDDING_ALLOW_SYSTEM_OVERRIDE&lt;/code&gt; at the application level. That belongs to a different feature (embedding / multi-pane); it has no documented link to ChromeOS freeform resize warnings, and, to no one's surprise, it did not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time to recap
&lt;/h2&gt;

&lt;p&gt;Declaring &lt;code&gt;resizeableActivity&lt;/code&gt; tells the system the app accepts window resize (rather than staying in a fixed compatibility viewport on large screens); it does not opt out of the Resizable confirmation. &lt;code&gt;supports_size_changes&lt;/code&gt; and launch metadata do not either. Optional touchscreen declarations matter for Chromebook distribution; they were never documented as silencing “designed for mobile.” The dialog fits Google’s preset-size UX, which is a way for users to pick Phone or Tablet without every app being fully adaptive (&lt;a href="https://androidcommunity.com/chrome-os-compatibility-mode-preset-windows-sizes-may-be-introduced-20211028/" rel="noopener noreferrer"&gt;Android Community&lt;/a&gt;). I had spent a week tuning manifest knobs for a message the OS shows on purpose when the user chooses &lt;strong&gt;Resizable&lt;/strong&gt;, and for which I have not found an officially documented, bulletproof way out.&lt;/p&gt;

&lt;p&gt;The biggest learning was not to underestimate what changes when the app comes from a Play install. Installing from Android Studio had led me to think the problem was solved; the store build told a different story. Play distribution, install context, and ChromeOS window presets are part of the behavior you are debugging, and they are not fully visible if you only deploy from the IDE.&lt;/p&gt;

&lt;p&gt;Nothing in this effort demonstrated that the “designed for mobile” dialog can be removed from the manifest alone. I continue with a manifest that reflects adaptability on Android in general: &lt;code&gt;resizeableActivity&lt;/code&gt; on the application, &lt;code&gt;android.supports_size_changes&lt;/code&gt; on the home activity, optional &lt;code&gt;touchscreen&lt;/code&gt; / &lt;code&gt;type.pc&lt;/code&gt; hardware declarations for desktop-class devices, and size-related &lt;code&gt;configChanges&lt;/code&gt; as &lt;a href="https://chromeos.dev/en/android/window-management" rel="noopener noreferrer"&gt;chromeos.dev&lt;/a&gt;'s second option for window reshape. The adaptive UI itself stays in Compose (&lt;code&gt;WindowSizeClass&lt;/code&gt; and layouts that follow the window).&lt;/p&gt;

&lt;p&gt;That is the story in prose; here is what it looks like in &lt;code&gt;app/src/main/AndroidManifest.xml&lt;/code&gt; today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="utf-8"?&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;manifest&lt;/span&gt; &lt;span class="na"&gt;xmlns:tools=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.android.com/tools"&lt;/span&gt;
    &lt;span class="na"&gt;xmlns:android=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.android.com/apk/res/android"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;queries&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;intent&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.action.MAIN"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.LAUNCHER"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/intent&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/queries&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;uses-feature&lt;/span&gt;
        &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.hardware.touchscreen"&lt;/span&gt;
        &lt;span class="na"&gt;android:required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-feature&lt;/span&gt;
        &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.hardware.faketouch"&lt;/span&gt;
        &lt;span class="na"&gt;android:required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-feature&lt;/span&gt;
        &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.hardware.type.pc"&lt;/span&gt;
        &lt;span class="na"&gt;android:required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;application&lt;/span&gt;
        &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".BeNiceApplication"&lt;/span&gt;
        &lt;span class="na"&gt;android:allowBackup=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
        &lt;span class="na"&gt;android:dataExtractionRules=&lt;/span&gt;&lt;span class="s"&gt;"@xml/data_extraction_rules"&lt;/span&gt;
        &lt;span class="na"&gt;android:enableOnBackInvokedCallback=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
        &lt;span class="na"&gt;android:fullBackupContent=&lt;/span&gt;&lt;span class="s"&gt;"@xml/backup_rules"&lt;/span&gt;
        &lt;span class="na"&gt;android:icon=&lt;/span&gt;&lt;span class="s"&gt;"@mipmap/ic_launcher"&lt;/span&gt;
        &lt;span class="na"&gt;android:label=&lt;/span&gt;&lt;span class="s"&gt;"@string/app_name"&lt;/span&gt;
        &lt;span class="na"&gt;android:resizeableActivity=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
        &lt;span class="na"&gt;android:supportsRtl=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
        &lt;span class="na"&gt;android:theme=&lt;/span&gt;&lt;span class="s"&gt;"@style/Theme.BeNice"&lt;/span&gt;
        &lt;span class="na"&gt;tools:ignore=&lt;/span&gt;&lt;span class="s"&gt;"UnusedAttribute"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;activity&lt;/span&gt;
            &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".AppChooserActivity"&lt;/span&gt;
            &lt;span class="na"&gt;android:configChanges=&lt;/span&gt;&lt;span class="s"&gt;"orientation|screenLayout|screenSize|smallestScreenSize"&lt;/span&gt;
            &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
            &lt;span class="na"&gt;android:launchMode=&lt;/span&gt;&lt;span class="s"&gt;"singleTask"&lt;/span&gt;
            &lt;span class="na"&gt;android:stateNotNeeded=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
            &lt;span class="na"&gt;android:windowSoftInputMode=&lt;/span&gt;&lt;span class="s"&gt;"adjustResize"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;meta-data&lt;/span&gt;
                &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.supports_size_changes"&lt;/span&gt;
                &lt;span class="na"&gt;android:value=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.action.MAIN"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.LAUNCHER"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.action.MAIN"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.HOME"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;category&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.category.DEFAULT"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/activity&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;activity&lt;/span&gt;
            &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".ImageViewerActivity"&lt;/span&gt;
            &lt;span class="na"&gt;android:configChanges=&lt;/span&gt;&lt;span class="s"&gt;"orientation|screenLayout|screenSize|smallestScreenSize"&lt;/span&gt;
            &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.intent.action.QUICK_VIEW"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/activity&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;activity&lt;/span&gt;
            &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".BeNiceActivity"&lt;/span&gt;
            &lt;span class="na"&gt;android:configChanges=&lt;/span&gt;&lt;span class="s"&gt;"orientation|screenLayout|screenSize|smallestScreenSize"&lt;/span&gt;
            &lt;span class="na"&gt;android:excludeFromRecents=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
            &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"de.thomaskuenneth.benice.intent.action.ACTION_LAUNCH_APP"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"de.thomaskuenneth.benice.intent.action.ACTION_LAUNCH_APP_PAIR"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/activity&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;service&lt;/span&gt;
            &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".BeNiceTileService"&lt;/span&gt;
            &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
            &lt;span class="na"&gt;android:icon=&lt;/span&gt;&lt;span class="s"&gt;"@drawable/tile_service_icon"&lt;/span&gt;
            &lt;span class="na"&gt;android:label=&lt;/span&gt;&lt;span class="s"&gt;"@string/app_name"&lt;/span&gt;
            &lt;span class="na"&gt;android:permission=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.BIND_QUICK_SETTINGS_TILE"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;intent-filter&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;action&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.service.quicksettings.action.QS_TILE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/intent-filter&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/service&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;receiver&lt;/span&gt;
            &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".ShortcutPinnedReceiver"&lt;/span&gt;
            &lt;span class="na"&gt;android:exported=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;/application&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/manifest&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you strip away the experiments, two resize declarations and one documented fallback are still worth keeping, and none of them is the magic flag that silences the dialog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;android:resizeableActivity="true"&lt;/code&gt; on &lt;code&gt;&amp;lt;application&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Google expects apps that behave well when resized to declare &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco#resizeableActivity" rel="noopener noreferrer"&gt;&lt;code&gt;resizeableActivity&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://developer.android.com/guide/practices/device-compatibility-mode?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;&lt;code&gt;supports_size_changes&lt;/code&gt;&lt;/a&gt; as enabled (&lt;a href="https://developer.android.com/guide/practices/device-compatibility-mode?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;device compatibility mode&lt;/a&gt;). At my target SDK the default is already true; I keep it explicit anyway.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;meta-data android:name="android.supports_size_changes" android:value="true" /&amp;gt;&lt;/code&gt; on &lt;code&gt;AppChooserActivity&lt;/code&gt;.&lt;/strong&gt; Same guidance: declare that the activity handles size changes without fighting compatibility mode. I put it on the home activity, not on every screen.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"&lt;/code&gt; on activities.&lt;/strong&gt; &lt;a href="https://chromeos.dev/en/android/window-management" rel="noopener noreferrer"&gt;chromeos.dev&lt;/a&gt; lists this as its &lt;strong&gt;second&lt;/strong&gt; option for resize; the &lt;strong&gt;first&lt;/strong&gt; is ViewModel plus saved state so activity recreation stays cheap. I use the flags to skip a full tear-down when the ARC window changes shape. Current &lt;a href="https://developer.android.com/develop/adaptive-apps/guides/get-started-with-adaptive-apps?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;adaptive app&lt;/a&gt; guidance puts &lt;a href="https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;&lt;code&gt;currentWindowAdaptiveInfo()&lt;/code&gt;&lt;/a&gt; and responsive Compose layouts first; I do not override &lt;code&gt;onConfigurationChanged&lt;/code&gt;, and I am not claiming this manifest line is the adaptability strategy. It is optional lifecycle plumbing on top of &lt;code&gt;WindowSizeClass&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The arc is not a win. It means accepting that Google never documented a clean way out of the Resizable warning, that Play behavior on Chromebooks stays largely opaque until you ship and test there, and that ChromeOS itself feels like a platform Google is letting fade while Aluminium OS and unified Android desktop take the spotlight.&lt;/p&gt;

&lt;p&gt;What's more, there is that feeling of being treated unfairly. Google pushes developers hard on making their apps behave well on as many device categories and form factors as possible, yet even first class citizens are stigmatised by this popup.&lt;/p&gt;

&lt;p&gt;Has anyone found a workaround, or is this firmly an OS-level restriction? Let me know in the comments if you've felt this pain, too.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://chromeunboxed.com/chromebook-android-app-resizing-lock-ui-first-look" rel="noopener noreferrer"&gt;Chrome Unboxed: preset sizes and dialog text&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://chromenerd.com/how-to-resize-android-apps-on-chromebook/" rel="noopener noreferrer"&gt;Chrome Nerd: ChromeOS resize confirmation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.aboutchromebooks.com/android-app-resizing-on-chromebooks/" rel="noopener noreferrer"&gt;About Chromebooks: Phone / Tablet / Resizable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/googleplay/answer/7021273?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Google Play Help: resize Android apps on Chromebook&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://chromeos.dev/en/android/window-management" rel="noopener noreferrer"&gt;chromeos.dev: Window management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.android.com/develop/devices/chromeos/learn/manifest?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Android Developers: ChromeOS manifest compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codelabs.developers.google.com/codelabs/android-resizing?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Google Codelabs: Android App Resizing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>android</category>
      <category>chromeos</category>
      <category>development</category>
      <category>uidesign</category>
    </item>
    <item>
      <title>Building a custom launcher for ChromeOS</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Thu, 14 May 2026 11:11:33 +0000</pubDate>
      <link>https://dev.to/tkuenneth/building-a-custom-launcher-for-chromeos-4fb7</link>
      <guid>https://dev.to/tkuenneth/building-a-custom-launcher-for-chromeos-4fb7</guid>
      <description>&lt;p&gt;In this article, I will share some of my experience of enhancing &lt;a href="https://codeberg.org/tkuenneth/benice" rel="noopener noreferrer"&gt;Be nice&lt;/a&gt; to be a launcher on ChromeOS. Now, why would I want to do that anyway? I use my ChromeOS detachable more like an Android tablet than a traditional laptop; therefore, I find myself deeply missing the Android goodness that Google's desktop OS tends to strip away. Chief among these missing features is support for app widgets. &lt;em&gt;Be nice&lt;/em&gt; has app widget support, so it would be great to run my app on ChromeOS. On a standard Android phone or tablet you can simply swap the home app in settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;changeDefaultHomeApp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTION_HOME_SETTINGS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;addFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FLAG_ACTIVITY_NEW_TASK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;PackageManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MATCH_DEFAULT_ONLY&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unfortunately, ChromeOS forces you to use its native environment. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgblajx3wryua53or551s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgblajx3wryua53or551s.png" alt="Screenshot of a system message reading *This setting is not supported*" width="800" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Consequently, the launcher will be treated just like any other Android app. What does this imply? While we can easily launch other apps, core launcher features like returning to the home screen via the system gesture or home button, acting as the dedicated home activity that fills the display with the normal Android home wallpaper visible behind transparent UI, and integration with the system-level overview or task switcher are not available. Instead, the launcher stays contained within its own window, meaning a swipe up or a press of the &lt;em&gt;Everything Button&lt;/em&gt; will still take you back to the native ChromeOS shelf rather than your custom interface.&lt;/p&gt;

&lt;p&gt;And there is another nasty issue. Before &lt;em&gt;Be nice&lt;/em&gt; became a genuine launcher, its main goal was to run two apps side by side, utilizing the split-screen capabilities that are present in Android since 7.0 (Nougat). On ChromeOS, this behavior is consistently buggy. Specifically, when the launcher attempts to start another activity in the adjacent window for a side-by-side view, the originating app (&lt;em&gt;Be nice&lt;/em&gt; itself) fails to redraw correctly and simply becomes a black, unresponsive rectangle. I have created an &lt;a href="https://issuetracker.google.com/issues/332903525" rel="noopener noreferrer"&gt;issue tracker item&lt;/a&gt; for this, as the black screen glitch makes automated multitasking nearly impossible on ChromeOS. The issue is, at the time of writing this article, marked as &lt;em&gt;Assigned&lt;/em&gt;, though there has been no significant movement toward a fix. It is disheartening to see such a fundamental multitasking feature remain broken while Google continues to market these devices as serious productivity tools.&lt;/p&gt;

&lt;p&gt;Let's return to &lt;em&gt;Be nice&lt;/em&gt;. When the app is the default launcher, tapping an item in its &lt;em&gt;Apps list&lt;/em&gt; launches the desired app just like any launcher would; however, when &lt;em&gt;Be nice&lt;/em&gt; is not the default home app, it intentionally opens the tapped app in split-screen. While the &lt;em&gt;default home app&lt;/em&gt; behavior is what we want on ChromeOS, on that platform we just can't become the launcher. To cater for this, I changed &lt;em&gt;Be nice&lt;/em&gt; to do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;detectIsHomeApp&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isRunningOnChromeOs&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;roleManager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
      &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoleManager&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;roleManager&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;isRoleHeld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RoleManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ROLE_HOME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTION_MAIN&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;addCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CATEGORY_HOME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;resolveInfo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
      &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;PackageManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MATCH_DEFAULT_ONLY&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resolveInfo&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;activityInfo&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageName&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But what does &lt;code&gt;isRunningOnChromeOs()&lt;/code&gt; do?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isRunningOnChromeOs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageManager&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasSystemFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SYSTEM_FEATURE_TYPE_CHROMEBOOK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasSystemFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SYSTEM_FEATURE_ARC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
    &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasSystemFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SYSTEM_FEATURE_ARC_DEVICE_MANAGEMENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because ChromeOS will not let &lt;em&gt;Be nice&lt;/em&gt; hold the real home role, &lt;code&gt;RoleManager&lt;/code&gt; would always report that we are not the default launcher. The early &lt;code&gt;return true&lt;/code&gt; in &lt;code&gt;detectIsHomeApp()&lt;/code&gt; is therefore a deliberate in‑app policy switch: on ChromeOS we pretend we are the home app so the rest of the codebase can follow the same paths it uses when &lt;em&gt;Be nice&lt;/em&gt; truly is the launcher on phones and tablets, without claiming any privilege the OS refuses to grant.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isRunningOnChromeOs()&lt;/code&gt; exists because a single &lt;code&gt;PackageManager&lt;/code&gt; feature is not reliable across ARC/ARCVM builds: &lt;code&gt;android.hardware.type.chromebook&lt;/code&gt; was absent on my device, so relying on it alone left detection false and the old UX in place. &lt;code&gt;org.chromium.arc&lt;/code&gt; (and the related &lt;code&gt;org.chromium.arc.device_management&lt;/code&gt; flag) matches what Android’s own docs and compatibility tooling use for the ChromeOS Android runtime; we still OR in the chromebook feature string when the system exposes it. Together, that trio is a pragmatic runtime probe; not a perfect definition of &lt;em&gt;ChromeOS everywhere&lt;/em&gt;, but a stable way to branch behavior where the platform already diverges from stock Android.&lt;/p&gt;

&lt;h3&gt;
  
  
  About wallpapers
&lt;/h3&gt;

&lt;p&gt;On phones and tablets, &lt;em&gt;Be nice&lt;/em&gt; is written like a classic launcher: the app theme turns on &lt;code&gt;android:windowShowWallpaper&lt;/code&gt; and uses a transparent window background, and the main shell keeps the Material &lt;code&gt;Scaffold&lt;/code&gt; surface transparent so the Android home wallpaper can show through wherever the UI does not paint an opaque layer. I do not sample the wallpaper with &lt;code&gt;WallpaperManager&lt;/code&gt; and draw it myself; instead, I rely on the system to composite the wallpaper behind the window, which is simple and matches how many launchers behave.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;style&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Theme.BeNice"&lt;/span&gt; &lt;span class="na"&gt;parent=&lt;/span&gt;&lt;span class="s"&gt;"Theme.AppCompat.DayNight.NoActionBar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"android:windowShowWallpaper"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"android:windowBackground"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;@android:color/transparent&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Compose tree leans into that model as well. For example, when &lt;em&gt;Be nice&lt;/em&gt; believes it is acting as the default home app and the pager actually has transitions between widget-style home pages and other pages, the horizontal pager applies a fade on those transitions so the swipe does not feel like hard cuts over a solid sheet; visually that only works if there is something worth looking at behind the transparent regions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;wallpaperFadeEdgeIndices&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;computeWallpaperFadeEdgeIndices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;useHomeWallpaperPagerFade&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isHomeApp&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;wallpaperFadeEdgeIndices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ChromeOS breaks the mental model. The wallpaper you care about on a Chromebook is usually the ChromeOS desktop behind all windows, while &lt;em&gt;Be nice&lt;/em&gt; runs inside the Android (ARC) window. &lt;code&gt;windowShowWallpaper&lt;/code&gt; still asks the Android side to show its wallpaper layer, but that layer is often weak, empty, or simply not the same thing as the ChromeOS background you identify as &lt;em&gt;my wallpaper&lt;/em&gt;. Transparent UI that looks intentional on a Pixel can read as muddy, flat, or accidental in a resizable Android window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nc"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;containerColor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaultAppsManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isRunningOnChromeOs&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;background&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ComposeColor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Transparent&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even after switching the main &lt;code&gt;Scaffold&lt;/code&gt; to an opaque background on ChromeOS, the pager’s cross-fade still runs only when the app considers itself the home app and the page list includes at least one transition between a widget-style home page and a non-widget page (the same situation &lt;code&gt;useHomeWallpaperPagerFade&lt;/code&gt; encodes), so the animation eases between pages over the opaque shell, not over the live wallpaper, without pretending ARC gives you a phone-quality backdrop.&lt;/p&gt;

&lt;p&gt;So the pragmatic fix is boring but honest: on ChromeOS, stop pretending the ARC wallpaper layer is a beautiful backdrop. In the main activity I branch &lt;code&gt;Scaffold&lt;/code&gt;’s &lt;code&gt;containerColor&lt;/code&gt;: opaque theme background on ChromeOS, transparent on everything else. That trades the phone-launcher aesthetic on ChromeOS for a predictable in-window surface, which matches how the platform actually presents Android apps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrap-up
&lt;/h3&gt;

&lt;p&gt;A pragmatic fix is also a boring one: a solid theme background is honest about ARC, but it is not necessarily nice. In a follow-up, I may explore what it would take to get an actually pleasant backdrop on ChromeOS, without lying to myself that &lt;code&gt;windowShowWallpaper&lt;/code&gt; is doing the same job it does on a phone, whether that means sampling and drawing wallpaper myself, using a curated gradient or image asset, or finding a host-supported way to align with ChromeOS personalization. If you have solved this cleanly in a launcher-style app on ARC, I would love pointers.&lt;/p&gt;

&lt;p&gt;Beyond wallpaper, the other open question is how to replace the system gestures you do not get on ChromeOS (home, recents, and that &lt;em&gt;snap back to launcher&lt;/em&gt; feeling), without fighting the shell. I have started experimenting with notifications as a lightweight, always-reachable affordance: not as a fake home button, but as a predictable escape hatch back into &lt;em&gt;Be nice&lt;/em&gt; when the OS route is wrong for how I actually use the device. I do not have a polished pattern yet. If you have tried notification-driven navigation (or a better substitute) for ARC-hosted &lt;em&gt;almost launchers&lt;/em&gt;, kindly share what worked in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>chromeos</category>
      <category>ui</category>
      <category>programming</category>
    </item>
    <item>
      <title>Bubble up - Android 17's new multitasking feature explained</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Mon, 11 May 2026 14:55:40 +0000</pubDate>
      <link>https://dev.to/tkuenneth/bubble-up-android-17s-new-multitasking-feature-explained-l58</link>
      <guid>https://dev.to/tkuenneth/bubble-up-android-17s-new-multitasking-feature-explained-l58</guid>
      <description>&lt;p&gt;Only with the release of 12L, Google became serious about making Android a productivity powerhouse across screen sizes and form factors. While we had tablets much earlier, neither the OS nor Mountain View’s flagship apps took advantage. With 12L, we got the persistent taskbar, which made dragging and dropping apps into split-screen mode a natural gesture. Subsequent updates like Android 14 and 15 refined this by adding app pairs and improving how the system handles unoptimized apps in multi-window mode. Android 16 introduced the Desktop Mode that allows for freeform window resizing similar to a traditional PC. And yes, we had seen precursors of this much earlier. Android 7.0 Nougat shipped with a hidden freeform windows developer flag, though it languished for years without proper app support or official ecosystem commitment.&lt;/p&gt;

&lt;p&gt;While ChromeOS can be considered a productivity hub, too (many devices are either detachables or convertibles, and some come with a stylus in the box), it's not Android. Although it runs Android apps (in a container called ArcVM), and even though the apps are shown in freely movable and resizable windows, apps run on an outdated Android version (depending on the device, Android 11, 13, or 15); what's more, the integration is plagued by a couple of annoying user-facing bugs that make the split-screen feature practically unusable.&lt;/p&gt;

&lt;h3&gt;
  
  
  (Chat) Bubbles
&lt;/h3&gt;

&lt;p&gt;With chat bubbles, Google introduced an alternative to notifications, specifically targeting conversations. Introduced with Android 11, these floating windows aimed at making chats more accessible without switching apps by putting them on top of other content. The implementation always felt like an experiment. The Bubbles API is an extension of the &lt;code&gt;ShortcutInfo&lt;/code&gt; and &lt;code&gt;Notification&lt;/code&gt; APIs. To this day, the feature is tied to the &lt;code&gt;MessagingStyle&lt;/code&gt; notification category. Consequently, developers can't just bubble any arbitrary app component, limiting a promising windowing concept to a niche utility for messengers like WhatsApp, Signal, or Telegram. Until Android 17, that is. This version can finally treat (almost) every app as a flexible, floating entity that can live on top of other apps.&lt;/p&gt;

&lt;p&gt;Apps are bubbled up from their launcher icons. By long-pressing any app on the home screen, app drawer, or taskbar, you’ll find a new Bubble option in the context menu. Tapping it pops the app into a floating window that persists over the current activity. On larger screens, you can drag an app icon from the taskbar directly into a screen corner to trigger the transition. Once active, the bubbles can be moved freely, stacked with other apps, or minimized into a floating pill on the edge of the display.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/uzkzaq0hqyA"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Transforming an app into a floating bubble, fortunately, is far from requiring a total rewrite. But it does require moving away from a fixed-screen mindset towards adaptability. On Android 17, the system treats bubbles as a specialized windowing mode; therefore, the technical hurdles are now closely aligned with standard multi-window support. To ensure your app is ready to be bubbled, it must meet these core requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support Multi-Window Mode&lt;/li&gt;
&lt;li&gt;Correctly handle configuration changes&lt;/li&gt;
&lt;li&gt;Target API level 37&lt;/li&gt;
&lt;li&gt;Optimize for Adaptive Layouts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A Glimpse Behind the Curtain
&lt;/h3&gt;

&lt;p&gt;The new Bubbles feature required a fundamental reorganization of the system's task management. Android now leverages &lt;code&gt;TaskView&lt;/code&gt; (the robust successor to the internal &lt;code&gt;ActivityView&lt;/code&gt;) to reparent an entire app task into a specialized floating container. This architecture is a direct sibling to the new Desktop Mode, sharing the same underlying freeform windowing logic. Internally, this convergence is often referred to as &lt;em&gt;Desktop Interactive PiP&lt;/em&gt; (iPiP), which leverages the &lt;code&gt;USE_PINNED_WINDOWING_LAYER&lt;/code&gt; permission to manage these interactive, persistent layers.&lt;/p&gt;

&lt;p&gt;When an app is bubbled, the &lt;code&gt;WindowManagerService&lt;/code&gt; creates a virtual display environment, intercepting and translating touch events and window insets in real-time to ensure the app remains responsive in its new dimensions. To keep the transition fluid, the system utilizes Shell Transitions, performing an atomic handoff that moves the task from a full-screen state to a floating layer without a flicker. This process avoids the typical destroy-and-recreate lifecycle by heavily favoring &lt;code&gt;onConfigurationChanged&lt;/code&gt;, preserving the user’s backstack and state perfectly. &lt;/p&gt;

&lt;p&gt;Ultimately, what looks like a simple UI pop is a complex orchestration of task reparenting and display abstraction that brings Android closer to a true desktop-class windowing engine.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Secondary Display Pitfall
&lt;/h4&gt;

&lt;p&gt;Because the system creates a virtual display environment to host the bubbled task, your app is technically no longer running on the default display. This introduces an important requirement for context management:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Avoid Application Context&lt;/strong&gt;: Always use the &lt;code&gt;Activity&lt;/code&gt; context for UI-related tasks, such as inflating layouts or querying &lt;code&gt;WindowManager&lt;/code&gt;. The &lt;code&gt;Application&lt;/code&gt; context is tied to the primary display and will not reflect the bubble's unique window metrics, density, or insets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Layout Queries&lt;/strong&gt;: Don't cache screen dimensions at startup. Since the bubble can be resized or moved between different display states (like folding/unfolding), your app must rely on &lt;code&gt;WindowMetrics&lt;/code&gt; and the &lt;code&gt;onConfigurationChanged&lt;/code&gt; callback to adapt to its floating container in real-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource Loading&lt;/strong&gt;: Resources loaded via the &lt;code&gt;Activity&lt;/code&gt; context will correctly resolve to the bubble's specialized configuration, whereas the &lt;code&gt;Application&lt;/code&gt; context may return values intended for the full-screen state&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Managing the Task Lifecycle
&lt;/h4&gt;

&lt;p&gt;Beyond the UI, the transition to a bubble affects how Android manages your app's task- and backstack. To ensure a seamless pop, you need to keep a few details in mind. When an app is bubbled, the system re-parents the existing task. If your app relies on complex launch modes like &lt;code&gt;singleInstance&lt;/code&gt; or &lt;code&gt;singleTask&lt;/code&gt;, ensure that your intent handling doesn't inadvertently clear the backstack when the user toggles between full-screen and bubble mode. Also, because the system utilizes Shell Transitions for an atomic handoff, it preserves the user's state perfectly without a destroy-and-recreate cycle. However, if your app is killed in the background while minimized as a floating pill, it will restart into the bubble container. Finally, the Bubble up mechanism utilizes the &lt;code&gt;ShortcutManager&lt;/code&gt;. If your app uses dynamic shortcuts, ensure the &lt;code&gt;LocusId&lt;/code&gt; remains consistent so the system can correctly associate the floating window with the specific user context or conversation it originated from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrap-up
&lt;/h3&gt;

&lt;p&gt;From a user's perspective, the Bubble feature is activated from a simple context menu item. But code-wise? Google expanded the &lt;code&gt;LauncherApps&lt;/code&gt; and &lt;code&gt;ShortcutManager&lt;/code&gt; services to bridge the gap between the launcher and the window manager. There is a new system-level intent handling logic that allows the launcher to query an app's compatibility with the &lt;code&gt;TaskView&lt;/code&gt; stack without the app ever needing to fire a notification. Essentially, the system now checks for &lt;code&gt;android:resizeableActivity="true"&lt;/code&gt; and &lt;code&gt;android:supportsPictureInPicture="true"&lt;/code&gt; (or the new &lt;code&gt;android:allowUntrustedEmbedding&lt;/code&gt;) as a signal that the app can handle the floating lifecycle. This turns multitasking from something an app requests into something the user commands.&lt;/p&gt;

&lt;p&gt;What about third-party launchers? Historically, whenever Google introduced a deep system-level multitasking change, third-party developers were left in the lurch, and Android 17 is no different. While the new Bubble feature is baked into the platform, the specific Bubble menu item and the drag-and-drop gestures are currently exclusive to the system’s own launcher. Third-party launchers can’t simply opt-in to this context menu (or provide their own) because the underlying intent handling and &lt;code&gt;TaskView&lt;/code&gt; orchestration are tied to privileged system permissions that aren't exposed to the public SDK. At this stage, while third-party launchers can of course see your app, they lack the system-level handshake required to command the window manager to reparent your task into a bubble. Until Google decides to open up these specific &lt;code&gt;LauncherApps&lt;/code&gt; flags to non-system callers, the Bubble up power remains a walled-garden feature for the default launcher experience.&lt;/p&gt;

</description>
      <category>android</category>
      <category>androiddev</category>
      <category>ui</category>
    </item>
    <item>
      <title>Agentic interaction using AppFunctions</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Mon, 06 Apr 2026 06:49:26 +0000</pubDate>
      <link>https://dev.to/tkuenneth/agentic-interaction-using-appfunctions-m8k</link>
      <guid>https://dev.to/tkuenneth/agentic-interaction-using-appfunctions-m8k</guid>
      <description>&lt;p&gt;Given the rise of agentic interaction on Android, we need a fast, reliable API to make app capabilities discoverable and executable by agents. Through the years, Google has introduced several native frameworks to bridge the gap between the operating system, its system-level assistants, and apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App Actions&lt;/strong&gt; is the long-standing predecessor to &lt;strong&gt;AppFunctions&lt;/strong&gt;. It uses &lt;em&gt;shortcuts.xml&lt;/em&gt; and built-in intents to map specific user requests directly to app features. &lt;strong&gt;Android Slices&lt;/strong&gt; were an attempt to surface interactive snippets of an app’s UI directly within the assistant or search interface; they have been effectively deprecated since 2021. Then there's the &lt;strong&gt;Direct Actions API&lt;/strong&gt;, a framework introduced to allow voice assistants to query a foreground app for its specific capabilities in real-time. Gone too. Finally, the &lt;strong&gt;Assist API&lt;/strong&gt;: the fundamental system-level hook that allows a native agent to read the screen context, providing the situational awareness necessary for agents to act on behalf of the user. &lt;/p&gt;

&lt;p&gt;In retrospect, the failure of these predecessors likely wasn't due to a lack of vision, but rather a fundamental mismatch between static engineering and the needs of dynamic intelligence. App Actions relied on a rigid library of built-in intents. If an app feature didn't fit into one of Google’s binding categories, it effectively didn't exist to the assistant. Android Slices were killed by the UI maintenance trap. By forcing developers to build and maintain restricted, templated versions of their interface that often felt out of place, Google asked for too much effort for too little user engagement. The Direct Actions API failed because of its requirement that an app is actively running on the screen, which prevents the assistant from performing tasks autonomously. And while the Assist API provided the eyes for the system, it lacked the intelligence. It could scrape a messy tree of text and nodes from the screen, but it couldn't reliably parse that data into meaningful actions without massive compute power and significant privacy trade-offs. Ultimately, these frameworks offered narrow shortcuts when the ecosystem instead required a universal language.&lt;/p&gt;

&lt;h3&gt;
  
  
  AppFunctions
&lt;/h3&gt;

&lt;p&gt;Unlike its predecessors, which tried to force apps into predefined boxes or complex UI mirrors, the AppFunctions model treats the app as a collection of capabilities to be indexed, rather than a destination to be visited. By shifting the focus from how the app looks to what the app can do, Google is moving toward a model where the agent doesn't just deep-link you into a screen, but picks up the tools to finish the job for you.&lt;/p&gt;

&lt;p&gt;AppFunctions have been in the works since late 2024. Although the official &lt;a href="https://developer.android.com/reference/android/app/appfunctions/package-summary?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;android.app.appfunctions&lt;/a&gt; package didn't land in the core framework until API level 36, the missing link for developers was the &lt;a href="https://developer.android.com/jetpack/androidx/releases/appfunctions?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;appfunctions&lt;/a&gt; Jetpack library, which began its alpha rollout in May 2025. This library allowed early adopters to start wiring their apps for tool use before the corresponding platform APIs were finalized. At that stage, it was a framework waiting for a brain; Jetpack supplied the plumbing, but assistants such as Gemini were not consistently able to invoke those tools on every device or build. Android 16 adds the platform hooks for discovery and execution on supported hardware. As of today, Google still frames the overall agent push as &lt;em&gt;early / beta&lt;/em&gt; and describes two parallel tracks: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AppFunctions as structured, self-describing entry points (what this article is about: discrete capabilities agents can call)&lt;/li&gt;
&lt;li&gt;UI automation for longer flows when there is no tailored integration: previewed on devices such as the Galaxy S26 series and select Pixel 10 models, in limited verticals and regions, with multi-step delegation already part of that story&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;a href="https://android-developers.googleblog.com/2026/02/the-intelligent-os-making-ai-agents.html?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Google’s February 2026 post on the intelligent OS&lt;/a&gt;, the &lt;em&gt;Looking ahead&lt;/em&gt; section states that Android 17 is meant to &lt;em&gt;broaden&lt;/em&gt; these same capabilities; that includes structured AppFunctions and the agentic UI automation previews already tied to hardware such as the Galaxy S26 series and select Pixel 10 models; the stated aim is to reach more users, more developers, and more device manufacturers.&lt;/p&gt;

&lt;p&gt;Let's turn to the stack you can use today: Gradle, Kotlin, and the device-facing &lt;code&gt;adb&lt;/code&gt; checks that validate a real APK against the current Jetpack and platform drops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing an AppFunction
&lt;/h3&gt;

&lt;p&gt;To start implementing AppFunctions, your development environment might require a few specific upgrades. First, ensure you are running a recent version of Android Studio to access the latest Gemini-integrated testing tools. While the Jetpack library itself can target a lower &lt;code&gt;minSdk&lt;/code&gt; where compatibility allows, you’ll want &lt;code&gt;compileSdk 36&lt;/code&gt; and typically &lt;code&gt;targetSdk 36&lt;/code&gt; so the Android 16 framework can index and run your AppFunctions on device. Next, declare the Jetpack coordinates in your version catalog, then wire plugins, SDK level, KSP, dependencies, and merge ordering in the app module.&lt;/p&gt;

&lt;h4&gt;
  
  
  Version catalog (&lt;code&gt;gradle/libs.versions.toml&lt;/code&gt;)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[versions]&lt;/span&gt;
&lt;span class="py"&gt;appFunctions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.0-alpha08"&lt;/span&gt;

&lt;span class="nn"&gt;[libraries]&lt;/span&gt;
&lt;span class="py"&gt;androidx-appfunctions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.appfunctions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appfunctions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appFunctions"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;androidx-appfunctions-service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.appfunctions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appfunctions-service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appFunctions"&lt;/span&gt; 
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;androidx-appfunctions-compiler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="py"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.appfunctions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appfunctions-compiler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="py"&gt;version.ref&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"appFunctions"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  App module (&lt;code&gt;app/build.gradle.kts&lt;/code&gt;)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;android&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="c1"&gt;// Apply KSP to process the @AppFunction annotations&lt;/span&gt;
 &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;devtools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ksp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;android&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="c1"&gt;// compileSdk 36 aligns with Android 16, where platform AppFunctions APIs land&lt;/span&gt;
 &lt;span class="n"&gt;compileSdk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;    
 &lt;span class="c1"&gt;// ... rest of your config&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;ksp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nf"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"appfunctions:aggregateAppFunctions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appfunctions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appfunctions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="nf"&gt;ksp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;appfunctions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compiler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Run each merge*Assets after its matching ksp*Kotlin so AppFunctions metadata is generated first&lt;/span&gt;
&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configureEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"merge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Assets"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@configureEach&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ArtProfile"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@configureEach&lt;/span&gt;
  &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;variant&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removePrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"merge"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;removeSuffix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Assets"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;kspTask&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ksp${variant}Kotlin"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kspTask&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;dependsOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kspTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While the Jetpack library automates the plumbing (from generating schemas to registering them) the system fundamentally relies on &lt;strong&gt;AppSearch&lt;/strong&gt; for on-device indexing. The beauty of the library is that it handles the AppSearch integration entirely behind the scenes; you don't need to manage sessions or write storage boilerplate yourself for your AppFunctions to become discoverable. With the environment ready, the next step is to spell out what distinguishes an AppFunction in source. &lt;/p&gt;

&lt;p&gt;At its core, an AppFunction is a standard Kotlin function; but it carries a few specific decorations that turn it from a private app method into a public system tool: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;@AppFunction&lt;/code&gt; annotation signals to the compiler that a method should be exported as such a system-level tool. Use &lt;code&gt;@AppFunction(isDescribedByKDoc = true)&lt;/code&gt; when you write a real KDoc block on that method; the compiler folds that documentation into the metadata agents and indexers consume, so parameter semantics (for example that &lt;code&gt;app1&lt;/code&gt; and &lt;code&gt;app2&lt;/code&gt; are package names) are not left implicit. &lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AppFunctionContext&lt;/code&gt; provides the function with essential situational awareness, such as information about the calling party or access to the app's own resources.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AppFunctionSerializable&lt;/code&gt; ensures your custom data classes are properly handled while they travel across process boundaries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's see this in action in a real utility. In my app &lt;a href="https://codeberg.org/tkuenneth/benice" rel="noopener noreferrer"&gt;Be nice&lt;/a&gt;, a core feature is the ability to create app pairs (launching two apps in split-screen simultaneously). By exposing this as an AppFunction, we turn a sequence of UI interactions (opening a dialog, choosing apps, customizing parameters) into a single voice command. On eligible devices and assistant builds (as mentioned, Google’s rollout is still limited) you can ask Gemini to &lt;em&gt;create an app pair for contacts and clock&lt;/em&gt;. The agent will call our AppFunction, passing the two apps’ package names as strings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;de.thomaskuenneth.benice.appfunctions&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.appfunctions.AppFunctionContext&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;androidx.appfunctions.service.AppFunction&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;de.thomaskuenneth.benice.R&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BeNiceFunctions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="cm"&gt;/**
   * Launches two installed apps together in split screen.
   *
   * @param context Execution context supplied by the AppFunctions runtime.
   * @param app1 Name of the first app in the pair.
   * @param app2 Name of the second app in the pair.
   * @return A localized message describing success or failure.
   */&lt;/span&gt;
  &lt;span class="nd"&gt;@AppFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isDescribedByKDoc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;createAppPair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AppFunctionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;app1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;app2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;success&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;performPairing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pair_created_success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;app1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app2&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pair_created_failure&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;performPairing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Be Nice logic omitted for brevity&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used &lt;code&gt;suspend fun&lt;/code&gt;, like Google’s own AppFunctions examples do, so we can easily call other suspending APIs from the body whenever the implementation does real async work instead of returning immediately. &lt;/p&gt;

&lt;p&gt;Writing a function with &lt;code&gt;@AppFunction&lt;/code&gt; creates the capability. However, because AppFunctions are designed to be executed headlessly by the system (even if the app isn't in the foreground), the Android framework needs a static entry point to find and instantiate the code. Previous versions of the Jetpack library required quite a bit of additional boilerplate. Thankfully, most of that is now handled automatically through Manifest Merging and KSP: when you include the &lt;code&gt;appfunctions-service&lt;/code&gt; dependency, a pre-built &lt;code&gt;PlatformAppFunctionService&lt;/code&gt; is merged into your app's manifest, acting as the universal entry point for the system. The &lt;code&gt;ksp { }&lt;/code&gt; block and &lt;code&gt;tasks.configureEach&lt;/code&gt; section in the Gradle listing earlier connect your code to that service: &lt;code&gt;appfunctions:aggregateAppFunctions&lt;/code&gt; tells the compiler to emit the aggregate inventory and related assets AppSearch reads, and the merge-after-KSP ordering ensures that output is packaged into the APK.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing AppFunctions
&lt;/h3&gt;

&lt;p&gt;Next, let's check that your AppFunctions succeed on real setups.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell cmd app_function list-app-functions | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; de.thomaskuenneth.benice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command should print one or more lines that mention your package and expose each AppFunction’s stable id (often a &lt;code&gt;ClassName#methodName&lt;/code&gt; form). This proves the OS indexer has picked up the app after install. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft20pwrmado82ir69hm9z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft20pwrmado82ir69hm9z.png" alt="List of appfunctions for the Be nice package" width="800" height="197"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On some Android 16 emulator images that command may return &lt;code&gt;No shell command implementation&lt;/code&gt;. In my case, updating the AVD to a system image at API level 36.1 brought the &lt;code&gt;app_function&lt;/code&gt; shell path to life; Android Studio shows that revision when you choose the platform image, as in the screenshot below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkzavtvyef46mifxh461u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkzavtvyef46mifxh461u.png" alt="Emulator system image with API level 36.1 (Android Studio)" width="800" height="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Executing an AppFunction on the command line can look frightening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;adb shell cmd app_function list-app-functions | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; de.thomaskuenneth.benice | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[A-Za-z0-9_.]+#[A-Za-z0-9_]+'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adb shell &lt;span class="s2"&gt;"cmd app_function execute-app-function --package de.thomaskuenneth.benice --function &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$FID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; --parameters '{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;app1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;com.foo&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;],&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;app2&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:[&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;com.bar&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]}'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Felimxg6uqi7r03gcoojv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Felimxg6uqi7r03gcoojv.png" alt="Executing an AppFunction" width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This command prints a JSON payload: on success it is the AppFunction’s return value (here, the string that &lt;code&gt;createAppPair&lt;/code&gt; builds); on failure you may see &lt;code&gt;App function not found&lt;/code&gt; (wrong id) or a JSON parse error if &lt;code&gt;--parameters&lt;/code&gt; does not use the same AppSearch-style encoding as the example; note how each string argument is passed as a one-element JSON array.&lt;/p&gt;

&lt;p&gt;If listing or execution still fails, confirm the aggregate assets are actually packaged (for example &lt;code&gt;unzip -l app/build/outputs/apk/debug/app-debug.apk | grep app_functions&lt;/code&gt; should list &lt;code&gt;app_functions.xml&lt;/code&gt; and &lt;code&gt;app_functions_v2.xml&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;For automated tests, treat the layers separately: run the &lt;code&gt;adb&lt;/code&gt; checks above on a device to verify that your metadata is packaged and the indexer has picked up your app. Jetpack’s &lt;a href="https://developer.android.com/reference/kotlin/androidx/appfunctions/testing/AppFunctionTestRule?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;&lt;code&gt;AppFunctionTestRule&lt;/code&gt;&lt;/a&gt; is built for &lt;em&gt;local&lt;/em&gt; JVM runs (the docs pair it with Robolectric-style environments) so you can exercise &lt;code&gt;AppFunctionManager&lt;/code&gt; and your &lt;code&gt;@AppFunction&lt;/code&gt; logic without a cable; Google explicitly says to prefer &lt;em&gt;real&lt;/em&gt; system-level checks when you can. Add instrumented or integration coverage on an API 36+ image when you care about the full stack (AppSearch sync, shell availability, release vs debug). None of that replaces the device-facing section here; it complements it, and deserves its own write-up once you outgrow copy-paste snippets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrap-up
&lt;/h3&gt;

&lt;p&gt;To close this article, let's see the invocation of the &lt;em&gt;Be nice&lt;/em&gt; AppFunction end to end:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/pW1Wg-ssQQM"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;AppFunctions sit at the intersection of Jetpack, KSP, manifest merging, and on-device indexing (messy in preview, powerful when the wiring is right). When you integrate them, you keep coming back to three things: platform context, a small Gradle and Kotlin surface that connects the aggregate compiler flag to &lt;code&gt;PlatformAppFunctionService&lt;/code&gt;, and the device or APK checks that show whether packaging and indexing still line up.&lt;/p&gt;

&lt;p&gt;On the &lt;a href="https://developer.android.com/ai/appfunctions?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;official AppFunctions overview&lt;/a&gt;, Google only documents &lt;code&gt;adb shell cmd app_function list-app-functions&lt;/code&gt; as a shell check; there is no second, documented &lt;code&gt;adb&lt;/code&gt; path for the full schema text (including KDoc folded in via &lt;code&gt;isDescribedByKDoc&lt;/code&gt;). For that, read &lt;code&gt;assets/app_functions.xml&lt;/code&gt; / &lt;code&gt;app_functions_v2.xml&lt;/code&gt; from the APK, or query metadata through &lt;code&gt;AppFunctionManager&lt;/code&gt;-style APIs—the same place agents are expected to pull richer descriptions. Anything further that &lt;code&gt;adb shell cmd app_function help&lt;/code&gt; shows on a given device is platform-specific and is not spelled out on that overview page.&lt;/p&gt;

&lt;p&gt;One caveat worth carrying forward: in my project, making each &lt;code&gt;merge*Assets&lt;/code&gt; task depend on the matching &lt;code&gt;ksp*Kotlin&lt;/code&gt; task was &lt;em&gt;necessary&lt;/em&gt; so KSP-generated AppFunctions assets were present before packaging. That ordering is not spelled out in every official sample, and it may stop being required as the Android Gradle Plugin, KSP, or the AppFunctions toolchain tightens its own task graph. Treat it as something to validate on your stack: if &lt;code&gt;app_functions.xml&lt;/code&gt; / &lt;code&gt;app_functions_v2.xml&lt;/code&gt; show up in the APK without the extra &lt;code&gt;tasks.configureEach&lt;/code&gt; block, you can drop it; if they are missing at runtime, the dependency ordering is still a reliable fix.&lt;/p&gt;

&lt;p&gt;If you ship with minify enabled (&lt;code&gt;isMinifyEnabled&lt;/code&gt; / R8), the AppFunctions AndroidX artifacts ship consumer ProGuard rules that keep much of the generated and reflection-heavy surface for you. You should still smoke-test a release build on a device: if execution or discovery fails only after shrinking, inspect R8 output and add targeted &lt;code&gt;-keep&lt;/code&gt; rules for your own &lt;code&gt;@AppFunction&lt;/code&gt; classes or related types; start from what the library already merges rather than copying random snippets from older posts.&lt;/p&gt;

&lt;p&gt;In this article, I showed you how to implement an AppFunction. As a &lt;em&gt;publisher&lt;/em&gt; you expose AppFunctions in your APK and rely on indexing plus your own validation of arguments. &lt;em&gt;Callers&lt;/em&gt; that discover or execute &lt;em&gt;other&lt;/em&gt; apps’ functions go through &lt;code&gt;AppFunctionManager&lt;/code&gt;-style APIs and sit behind platform rules; privileged assistants hold permissions such as &lt;code&gt;EXECUTE_APP_FUNCTIONS&lt;/code&gt; that ordinary store apps do not get by declaring a line in the manifest. Google still describes much of the end-to-end agent path as experimental and capacity-limited, so assume your parameters can be reached only by trusted system-side callers today, and still treat them like untrusted input.&lt;/p&gt;

&lt;h3&gt;
  
  
  References
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://blog.shreyaspatil.dev/the-future-of-android-apps-with-appfunctions" rel="noopener noreferrer"&gt;The Future of Android Apps with AppFunctions&lt;/a&gt; by fellow GDE &lt;a href="https://shreyaspatil.dev/" rel="noopener noreferrer"&gt;Shreyas Patil&lt;/a&gt; goes deep on dependency injection with &lt;code&gt;AppFunctionConfiguration&lt;/code&gt;, a note-taking sample, and &lt;code&gt;adb&lt;/code&gt;-driven execution. Further reading beyond the links already woven through the article (overview, Jetpack release notes, platform &lt;code&gt;android.app.appfunctions&lt;/code&gt;, intelligent-OS blog, Play listing, and &lt;code&gt;AppFunctionTestRule&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/reference/kotlin/androidx/appfunctions/service/AppFunction?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;AppFunction annotation&lt;/a&gt; (&lt;code&gt;isDescribedByKDoc&lt;/code&gt;, compiler behavior, supported types)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/reference/kotlin/androidx/appfunctions/AppFunctionManager?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;AppFunctionManager&lt;/a&gt; (Jetpack discovery and execution APIs)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/develop/ui/views/search/appsearch?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;AppSearch&lt;/a&gt; (on-device indexing stack the runtime builds on)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/build/manage-manifests?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco" rel="noopener noreferrer"&gt;Merge multiple manifest files&lt;/a&gt; (how library manifests contribute &lt;code&gt;PlatformAppFunctionService&lt;/code&gt; and related entries)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.android.com/reference/android/Manifest.permission?utm_campaign=deveco_gdemembers&amp;amp;utm_source=deveco#EXECUTE_APP_FUNCTIONS" rel="noopener noreferrer"&gt;&lt;code&gt;EXECUTE_APP_FUNCTIONS&lt;/code&gt;&lt;/a&gt; (permission called out in the trust discussion)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>android</category>
      <category>programming</category>
    </item>
    <item>
      <title>From Vibe-Coding to Reality: Building MarvinSync</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Fri, 03 Apr 2026 13:17:03 +0000</pubDate>
      <link>https://dev.to/tkuenneth/from-vibe-coding-to-reality-building-marvinsync-171h</link>
      <guid>https://dev.to/tkuenneth/from-vibe-coding-to-reality-building-marvinsync-171h</guid>
      <description>&lt;p&gt;If you saw my posts back in February on &lt;a href="https://www.linkedin.com/posts/tkuenneth_allow-me-to-introduce-you-to-marvinsync-ugcPost-7428476671407656960-9bvo" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; and &lt;a href="https://mastodon.social/@tkuenneth/116092543272640657" rel="noopener noreferrer"&gt;Mastodon&lt;/a&gt;, you know I’ve been deep in a &lt;a href="https://cursor.com/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; session. I promised to pull back the curtain on how &lt;strong&gt;MarvinSync&lt;/strong&gt;—my new macOS utility for syncing local music to Android—came to life through the lens of AI-assisted development.&lt;/p&gt;

&lt;p&gt;Before we get to the binaries (which are coming soon, I promise!), I want to share the &lt;em&gt;vibe-coding&lt;/em&gt; post-mortem of how the first version actually took shape. If you want to follow along with the code as I describe it, the full project is already live on &lt;a href="https://codeberg.org/tkuenneth/marvinsync" rel="noopener noreferrer"&gt;Codeberg&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is MarvinSync? (And why Vibe-Code it?)
&lt;/h3&gt;

&lt;p&gt;MarvinSync is a utility designed for a specific niche: people who still believe in local media ownership. It bridges the gap between a curated macOS music library and an Android device. No streaming, no cloud—just your folders, your metadata, and a clean sync via ADB (Android Debug Bridge).&lt;/p&gt;

&lt;p&gt;But there’s a meta-story here. As an Android GDE, I spend my life deep in Kotlin, Compose, and Kotlin Multiplatform. Naturally, KMP would have been the logical choice for a cross-platform sync tool. However, I wanted to take this opportunity to go fully native on the Mac side using Swift and SwiftUI.&lt;/p&gt;

&lt;p&gt;I’ll be the first to admit: I am no Swift expert. This is where vibe-coding comes in. I used &lt;em&gt;Cursor&lt;/em&gt; to bridge the gap between my architectural knowledge and my lack of Swift syntax fluency. I provided the &lt;em&gt;vibe&lt;/em&gt;—the logic, the structure, and the constraints—and the AI handled the boilerplate and the nuances of a language I’m still learning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trusting the folder, not the tag
&lt;/h3&gt;

&lt;p&gt;One of the first big hurdles was handling music metadata. Initially, we tried the traditional route of scanning ID3 tags using standard APIs. The result was a mess of duplicates and wrong titles that didn't match how my files were actually organized.&lt;/p&gt;

&lt;p&gt;The solution was to stop being smart with metadata and start being literal with the file system. We shifted to a strict folder-based hierarchy where the directory structure itself defines the library.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Inside `MusicFolderStore`. Structure: base / Artist / Album / tracks.&lt;/span&gt;
&lt;span class="c1"&gt;/// `extractArtworkFromFirstTrack` walks audio files and uses `AVMetadataItem` (identifier-based artwork only).&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;scanForAlbumsFolderBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Album&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;artistURLs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;fileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentsOfDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;includingPropertiesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectoryKey&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skipsHiddenFiles&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;artistURLs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="nf"&gt;in&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectoryKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;NSLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MusicFolderStore: failed to list base folder: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localizedDescription&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;Album&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;artistURL&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;artistURLs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;artistName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;artistURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastPathComponent&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;albumURLs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;contents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;fileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentsOfDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;artistURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;includingPropertiesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectoryKey&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;skipsHiddenFiles&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;albumURLs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="nf"&gt;in&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectoryKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;albumURL&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;albumURLs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Album&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;albumURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastPathComponent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;artistName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nv"&gt;artwork&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractArtworkFromFirstTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;albumURL&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="nv"&gt;albumURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;albumURL&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sorted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By using a non-recursive scan of the directory structure instead of metadata deep-dives, the app finally reflected the &lt;em&gt;vibe&lt;/em&gt; of the actual library. It’s a reminder that sometimes the simplest architecture—the folder tree—is more robust than the most modern API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making Android Feel Native
&lt;/h3&gt;

&lt;p&gt;A huge part of the session was dedicated to the handshake between macOS and Android. Making ADB feel like a native Mac service requires some plumbing. We built a &lt;code&gt;ConnectedDeviceChecker&lt;/code&gt; that polls for devices every two seconds via ADB.&lt;/p&gt;

&lt;p&gt;The checker publishes a &lt;code&gt;@Published&lt;/code&gt; &lt;code&gt;deviceStatus&lt;/code&gt; (&lt;code&gt;ADBDeviceStatus&lt;/code&gt;) for the window’s status line; &lt;code&gt;isDeviceConnected&lt;/code&gt; is simply &lt;code&gt;deviceStatus == .oneDevice&lt;/code&gt;. The &lt;em&gt;Sync&lt;/em&gt; button is context-aware: it only turns on when &lt;strong&gt;exactly one&lt;/strong&gt; authorized device shows up in &lt;code&gt;adb devices&lt;/code&gt;—because with two targets (say, a phone and an emulator), &lt;em&gt;which device?&lt;/em&gt; is ambiguous until we teach the app to choose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;ADBDeviceStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Sendable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;adbNotSet&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;adbNotAccessible&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;noDevice&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;oneDevice&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;multipleDevices&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;statusMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;adbNotSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"ADB not configured"&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;adbNotAccessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"ADB not found or not accessible"&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;noDevice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"No device connected"&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;oneDevice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"One device connected"&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;multipleDevices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"More than one device connected"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;/// Polls `adb devices` every 2s; updates `deviceStatus` from bookmarked `store.adbURL`.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ConnectedDeviceChecker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;deviceStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ADBDeviceStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbNotSet&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isDeviceConnected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;deviceStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oneDevice&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;MusicFolderStore&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Timer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.example.MarvinSync.adbCheck"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;qos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utility&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;MusicFolderStore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;
        &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Timer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduledTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;withTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;repeats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;RunLoop&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;deinit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbNotSet&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;adbURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbURL&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbNotSet&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runADBDevices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;executableURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;adbURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;runADBDevices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;executableURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;ADBDeviceStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;process&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executableURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executableURL&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"devices"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Pipe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standardOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;
        &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standardError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;FileHandle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nullDevice&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntilExit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbNotAccessible&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fileHandleForReading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readDataToEndOfFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fileHandleForReading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbNotAccessible&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;components&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;separatedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;newlines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;device"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noDevice&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oneDevice&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;multipleDevices&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because MarvinSync is a well-behaved macOS citizen, it respects app sandboxing. However, a sandboxed app can't remember a file path after a reboot without help. To solve this, we implemented &lt;strong&gt;security-scoped bookmarks&lt;/strong&gt;. This ensures that once you grant permission to access your Music folder (and separately the &lt;code&gt;adb&lt;/code&gt; binary, often hiding under &lt;code&gt;~/Library&lt;/code&gt;), the app can resolve that permission on the next launch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same pattern as `MusicFolderStore`: two bookmarks, two keys.&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="kt"&gt;Defaults&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;musicFolderBookmarkKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"musicFolderBookmark"&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;adbBookmarkKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"adbBookmark"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;saveBookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSecurityScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;includingResourceValuesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;musicFolderBookmarkKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;saveADBBookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSecurityScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;includingResourceValuesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Defaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adbBookmarkKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;/// On startup: load `Data`, resolve, call `startAccessingSecurityScopedResource()`.&lt;/span&gt;
&lt;span class="c1"&gt;/// MarvinSync then checks `isStale` and, if needed, calls `saveBookmark` or `saveADBBookmark` again for that URL.&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;resolvedURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;bookmarkKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bookmarkKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isStale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;resolvingBookmarkData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSecurityScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;bookmarkDataIsStale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;isStale&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sync itself grew into a small pipeline: verify the Android base path, remove ignored albums on the device with carefully validated paths and shell-safe quoting (parentheses in folder names bite), then incremental &lt;code&gt;adb push&lt;/code&gt; with size checks so we only transfer files that are missing or changed. All of that is more interesting in the repo than in a short essay—but it’s the kind of &lt;em&gt;boring&lt;/em&gt; engineering AI accelerates when you already know what &lt;em&gt;must&lt;/em&gt; be true.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the code fences don’t show (but the session did)
&lt;/h3&gt;

&lt;p&gt;The snippets above are the architectural spine. The rest of the Cursor log is mostly product and UX: a sectioned A–Z grid; tap an album to include or exclude it for sync, with a green checkmark when it will be copied and &lt;em&gt;ignored paths&lt;/em&gt; persisted as relatives of the music folder. Sync opens a sheet with per-step spinners, checkmarks, and failures—errors stay on the row that broke, not in a separate alert. &lt;em&gt;Removal&lt;/em&gt; runs only for ignored folders that still exist on the device; &lt;em&gt;copy&lt;/em&gt; steps appear only when a size comparison shows something missing or stale, with human-readable labels (&lt;code&gt;Album (Artist)&lt;/code&gt;) instead of raw paths. &lt;em&gt;Cancel&lt;/em&gt; stops the pipeline; a &lt;em&gt;Checking …&lt;/em&gt; line covers the otherwise silent &lt;em&gt;do we need to copy?&lt;/em&gt; work. Fixing sheet height so the window stopped juddering as rows appeared took the same stubbornness as the &lt;em&gt;Settings&lt;/em&gt; form. None of that required a fourth code listing for this post—the Codeberg tree is the ground truth—but it deserved ink here so the story matches the full chat, not only the plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Friction of Vibe-Coding
&lt;/h3&gt;

&lt;p&gt;People often ask if AI makes coding effortless. The chat log tells a different story. Cursor is excellent at bulk work and at iterating when you give crisp specs—but &lt;em&gt;SwiftUI &lt;code&gt;Form&lt;/code&gt; on macOS&lt;/em&gt; still caught me in a loop of almost-right layouts: labels, value columns, and path text fields that looked centered or pushed to the wrong edge no matter how many HIG-aligned refactors we tried.&lt;/p&gt;

&lt;p&gt;At one point the chat reads like an argument, not a pull request. I wrote things I would not put in a colleague’s review—&lt;em&gt;frustration that went personal&lt;/em&gt; (&lt;em&gt;What is wrong with you?&lt;/em&gt;, and worse). The punchline is not that the model &lt;em&gt;deserved&lt;/em&gt; it; it doesn’t have feelings or pride. The punchline is that &lt;em&gt;I&lt;/em&gt; still needed to vent, then to stop accepting &lt;em&gt;close enough&lt;/em&gt;. The fix, when it came, was almost embarrassingly small: in our case, making the Android base path &lt;code&gt;TextField&lt;/code&gt; behave in the form the way Apple intends—&lt;em&gt;&lt;code&gt;.labelsHidden()&lt;/code&gt;&lt;/em&gt; so the field wasn’t fighting an implicit label column—and refusing another round of decorative layout hacks.&lt;/p&gt;

&lt;p&gt;That friction is the human bit of AI-assisted development: &lt;em&gt;not&lt;/em&gt; mistaking the chat for a person, but &lt;em&gt;not&lt;/em&gt; mistaking &lt;strong&gt;plausible&lt;/strong&gt; for &lt;strong&gt;shipped&lt;/strong&gt; either. The best sessions, this one included, end with you back in control—exact snippet in hand, build green, behavior finally matching the picture in your head.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s Next?
&lt;/h3&gt;

&lt;p&gt;The grid, sync sheet, and guardrails sketched above are in the repo; I even handcrafted the app icon myself (no AI involved there!).&lt;/p&gt;

&lt;p&gt;As I mentioned in my February posts, the binaries are coming. But I wanted to document this journey first. Building MarvinSync hasn't just been about creating a tool I needed; it’s been an experiment in how a veteran Android dev can use AI to build natively for the Mac.&lt;/p&gt;

&lt;p&gt;The source is open, the &lt;em&gt;vibe&lt;/em&gt; is set, and soon, it'll be time to sync.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>ai</category>
      <category>swiftui</category>
      <category>android</category>
    </item>
    <item>
      <title>Refuelling your Jetpack</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Sun, 08 Mar 2026 11:36:03 +0000</pubDate>
      <link>https://dev.to/tkuenneth/refuelling-your-jetpack-d8i</link>
      <guid>https://dev.to/tkuenneth/refuelling-your-jetpack-d8i</guid>
      <description>&lt;p&gt;If you are an Android developer, you know Jetpack. It changed how we build Android apps. But that was long ago. Today, the ecosystem is shifting again. We aren't just building for one platform anymore. We are building for the world. Let me show you how to take the Jetpack libraries you know and use them to fuel a new generation of applications that run everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Jetpack to a unified architecture
&lt;/h2&gt;

&lt;p&gt;First, let's ground ourselves. What exactly is Jetpack? It's more than just a bag of libraries. It is Google's opinionated answer to Android development. It unbundled features from the OS, so we could update our apps without waiting for Android system updates. It gave us backward compatibility. Most importantly, it gave us guidance. It stopped the wild west of Android development and brought us a standard way to build.&lt;/p&gt;

&lt;p&gt;Before Jetpack, we lived in an era of fragmentation. New features were tied to the OS. If you wanted the latest UI on an older phone, you were out of luck. Eventually, Google introduced the &lt;em&gt;Support Library&lt;/em&gt; to fix this. It sort of worked, but it was a mess. We had &lt;code&gt;v4&lt;/code&gt; support, &lt;code&gt;v7&lt;/code&gt; appcompat, &lt;code&gt;v13&lt;/code&gt;... it was dependency hell. We needed a reboot.&lt;/p&gt;

&lt;p&gt;That reboot came in two steps. In 2017, Google announced &lt;em&gt;Android Architecture Components&lt;/em&gt; at I/O—ViewModel, Room, Lifecycle, LiveData—and they went 1.0 stable that November. That was the &lt;em&gt;how to architect&lt;/em&gt; piece. Then, at I/O 2018, came &lt;em&gt;Jetpack&lt;/em&gt;: the umbrella name and the migration to &lt;code&gt;androidx.*&lt;/code&gt;. Eventually, everything moved to the new namespace. Libraries were strictly unbundled—own versions, own cycles, semantic versioning so we could reason about compatibility. So: Architecture Components in 2017, Jetpack and androidx in 2018.&lt;/p&gt;

&lt;p&gt;Today, we have solved one problem but created another: &lt;em&gt;The Jetpack Jungle.&lt;/em&gt; There are now over 130 artifacts in the suite. We have historical baggage. Deprecated libraries still sit next to modern ones—for example &lt;em&gt;lifecycle-extensions&lt;/em&gt;, which Google deprecated and replaced with separate lifecycle-runtime, lifecycle-viewmodel, and so on, but it still turns up in old tutorials. Or &lt;em&gt;security-crypto&lt;/em&gt;, deprecated with no clear official successor, but still in the docs. We have three different ways to do background work, two ways to do navigation, and endless UI helpers. The challenge is no longer &lt;em&gt;how do I do this?&lt;/em&gt; but &lt;em&gt;which of these 130 libraries should I actually use?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To survive the jungle, we need a map. A golden path, if you will. It's the curated, opinionated stack that Google actually recommends today. Stick to it and you avoid the jungle. UI: reactive, driven by state. Presentation: ViewModels that survive config changes. Navigation: a single graph defining your flow. Data: Room for database, DataStore for preferences. That stack is the gold standard for Android—but it no longer stops there. Everything on that list—Compose, ViewModels, Navigation, Room, DataStore—is available in Kotlin Multiplatform. You can keep the same architecture and the same APIs and compile them for iOS, Desktop, and the Web. One architecture, same code, everywhere.&lt;/p&gt;

&lt;p&gt;That stack has a few key pieces. The UI layer is &lt;em&gt;Compose Multiplatform&lt;/em&gt;. It is the exact same declarative, reactive paradigm you use on Android, just unbundled from the OS. You write composables, and they react to state. On Android, it's Jetpack Compose. On iOS and Desktop, it uses the Skia graphics engine to draw pixel-perfect UI while running natively on the hardware. This means you aren't learning three different UI frameworks. You aren't context-switching. You are taking your existing Android expertise and applying it to the entire world.&lt;/p&gt;

&lt;p&gt;For state and lifecycle: Android didn't invent the ViewModel. Cross-platform frameworks like Xamarin were using the MVVM pattern to share logic between iOS and Android long before Jetpack existed. The good news is that Android and KMP have finally adapted this proven standard. You put your &lt;code&gt;ViewModel&lt;/code&gt; in shared code. It survives configuration changes. It holds your state. It's the industry standard for a reason, and now it's the standard for our shared code, too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Navigation&lt;/em&gt; is another piece. It used to be where cross-platform architectures fell apart. But not here. With &lt;em&gt;Navigation Compose&lt;/em&gt;, your navigation graph travels with you. You declare your routes, your arguments, your back stack, and your deep links once in shared code. Type-safe. Whether it's an Android Activity, an iOS View Controller, or a Desktop Window, the platform code is just a thin container hosting your navigation. You aren't reimplementing routing logic three times. You define the flow once, and it drives the UI everywhere.&lt;/p&gt;

&lt;p&gt;For local database storage, we use &lt;em&gt;Room&lt;/em&gt;. If you haven't used it before, Room is a full Object-Relational Mapper (ORM) wrapping SQLite. It lets you define your data as simple Kotlin objects and maps them automatically to database tables. Its superpower is compile-time verification. Unlike many ORMs that fail at runtime, Room checks your SQL queries against your schema as you build. In this architecture, Room runs natively on iOS, Android, and Desktop, giving you a single, type-safe data layer with the performance of raw SQLite.&lt;/p&gt;

&lt;p&gt;For configuration and preferences: every app needs to store something—whether it's a dark mode toggle, a session token, or feature flags. Usually, this means writing one implementation for iOS using &lt;code&gt;UserDefaults&lt;/code&gt; and another for Android using &lt;code&gt;SharedPreferences&lt;/code&gt;. With &lt;em&gt;DataStore&lt;/em&gt;, we unify this. It is a modern, multiplatform key-value store built entirely on Kotlin coroutines. It is asynchronous by default, preventing UI freezes on any platform. You write your preference logic once in shared code, and it handles the native storage details for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;So, how does this fit together? You have one shared core. This module contains almost everything: your Compose UI, your Navigation graph, your ViewModels, and your Database. Surrounding that, you have thin native shells. The Android app, the iOS app, and the Desktop app. They do little more than initialize the process and host the shared UI. They might add a splash screen or handle push notifications, but the actual application—the screens and the logic—is shared. This means no duplicate business logic and no synchronization issues between platforms.&lt;/p&gt;

&lt;p&gt;The shared module is the structural center of your application. Inside &lt;code&gt;commonMain&lt;/code&gt;, you place the core components we just went through. But when you need to interact with specific OS APIs—like file paths, Bluetooth, or system intents—you use the &lt;code&gt;expect&lt;/code&gt; / &lt;code&gt;actual&lt;/code&gt; pattern. You declare the interface in the shared code, and the platform module provides the implementation. This keeps your business logic pure and testable, ensuring platform details don't leak into your core architecture.&lt;/p&gt;

&lt;p&gt;The platform modules are intentionally thin. On Android, you have a single Activity calling &lt;code&gt;setContent&lt;/code&gt;. On iOS, you have a standard View Controller that hosts the shared Compose UI. On Desktop, it's just a &lt;code&gt;main()&lt;/code&gt; function. Crucially, this is where you initialize your Dependency Injection—like Koin. You wire it up once at startup, and then the rest of the application logic is fully shared.&lt;/p&gt;

&lt;p&gt;Dependency injection often raises a question: Isn't Hilt the recommended Jetpack DI? Yes, for Android it's fantastic. But Hilt relies heavily on Dagger and Java annotation processing, which simply does not work on iOS or Desktop. Critically, Google has not yet said anything about Hilt going multiplatform. There is no roadmap. So, to keep our shared code clean and compile-safe &lt;em&gt;today&lt;/em&gt;, we use a pure Kotlin solution like &lt;em&gt;Koin&lt;/em&gt;. You define your modules in shared code; each platform supplies the actuals (e.g. where's the data directory). One container, init once per platform. It effectively becomes the standard for this architecture by necessity.&lt;/p&gt;

&lt;p&gt;To make this concrete: in &lt;em&gt;CMP Unit Converter&lt;/em&gt;, the Koin module lives in &lt;code&gt;commonMain&lt;/code&gt;. The database and ViewModels are provided there; the platform supplies the DB path via &lt;code&gt;expect&lt;/code&gt; / &lt;code&gt;actual&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/.../di/AppModule.kt&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;appModule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;module&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;getRoomDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getDatabaseBuilder&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// getDatabaseBuilder() is expect/actual&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;AppViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;TemperatureViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;DistanceViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  In practice: CMP Unit Converter
&lt;/h2&gt;

&lt;p&gt;Enough theory—let's walk through the full picture. I built an app called &lt;a href="https://github.com/tkuenneth/CMP-Unit-Converter" rel="noopener noreferrer"&gt;&lt;em&gt;CMP Unit Converter&lt;/em&gt;&lt;/a&gt; to prove this stack works. It converts temperatures and distances, stores your history, and remembers your preferences. It runs on Android, iOS, and Desktop, and it's built entirely on the stack we just went through: Compose for UI, ViewModels for state, Room for history, and Koin for injection. Let's tear it apart and see how it works.&lt;/p&gt;

&lt;p&gt;When you open the project, you'll notice the structure right away. It follows the modern AGP 9 guidelines. Gradle 9.1 and AGP 9 are the baseline. In this repo the modules are: shared (the library), composeApp (the Android app), desktopApp, and iosApp is a separate Xcode project. Your Android app—here, &lt;code&gt;composeApp&lt;/code&gt;—is its own module: just application code, no Kotlin Multiplatform plugin. The shared module uses the new &lt;code&gt;com.android.kotlin.multiplatform.library&lt;/code&gt; plugin. In shared's &lt;em&gt;build.gradle.kts&lt;/em&gt; you configure the Android target with &lt;code&gt;androidLibrary { }&lt;/code&gt; inside the &lt;code&gt;kotlin { }&lt;/code&gt; block, not the old top-level &lt;code&gt;android { }&lt;/code&gt; block. The app module uses AGP's built-in Kotlin, so you don't apply the Kotlin Android plugin there. Android is just another target, like Desktop or iOS. This clean separation is key: the app is a thin shell, the library does the heavy lifting. It's a bit of a mental shift if you had one &lt;code&gt;composeApp&lt;/code&gt; doing everything, but it pays off in clearer separation and faster builds.&lt;/p&gt;

&lt;p&gt;In code it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;androidKotlinMultiplatformLibrary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;kotlin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;androidLibrary&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// composeApp/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":shared"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared UI lives in &lt;code&gt;commonMain&lt;/code&gt;—for example, &lt;em&gt;ConverterScreen.kt&lt;/em&gt;. That's the app's main screen: standard Compose code. But notice the resources—we don't have XML strings for Android and Localizable.strings for iOS. We put everything in the &lt;code&gt;composeResources&lt;/code&gt; folder. We access them in Kotlin using the generated &lt;code&gt;Res&lt;/code&gt; object—&lt;code&gt;Res.string&lt;/code&gt; or &lt;code&gt;Res.drawable&lt;/code&gt;. At compile time, CMP bundles these into the correct native format for each platform. You write the UI once, and it looks native everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/.../ConverterScreen.kt&lt;/span&gt;
&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ConverterScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;navigationState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NavigationState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AbstractConverterViewModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scrollBehavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TopAppBarScrollBehavior&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Res API (e.g. AppIcons.kt)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;Thermostat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DrawableResource&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drawable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ic_thermostat&lt;/span&gt;
&lt;span class="c1"&gt;// In composables: stringResource(Res.string.app_name)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The brain of the app is the converter ViewModels—we have &lt;code&gt;TemperatureViewModel&lt;/code&gt; and &lt;code&gt;DistanceViewModel&lt;/code&gt;, both in shared code and registered with Koin. When the UI loads, it asks for the ViewModel via &lt;code&gt;koinViewModel()&lt;/code&gt;. It doesn't care if it's running on a Pixel or an iPhone. The ViewModels survive configuration changes on Android and manage state on iOS seamlessly. Same classes everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/.../di/AppModule.kt&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;appModule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;module&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;single&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;getRoomDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getDatabaseBuilder&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;TemperatureViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;viewModelOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;DistanceViewModel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In a composable (e.g. App.kt)&lt;/span&gt;
&lt;span class="n"&gt;viewModel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;koinViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TemperatureViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For data, we use Room and DataStore. The app saves your conversion history in a SQLite database using Room. It remembers your last selected unit using DataStore. The only platform-specific code here is a tiny &lt;code&gt;expect/actual&lt;/code&gt; function to tell the app &lt;em&gt;where&lt;/em&gt; to save the file on disk (e.g. Application Support on iOS, &lt;code&gt;getDatabasePath&lt;/code&gt; on Android). Everything else—the DAOs, the queries, the preference keys—is shared. We aren't fighting with CoreData or SharedPreferences.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/Platform.kt&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getDatabaseBuilder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;RoomDatabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// androidMain: path from context.getDatabasePath("...")&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getDatabaseBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;databaseBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AppDatabase&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDatabasePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CMPUnitConverter.db"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;absolutePath&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// iosMain: path from getDirectoryForType(DirectoryType.Database) → Application Support&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The platform apps are just entry points. On Android, the Application class calls &lt;code&gt;initKoin&lt;/code&gt; at startup (e.g. in &lt;code&gt;CMPUnitConverterApp&lt;/code&gt;); &lt;code&gt;MainActivity&lt;/code&gt; then calls &lt;code&gt;setContent&lt;/code&gt;. On iOS, &lt;code&gt;ComposeView&lt;/code&gt; inside a ViewController. On Desktop, &lt;code&gt;main()&lt;/code&gt; launches the window. There's one little gotcha on iOS. When we export our Kotlin &lt;code&gt;initKoin&lt;/code&gt; function to Swift, Kotlin/Native renames it to &lt;code&gt;doInitKoin&lt;/code&gt; because it returns &lt;code&gt;Unit&lt;/code&gt;. It's a small quirk, but knowing it saves you a "Method Not Found" error. Call &lt;code&gt;doInitKoin&lt;/code&gt; in your Swift AppLifecycle and you're good to go.&lt;/p&gt;

&lt;p&gt;In Kotlin we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain/.../di/KoinApp.kt&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;initKoin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KoinAppDeclaration&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startKoin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appModule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On iOS, Swift sees the same function with the "do" prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iOS (Swift). Kotlin exports Unit-returning functions with "do" prefix:&lt;/span&gt;
&lt;span class="kt"&gt;KoinAppKt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doInitKoin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaways and resources
&lt;/h2&gt;

&lt;p&gt;One of the most common pitfalls is version mismatch. Your shared module pulls in Compose Multiplatform and a specific Compose runtime. Your Android app uses &lt;code&gt;activity-compose&lt;/code&gt; to call &lt;code&gt;setContent&lt;/code&gt;. If &lt;code&gt;activity-compose&lt;/code&gt; was built for a different Compose version, you get crashes like &lt;code&gt;NoSuchMethodError&lt;/code&gt; at runtime. So align &lt;code&gt;activity-compose&lt;/code&gt; with the Jetpack Compose version that Compose Multiplatform uses—there’s a compatibility table in the docs. Also keep the Compose Compiler plugin version in sync with your Kotlin Multiplatform plugin. When in doubt, check the Compose Multiplatform and AGP compatibility pages before you upgrade. That habit pays off.&lt;/p&gt;

&lt;p&gt;A few other things help in practice. If you’re starting fresh, use the AGP 9 structure from day one. Migrating an old single-module app to the new structure works, but it’s more work. And &lt;code&gt;expect&lt;/code&gt; / &lt;code&gt;actual&lt;/code&gt; is your friend: put every platform quirk behind an &lt;code&gt;expect&lt;/code&gt; declaration, implement it per platform, and document the odd ones—like &lt;code&gt;doInitKoin&lt;/code&gt; on iOS—so your future self or your team don’t “fix” what isn’t broken. That keeps the shared code clean and the architecture solid.&lt;/p&gt;

&lt;p&gt;To recap: the story—where Jetpack came from, the jungle of 130+ artifacts, the Golden Path map, and that it all runs beyond Android. The lineup—Compose Multiplatform, ViewModel, Navigation, Room, DataStore—all available in KMP. How it fits together: one shared module, thin platform shells, entry points, dependency injection. And the AGP 9 structure in a real project, &lt;em&gt;CMP Unit Converter&lt;/em&gt;—one codebase, multiple binaries. &lt;/p&gt;

&lt;p&gt;A few topics were left out of this overview. For example, testing: your shared module is highly testable. ViewModels, repositories, use cases—you can run them in &lt;code&gt;commonTest&lt;/code&gt; or on the JVM. Room works with an in-memory database; same DAOs and entities. For &lt;code&gt;expect&lt;/code&gt;/&lt;code&gt;actual&lt;/code&gt; you supply test implementations. The Kotlin and Android docs and the &lt;em&gt;CMP Unit Converter&lt;/em&gt; repo show how. Version alignment was mentioned above: when you upgrade, align &lt;code&gt;activity-compose&lt;/code&gt; with the Compose version your shared module uses, and check the compatibility pages. And what's not in KMP yet: WorkManager, CameraX, a few others. For those you stay in the Android app module or look for community options. The stack we focused on is the part that's officially supported and where you'll spend most of your time.&lt;/p&gt;

&lt;p&gt;Where is this going? Google and JetBrains are actively moving Jetpack beyond Android. We're not there yet for every library, but the trend is clear: the same APIs you use on Android are being made available in KMP. The Golden Path stack we've talked about is at the front of that wave. So investing in this architecture now—Compose, ViewModel, Navigation, Room, DataStore—is not a bet on a niche. It's aligning with where the ecosystem is headed. One architecture, many platforms.&lt;/p&gt;

&lt;p&gt;For your own projects, here's a practical checklist. UI: Compose Multiplatform. State: ViewModel and Lifecycle Runtime in KMP. Navigation: Navigation Compose in KMP. Persistence: Room and DataStore in KMP. Structure: one shared module, thin Android, iOS, and Desktop apps—and if you're on Android, use the AGP 9 layout with the new KMP library plugin. Dependency injection: Koin or Hilt, init once at app startup on each platform. Stick to this stack and you avoid the jungle. You get one architecture that runs everywhere.&lt;/p&gt;

&lt;p&gt;For more detail, the Kotlin and Compose Multiplatform docs on kotlinlang.org and jetbrains.com are the place to start. For the Android side—the new KMP library plugin, AGP 9, and the migration steps—developer.android.com has the official guides. The Compose Multiplatform compatibility page tells you which Jetpack Compose version lines up with which CMP version. When in doubt, check it before you upgrade—it saves head-scratching. And there are samples: &lt;a href="https://github.com/tkuenneth/CMP-Unit-Converter" rel="noopener noreferrer"&gt;CMP Unit Converter&lt;/a&gt; is one; the Kotlin and Android teams publish more. You're not on your own—the docs and the samples are there to back you up.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>android</category>
      <category>jetpackcompose</category>
      <category>kmp</category>
    </item>
    <item>
      <title>Setting up local Codeberg runners</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Mon, 02 Mar 2026 18:29:02 +0000</pubDate>
      <link>https://dev.to/tkuenneth/setting-up-local-codeberg-runners-4eif</link>
      <guid>https://dev.to/tkuenneth/setting-up-local-codeberg-runners-4eif</guid>
      <description>&lt;p&gt;In my previous article, &lt;a href="https://dev.to/tkuenneth/first-steps-towards-codeberg-48hl"&gt;First steps towards Codeberg&lt;/a&gt;, we looked at how to get set up and comfortable on the platform. Now that your code has a new home, it’s time to level up your workflow with automation.&lt;/p&gt;

&lt;p&gt;While Codeberg provides shared runners for CI/CD, there are plenty of reasons to run your own, among others,&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;performance&lt;/li&gt;
&lt;li&gt;specific hardware requirements&lt;/li&gt;
&lt;li&gt;avoiding queue times&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this guide , I'll show you how to use your local machine as a CI/CD runner for Codeberg. This works behind firewalls and home routers without needing to expose your IP address. Sounds cool, right? &lt;/p&gt;

&lt;p&gt;Depending on your needs, you can choose between two setup methods: the containerized approach using &lt;em&gt;Docker&lt;/em&gt; / &lt;em&gt;OrbStack&lt;/em&gt;, or running the &lt;code&gt;act_runner&lt;/code&gt; binary directly. Since I am on a Mac with Apple Silicon, the choice actually matters quite a bit. Docker on M-series chips runs Linux ARM64 images, and unfortunately, the standard Android build tools don't officially support that environment yet. If you are here for Android builds, you might want to look at the binary method; otherwise, Docker is a safe bet.&lt;/p&gt;

&lt;p&gt;First, get your registration token.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On Codeberg, navigate to Settings &amp;gt; Actions &amp;gt; Runners&lt;/li&gt;
&lt;li&gt;Click Create new runner&lt;/li&gt;
&lt;li&gt;Copy the Registration Token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uwkd019yeb7dnkepo64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uwkd019yeb7dnkepo64.png" alt="Getting the registration token for a Codeberg runner" width="800" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, prepare the workspace. &lt;/p&gt;

&lt;h3&gt;
  
  
  Docker / OrbStack
&lt;/h3&gt;

&lt;p&gt;Open your terminal and create a folder to house the runner's identity and configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;codeberg-runner
&lt;span class="nb"&gt;cd &lt;/span&gt;codeberg-runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, create the configuration by putting a file named &lt;em&gt;docker-compose.yml&lt;/em&gt; in that folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;code docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's how that file should look like. Replace &lt;code&gt;&amp;lt;TOKEN&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;name-of-your-runner&amp;gt;&lt;/code&gt; with your registration token and a name for your runner. &lt;code&gt;&amp;lt;name-of-your-runner&amp;gt;&lt;/code&gt; will appear in the Codeberg dashboard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea/act_runner:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codeberg_runner&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_INSTANCE_URL=https://codeberg.org&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_REGISTRATION_TOKEN=&amp;lt;TOKEN&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GITEA_RUNNER_NAME=&amp;lt;name-of-your-runner&amp;gt;&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ACT_RUNNER_DEFAULT_IMAGE=gitea/runner-images:ubuntu-22.04&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./data:/data&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;daemon&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we can launch and register our runner. Run the following command &lt;strong&gt;from inside&lt;/strong&gt; the &lt;em&gt;codeberg-runner&lt;/em&gt; folder. This will pull the image, register the runner automatically, and start the background service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To verify the connection you can check the logs to ensure the registration was successful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs codeberg_runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;Starting runner daemon&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx4p4s8vfwcp7htldjjz6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx4p4s8vfwcp7htldjjz6.png" alt="Screenshot showing the output of docker logs" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You'll also see your runner in the Codeberg settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7uuuruee67kn5pqh66y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi7uuuruee67kn5pqh66y.png" alt="Screenshot showing your runner in the Codeberg settings" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the initial setup is complete, use these commands to control your runner:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go Offline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safely pauses the runner; it will show as "Offline" on Codeberg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Go Online&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reconnects to Codeberg; the runner will show as "Idle" (ready)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;View Live Logs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose logs -f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Streams the activity; you'll see jobs being picked up in real-time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Check Identity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls -la ./data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifies the &lt;code&gt;.runner&lt;/code&gt; file exists (your runner's "ID card")&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it. Your Docker-based runner is now active and polling for jobs. To use it, you simply need to update your workflow file (usually in &lt;em&gt;.gitea/workflows/&lt;/em&gt;) to target this specific runner.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;blackmac-runner&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Hello from my local runner!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the next section, I'll show you an alternative method. &lt;code&gt;act_runner&lt;/code&gt; is the lightweight binary that powers the Docker image we just used. By configuring it with a host label, we can run jobs directly on your machine without a container layer. This allows the runner to access your local tools (for example, Xcode or the Android SDK) directly. As mentioned earlier, this is the preferred method if you need to break out of the container's isolation or are dealing with architecture-specific limitations on Apple Silicon.&lt;/p&gt;

&lt;h3&gt;
  
  
  act_runner
&lt;/h3&gt;

&lt;p&gt;First, let's install &lt;code&gt;act_runner&lt;/code&gt;. We will also install &lt;code&gt;node&lt;/code&gt;, which is required to run standard actions (like &lt;code&gt;actions/checkout&lt;/code&gt;) directly on the host machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;act_runner node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, generate the default configuration file so we can customize the runner settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.config/act_runner
act_runner generate-config &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.config/act_runner/config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open ~/.config/act_runner/config.yaml and locate the runner section. Add your custom label ending in :host to ensure jobs run directly on your machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt; &lt;span class="na"&gt;runner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
   &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blackmac-runner:host"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we can register the runner. Replace &lt;code&gt;YOUR_REGISTRATION_TOKEN&lt;/code&gt; with the one you obtained from the Codeberg settings earlier. This command links your local machine to the instance using the configuration and label we just defined.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;act_runner register &lt;span class="nt"&gt;--no-interactive&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance&lt;/span&gt; https://codeberg.org &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; YOUR_REGISTRATION_TOKEN &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; blackmac-runner &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--labels&lt;/span&gt; blackmac-runner:host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; ~/.config/act_runner/config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, start the runner daemon. It will connect to Codeberg and immediately begin listening for incoming jobs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;act_runner daemon &lt;span class="nt"&gt;--config&lt;/span&gt; ~/.config/act_runner/config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we are live! The daemon is now connected to Codeberg and listening for jobs. To use it, simply reference the label we just registered (&lt;code&gt;blackmac-runner&lt;/code&gt;) in your workflow file, exactly as shown earlier. The key difference here is that your jobs will now execute directly on your Mac's host system, bypassing the container layer and giving you full access to local tools like Xcode or the Android SDK.&lt;/p&gt;

</description>
      <category>ci</category>
      <category>cicd</category>
      <category>opensource</category>
    </item>
    <item>
      <title>F-Droid on ChromeOS: trying to get behind the road blocks</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Fri, 09 Jan 2026 12:43:52 +0000</pubDate>
      <link>https://dev.to/tkuenneth/f-droid-on-chromeos-trying-to-get-behind-the-road-blocks-48f</link>
      <guid>https://dev.to/tkuenneth/f-droid-on-chromeos-trying-to-get-behind-the-road-blocks-48f</guid>
      <description>&lt;p&gt;F-Droid has been a trusted source for high-quality open-source Android apps for many years. While ChromeOS devices come with Google Play (provided it is enabled in &lt;em&gt;Settings&lt;/em&gt;), also having F-Droid available offers a gateway to a vast ecosystem of privacy-respecting software.&lt;/p&gt;

&lt;p&gt;F-Droid is not available on Google Play. Instead, you usually download it directly from the official project homepage at f-droid.org.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feviu215r8eanl2kqegfm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feviu215r8eanl2kqegfm.png" alt="Official F-Droid homepage with the Download options" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the download is complete, your first instinct would likely be to open the APK file using the &lt;em&gt;Files&lt;/em&gt; app:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuhztqupcqwbc2wczevhv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuhztqupcqwbc2wczevhv.png" alt="Files app on ChromeOS" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately this would give you just a message saying &lt;em&gt;Turn on Chrome OS Developer mode to install apps from sources other than the Play Store&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7wf92zuiricbt4nlmxyh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7wf92zuiricbt4nlmxyh.png" alt="Message explaining that installation is blocked" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That sounds scary, doesn't it? What's more, it's simply not true. You do not need to enable the full-fledged Developer Mode, which in itself may open up more severe security issues. Instead, you can simply enable ADB debugging. You’ll find this toggle in &lt;em&gt;Settings&lt;/em&gt; under the &lt;em&gt;Develop Android apps&lt;/em&gt; section.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwo7lk2w55vf039bkzgyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwo7lk2w55vf039bkzgyv.png" alt="Section Develop Android apps in Settings" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once &lt;em&gt;ADB debugging&lt;/em&gt; is toggled and an APK has been downloaded, a simple &lt;code&gt;adb install&lt;/code&gt; triggers the installation. Let's use the internal Linux container (Crostini) to do so.&lt;/p&gt;

&lt;p&gt;First, open your Chrome OS &lt;em&gt;Terminal&lt;/em&gt; and install a few required tools&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install android-tools-adb curl -y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, connect the Linux container to the Android subsystem&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adb connect 100.115.92.2:5555
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running the &lt;code&gt;adb connect&lt;/code&gt; command, keep your eyes on the Chromebook screen. You may need to manually authorize the debugging link and confirm the installation.&lt;/p&gt;

&lt;p&gt;Finally, use &lt;code&gt;curl&lt;/code&gt; to grab the latest version of F-Droid and install it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -L https://f-droid.org/F-Droid.apk -o fdroid.apk &amp;amp;&amp;amp; adb install fdroid.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once we have opened F-Droid, we can browse the catalogue. However, trying to install an app, shows an error saying we need to enable Developer mode.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs45tqg5ep0ichf28bpku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs45tqg5ep0ichf28bpku.png" alt="Error while installing an app: Developer mode required" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Wait a minute. There's something in Settings, right?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Yes, &lt;em&gt;Install unknown apps&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw1nkdqcveen8us89m2l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw1nkdqcveen8us89m2l.png" alt="The Install unknown apps screen" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unfortunately, enabling this does not change anything. What's more, trying to grant the permission on the command line gives us a little bit of an explanation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adb shell pm grant org.fdroid.fdroid android.permission.REQUEST_INSTALL_PACKAGES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fecc9k77rauesye38b2fe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fecc9k77rauesye38b2fe.png" alt="A Terminal session, trying to grant android.permission.REQUEST_INSTALL_PACKAGES" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;So, what can we make out of this? Well, this article clarifies that you do not need to bring your ChromeOS device into Developer mode to install Android apps (I guess most of us knew that 😅); even though Google tries hard to persuade you otherwise. &lt;/p&gt;

&lt;p&gt;Next. Switching on developer mode is usually a bad idea. So, why does Google stick to it? Well, while I can certainly only speculate, tying the users to Google Play is in the interest of Google, whereas allowing alternative app stores definitely is not. We see the hesitation on plain Android - it took law suits and a lot of developer backlash to force Google into making the installation of Play Store alternatives less painful. And it will take Android 17. &lt;/p&gt;

&lt;p&gt;Regarding ChromeOS, having apps download and install other apps seems, at least for now, impossible to do for ordinary users.&lt;/p&gt;

&lt;p&gt;One final thought. Since F-Droid has been the hook for this article, I feel the need to praise them to as much extent as I possibly can. They continue to be a landmark institution for Android open source software since many years. It's certainly not their fault that they can't easily be used on ChromeOS.&lt;/p&gt;

</description>
      <category>chromeos</category>
      <category>android</category>
      <category>opensource</category>
      <category>crostini</category>
    </item>
    <item>
      <title>First steps towards Codeberg</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Wed, 31 Dec 2025 11:43:33 +0000</pubDate>
      <link>https://dev.to/tkuenneth/first-steps-towards-codeberg-48hl</link>
      <guid>https://dev.to/tkuenneth/first-steps-towards-codeberg-48hl</guid>
      <description>&lt;p&gt;A lot of Europeans are currently talking about Europe having to become more independent from US-based big tech. Being a European myself, I feel the need for this, too. However, just talking won't make a difference. So, why not make this our New Year's resolution? Here's mine: I love open source. Given GitHub's recent trajectory toward centralisation, I feel there are better-suited homes for my repositories. That's why I will start migrating them to &lt;strong&gt;Codeberg&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Codeberg?
&lt;/h3&gt;

&lt;p&gt;Codeberg is a community-driven non-profit platform for hosting software projects. Many consider it the leading independent alternative to commercial services like GitHub and Bitbucket. While it looks and feels very similar to GitHub, its underlying philosophy and legal structure are fundamentally different: unlike GitHub, which is owned by Microsoft, Codeberg is run by a German non-profit organisation called &lt;strong&gt;Codeberg e.V.&lt;/strong&gt;. It is funded by donations rather than venture capital or ads. The platform runs on &lt;strong&gt;Forgejo&lt;/strong&gt;, which is a community-governed fork of &lt;strong&gt;Gitea&lt;/strong&gt;. Therefore, the very software used to run the site is itself open source and transparent. And it is privacy-focused. Since Codeberg is hosted in the European Union (Germany), it adheres to strict GDPR standards. It does not track users for advertising and avoids black box AI features like GitHub Copilot.&lt;/p&gt;

&lt;p&gt;Does this sound appealing? To me it certainly did. That's why I decided to jump right in. In this introductory article, I'll show you my first baby steps, that is, registering and migrating the first GitHub repository.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signing up
&lt;/h3&gt;

&lt;p&gt;Registering is a very quick and pleasant experience. Visit &lt;a href="https://codeberg.org" rel="noopener noreferrer"&gt;https://codeberg.org&lt;/a&gt;, find and click the &lt;em&gt;Register&lt;/em&gt; button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowngilbf6pv5j3h62t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowngilbf6pv5j3h62t.png" alt="Codeberg homepage" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Just enter a username, your email address, a password, and the randomly generated number or word.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6qb7dqghsdizkx4d2my.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6qb7dqghsdizkx4d2my.png" alt="sign-up page" width="542" height="775"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you click on &lt;em&gt;Register Account&lt;/em&gt;, you should receive an email with the inevitable confirmation link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr6ntbdznrdpkjwfvz323.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr6ntbdznrdpkjwfvz323.png" alt="Activation email" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on the link to verify your email address. You will be directed to your personal Codeberg landing page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs39kn5gvrg6st9b2vtjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs39kn5gvrg6st9b2vtjd.png" alt="Personal Codeberg landing page" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, you may want to update some settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthsrtvo6mmn5x7yyuli6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fthsrtvo6mmn5x7yyuli6.png" alt="Settings page" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While I won't walk you through the settings, I would like to encourage you to show your Codeberg account on Mastodon. First, add Mastodon to Codeberg. Look for the &lt;em&gt;Website&lt;/em&gt; field or the &lt;em&gt;Social Accounts section&lt;/em&gt; (if available in the current UI). Paste your full Mastodon profile URL (for example, &lt;a href="https://mastodon.social/@tkuenneth" rel="noopener noreferrer"&gt;https://mastodon.social/@tkuenneth&lt;/a&gt;) and click &lt;em&gt;Update Profile&lt;/em&gt; at the bottom. Codeberg automatically adds the &lt;code&gt;rel="me"&lt;/code&gt; attribute to the website link in your profile, which is exactly what Mastodon needs to verify you.&lt;/p&gt;

&lt;p&gt;Next, open your Mastodon instance and visit your  &lt;em&gt;Profile&lt;/em&gt; page. Click &lt;em&gt;Edit profile&lt;/em&gt; and find the &lt;em&gt;Extra fields&lt;/em&gt; in &lt;em&gt;Basic information&lt;/em&gt;. This is where you add labels and links. In the label column, type something like &lt;code&gt;Codeberg&lt;/code&gt;. In the content column, paste your Codeberg profile URL (e.g., &lt;a href="https://codeberg.org/tkuenneth" rel="noopener noreferrer"&gt;https://codeberg.org/tkuenneth&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Click &lt;em&gt;Save Changes&lt;/em&gt;. It may take a short while until Mastodon detects that it's you, but in the end, it should look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F91vd6ms1bfavhocp1q8b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F91vd6ms1bfavhocp1q8b.png" alt="A Mastodon profile page with several verified links" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating your first repository
&lt;/h3&gt;

&lt;p&gt;To start a migration, click on the &lt;em&gt;+&lt;/em&gt; symbol on the top right, and select &lt;em&gt;New migration&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm9d79jm525lfns6vvzks.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm9d79jm525lfns6vvzks.png" alt="Starting a migration from a drop down menu" width="273" height="178"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The next step is to select the Git host you want to migrate from. The migration tool can migrate your repository data, as well as metadata like issues, labels, wiki, releases, and milestones.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwivt2856jmnevel19n0c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwivt2856jmnevel19n0c.png" alt="Selecting the host" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most important piece of information is, of course, the url of the repository you want to migrate. To be able to also migrate metadata, you need to provide an access token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnbrokh6glz38yt2on8ql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnbrokh6glz38yt2on8ql.png" alt="Configuring the migration" width="800" height="911"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you have specified the owner, the repository name, and the visibility, you can start the migration by clicking on &lt;em&gt;Migrate repository&lt;/em&gt;. The following screenshot shows a freshly migrated repo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuziow5ve79tbjj6k0vcq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuziow5ve79tbjj6k0vcq.png" alt="Repository homepage" width="800" height="985"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Finalising the migration
&lt;/h3&gt;

&lt;p&gt;Once the new repository has been set up, you may want to update the README of the old repo by mentioning its new home and then archive the content (on GitHub, this makes it &lt;em&gt;read-only&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;I strongly advise against deleting the old repo. It’s tempting to want a clean break, but there are two big reasons to keep it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Broken Links: There are inevitably links to your code scattered across the web—in blog posts, old commits, or bookmarks—which you would render useless.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Security (Namespace Hijacking): This is a risk many people overlook. If you delete a repository, that specific URL becomes available again. Someone else could potentially register that same name and host malicious code where your project used to be. By keeping your old repository as a placeholder or a &lt;em&gt;tombstone&lt;/em&gt;, you ensure that you still control that space and can point your users safely to Codeberg.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So, the best move is to add a clear migration notice to the top of the README, set the repository to &lt;em&gt;Archived&lt;/em&gt;, and let it serve as a signpost.&lt;/p&gt;

&lt;p&gt;To learn more about migrations to Codeberg, read the official guide at &lt;a href="https://docs.codeberg.org/advanced/migrating-repos/" rel="noopener noreferrer"&gt;https://docs.codeberg.org/advanced/migrating-repos/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>privacy</category>
      <category>digitalsovereignty</category>
      <category>git</category>
    </item>
    <item>
      <title>Some thoughts on keyboard shortcuts on Android</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Fri, 01 Aug 2025 12:30:23 +0000</pubDate>
      <link>https://dev.to/tkuenneth/some-thoughts-on-keyboard-shortcuts-on-android-2pec</link>
      <guid>https://dev.to/tkuenneth/some-thoughts-on-keyboard-shortcuts-on-android-2pec</guid>
      <description>&lt;p&gt;When you think about which cool new feature you may want to add to your app, you probably won't immediately shout &lt;strong&gt;Hell yes, my app desperately needs keyboard shortcut support&lt;/strong&gt;. I mean, how often do we use physical keyboards with our mobile devices anyway? The days of Android phones having a keyboard built in are long gone, aren't they? &lt;/p&gt;

&lt;p&gt;Well, not really. Chromebooks run Android apps. Many Android tablets are sold with physical keyboards. And Android's desktop mode, which hopefully will gain some traction eventually, allows us to connect our smartphone to big screens. This setup only makes sense when you also pair a mouse and keyboard. So, keyboards most certainly are not a thing of the past. They may not have been very common on Android in recent years, but other platforms have always relied on them. And still do. Why? Because physical keyboards are productivity boosters.&lt;/p&gt;

&lt;p&gt;That's where keyboard shortcuts come in. They allow us to trigger an action by simultaneously pressing a few keys. Prime examples are the clipboard-related commands &lt;em&gt;Cut&lt;/em&gt; (Control-X), &lt;em&gt;Copy&lt;/em&gt; (Control-C), and &lt;em&gt;Paste&lt;/em&gt; (Control-V). And yes, it's &lt;em&gt;Command&lt;/em&gt; on the Mac. &lt;/p&gt;

&lt;p&gt;Have you noticed that I said &lt;strong&gt;command&lt;/strong&gt;? Keyboard shortcuts allow us to trigger an action or command &lt;em&gt;fast&lt;/em&gt;. That's why they are called shortcuts. The important point here is: there should always be another way of executing that command. Typically, this &lt;em&gt;other way&lt;/em&gt; also advertises the corresponding keyboard shortcut. Take a look:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2w38hnizb36v454ol7k8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2w38hnizb36v454ol7k8.png" alt="The macOS menu bar with the Edit menu being opened" width="735" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When we open a menu, we see the command and its associated shortcut, which helps us remember it (eventually). Actually &lt;em&gt;using&lt;/em&gt; the shortcut helps us remember it even better, but that's another topic.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;But Android doesn't have a menu bar&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Traditional menu bars work best with a mouse and a mouse pointer. That's why Android does not have one. However, Android most certainly allows apps to show menus. And these can contain keyboard shortcuts, too. We'll tackle this shortly. But first, let me show you how to define and consume keyboard shortcuts on an app level. The source code of my sample app &lt;em&gt;KeyboardShortcutDemo&lt;/em&gt; is available on &lt;a href="https://github.com/tkuenneth/KeyboardShortcutDemo" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. It is a &lt;em&gt;Compose Multiplatform&lt;/em&gt; project that targets Android, IOS, and the Desktop. In this article, I focus on Android.&lt;/p&gt;

&lt;h3&gt;
  
  
  Global shortcuts
&lt;/h3&gt;

&lt;p&gt;Working with global keyboard shortcuts consists of two steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Defining the shortcuts&lt;/li&gt;
&lt;li&gt;Receiving shortcut activations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both can be implemented on an &lt;code&gt;Activity&lt;/code&gt; level. Here's how to define a keyboard shortcut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;listKeyboardShortcutInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardShortcutInfo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;savedInstanceState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Bundle&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;savedInstanceState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;enableEdgeToEdge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;listKeyboardShortcutInfo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;globalShortcuts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="nc"&gt;KeyboardShortcutInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keyAsString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;modifiers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;hardKeyboardHidden&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt;
                &lt;span class="n"&gt;hardKeyboardHiddenFlow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectAsStateWithLifecycle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;systemInDarkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isSystemInDarkTheme&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;darkMode&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;rememberSaveable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;systemInDarkMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                            &lt;span class="nf"&gt;darkColorScheme&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="k"&gt;else&lt;/span&gt;
                            &lt;span class="nf"&gt;lightColorScheme&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;MainScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;globalShortcuts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;hardKeyboardHidden&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hardKeyboardHidden&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt;
                    &lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HARDKEYBOARDHIDDEN_YES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;darkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;requestShowKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;toggleDarkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;darkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;darkMode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onProvideKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MutableList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardShortcutGroup&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Menu&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onProvideKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;menu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyboardShortcutGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;listKeyboardShortcutInfo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;onCreate()&lt;/code&gt;, we populate a list of &lt;code&gt;KeyboardShortcutInfo&lt;/code&gt; instances and, inside &lt;code&gt;onProvideKeyboardShortcuts()&lt;/code&gt;, add it to &lt;code&gt;data&lt;/code&gt;, which has been passed to us by Android. Please notice the &lt;code&gt;Menu&lt;/code&gt; which will usually be &lt;code&gt;null&lt;/code&gt; in a Compose-only app.&lt;/p&gt;

&lt;p&gt;But what is &lt;code&gt;globalShortcuts&lt;/code&gt;? It's defined in &lt;em&gt;GlobalShortcuts.kt&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;globalShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runBlocking&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;say_hello&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;keyAsString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"H"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ctrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;KeyboardShortcut&lt;/code&gt; is a cross-platform generalisation of &lt;code&gt;KeyboardShortcutInfo&lt;/code&gt;, which I defined to be able to refer to keyboard shortcuts beyond Android activities. Have you spotted that I pass &lt;code&gt;globalShortcuts&lt;/code&gt; to &lt;code&gt;MainScreen()&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Next, let's look at how to receive keyboard shortcut presses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onKeyShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;keyCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KeyEvent&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;listKeyboardShortcutInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEachIndexed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keycode&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;keyCode&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
            &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasModifiers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiers&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;globalShortcuts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;triggerAction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onKeyShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;onKeyShortcut()&lt;/code&gt; function receives a &lt;code&gt;keyCode&lt;/code&gt; and an &lt;code&gt;event&lt;/code&gt;. Using both, it is simple to check if a shortcut defined by our app has been pressed. If this is the case, we return &lt;code&gt;true&lt;/code&gt;, otherwise &lt;code&gt;super.onKeyShortcut(keyCode, event)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But what does &lt;code&gt;shortcut.triggerAction()&lt;/code&gt; do? Here's how it is implemented:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;keyAsString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="nc"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONFLATED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;flow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receiveAsFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;triggerAction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trySend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shortcutAsText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;
        &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;parts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableListOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ctrl"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Meta"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Shift"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyAsString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"+"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since &lt;code&gt;KeyboardShortcutInfo&lt;/code&gt; is specific to Android, it makes sense to provide an alternative that we can use across platforms. But even on Android we need a mechanism that allows us to trigger and consume keyboard shortcuts in a modern Kotlin way. &lt;/p&gt;

&lt;p&gt;That's what &lt;code&gt;KeyboardShortcut&lt;/code&gt; is for. &lt;code&gt;label&lt;/code&gt; and &lt;code&gt;shortcutAsText&lt;/code&gt; will be used by composables. &lt;code&gt;flow&lt;/code&gt; allows us to react upon invocations of the keyboard shortcut. Finally, &lt;code&gt;triggerAction()&lt;/code&gt;: as its name implies, it triggers an action by trying to send something to a channel. Kindly recall that we invoke this function inside &lt;code&gt;onKeyShortcut()&lt;/code&gt;. What does this mean? Once we have determined that a keyboard shortcut has been invoked by pressing the corresponding keys, we make sure to notify everyone interested in the event. We'll, by the way, also call this function from inside our Compose hierarchy. I'll show you shortly. &lt;/p&gt;

&lt;p&gt;But before that, let's take a user's perspective. Android and Chrome OS can display a list of available shortcuts. On most devices, this help screen can be opened by pressing Meta-/, which is known as Search-/ on Chromebooks. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkklqqy9evbriguv1s4u7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkklqqy9evbriguv1s4u7.png" alt="The keyboard shortcuts dialog on Android" width="800" height="1695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since not all users may be familiar with this system shortcut, consider allowing the user to summon it from within your app. Kindly recall what my sample app does inside &lt;code&gt;setContent {}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;MainScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;globalShortcuts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;hardKeyboardHidden&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;darkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;requestShowKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="err"&gt;…&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requestShowKeyboardShortcuts()&lt;/code&gt; (this method is defined in &lt;code&gt;android.app.Activity&lt;/code&gt;) requests the &lt;em&gt;Keyboard Shortcuts&lt;/em&gt; screen to show up. This will trigger &lt;code&gt;onProvideKeyboardShortcuts()&lt;/code&gt; to retrieve the shortcuts for the foreground activity. &lt;em&gt;KeyboardShortcutDemo&lt;/em&gt; just shows a &lt;em&gt;Show keyboard shortcuts&lt;/em&gt; button. Certainly, there are more clever places to incorporate it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg2l9hjkw44xqs8v7bvjg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg2l9hjkw44xqs8v7bvjg.png" alt="Screenshot of the KeyboardShortcutDemo sample" width="800" height="1695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's recap. I showed you how to provide keyboard shortcuts on an &lt;code&gt;Activity&lt;/code&gt; level. We can even summon a system dialog that lists them. But how do we react to shortcut presses and how do we visualise the shortcut in our user interface? Finally, how do we mention the shortcut to the user so that they can remember it, similar to what I explained regarding classic menu bars?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;MainScreen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hardKeyboardHidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;toggleDarkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;snackbarMessage&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;helloMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collectLatest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;snackbarMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;helloMessage&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;KeyboardShortcutDemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;hardwareKeyboardHidden&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hardKeyboardHidden&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;snackbarMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snackbarMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;shortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;listKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;darkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;clearSnackbarMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;snackbarMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;toggleDarkMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;toggleDarkMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a keyboard shortcut is invoked, the sample app shows a snackbar message. We keep the text in &lt;code&gt;snackbarMessage&lt;/code&gt;. &lt;code&gt;listKeyboardShortcuts&lt;/code&gt; is the list of keyboard shortcuts. &lt;code&gt;KeyboardShortcutDemo()&lt;/code&gt; (this composable is invoked from &lt;code&gt;MainScreen()&lt;/code&gt;) uses it to populate a menu. &lt;code&gt;hardKeyboardHidden&lt;/code&gt; is used to determine if a physical keyboard is ready to use. Kindly just ignore it, I will detail on this in a follow-up article. &lt;/p&gt;

&lt;p&gt;So far, I still haven't explained how we react to shortcut presses. The magic happens inside &lt;code&gt;LaunchedEffect()&lt;/code&gt;. For each keyboard shortcut, we invoke &lt;code&gt;shortcut.flow.collectLatest {}&lt;/code&gt;. In my example, we always set the snackbar message. Real-world apps would certainly provide different implementations, depending on the shortcut.&lt;/p&gt;

&lt;h3&gt;
  
  
  Menus and keyboard shortcuts in Jetpack Compose
&lt;/h3&gt;

&lt;p&gt;When looking at the &lt;code&gt;KeyboardShortcutDemo()&lt;/code&gt; composable, kindly recall the screenshot of the app to understand its structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an app bar at the top, including the app name and a menu&lt;/li&gt;
&lt;li&gt;a text field (not further discussed in this article)&lt;/li&gt;
&lt;li&gt;a switch with an accompanying label (not further discussed in this article)&lt;/li&gt;
&lt;li&gt;a button to open the keyboard shortcuts dialog&lt;/li&gt;
&lt;li&gt;(not visible in the screenshot) a text when no physical keyboard is ready to use
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;KeyboardShortcutDemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;hardwareKeyboardHidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shortcuts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
    &lt;span class="n"&gt;snackbarMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;darkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;clearSnackbarMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;toggleDarkMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;snackBarHostState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;SnackbarHostState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;showMenu&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;remember&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;mutableStateOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;
    &lt;span class="nc"&gt;Scaffold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;topBar&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;TopAppBar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; 
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;IconButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;showMenu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;showMenu&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nc"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                            &lt;span class="n"&gt;imageVector&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Icons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Filled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MoreVert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;contentDescription&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                                &lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;more_options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="nc"&gt;DropdownMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;expanded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;showMenu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="n"&gt;onDismissRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;showMenu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;shortcuts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                            &lt;span class="nc"&gt;DropdownMenuItemWithShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shortcutAsText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                                    &lt;span class="n"&gt;showMenu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
                                    &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;triggerAction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                                &lt;span class="p"&gt;}&lt;/span&gt;
                            &lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;snackbarHost&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;SnackbarHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snackBarHostState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;innerPadding&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
                &lt;span class="err"&gt;…&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;innerPadding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;contentAlignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Center&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="err"&gt;…&lt;/span&gt;
            &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;horizontalAlignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CenterHorizontally&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;verticalArrangement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrangement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spacedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="err"&gt;…&lt;/span&gt;
                &lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;showKeyboardShortcuts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;TextWithUnderlinedChar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show_keyboard_shortcuts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hardwareKeyboardHidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stringResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="nc"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hardware_keyboard_hidden&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;align&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BottomCenter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeContentPadding&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headlineSmall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snackbarMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snackbarMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotBlank&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;snackBarHostState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showSnackbar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snackbarMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;clearSnackbarMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The menu will contain as many items as &lt;code&gt;shortcuts&lt;/code&gt; has elements. When an element is selected, a snack bar will appear. That's because &lt;code&gt;onClick()&lt;/code&gt; of each &lt;code&gt;DropdownMenuItemWithShortcut()&lt;/code&gt; invokes &lt;code&gt;shortcut.triggerAction()&lt;/code&gt;. The &lt;code&gt;onClick()&lt;/code&gt; of the button just invokes the &lt;code&gt;showKeyboardShortcuts&lt;/code&gt; lambda.&lt;/p&gt;

&lt;p&gt;That's been quite a bit to digest, right? Fortunately, there is only one piece of the puzzle missing. What is &lt;code&gt;DropdownMenuItemWithShortcut()&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Unlike the traditional &lt;code&gt;Activity&lt;/code&gt;-level options menu, &lt;code&gt;DropDownMenuItem()&lt;/code&gt; (which comes with Jetpack Compose) does not support shortcuts out of the box. This means we need to somehow add this to the &lt;code&gt;Text()&lt;/code&gt; composable. The most basic approach is to just add the shortcut at the end of the &lt;code&gt;String&lt;/code&gt;. While this works, this does not look particularly pleasing. Besides, that's not how we build UIs with Jetpack Compose.&lt;/p&gt;

&lt;p&gt;Here's a composable that provides a &lt;code&gt;DropDownMenuItem()&lt;/code&gt; with a shortcut at the end of the text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;DropdownMenuItemWithShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;DropdownMenuItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;ShortcutText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ShortcutText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxWidth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;horizontalArrangement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrangement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SpaceBetween&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alignByBaseline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;style&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typography&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bodyMedium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MaterialTheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;colorScheme&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onSurface&lt;/span&gt;
                            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.6f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alignByBaseline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Have you spotted the &lt;code&gt;alignByBaseline()&lt;/code&gt; modifier? While most examples using &lt;code&gt;Row()&lt;/code&gt; just vertically center the children, this is a nightmare from a UX perspective. The texts have to be baseline-aligned to be readable nicely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fog52dk4th4g4cer4aix0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fog52dk4th4g4cer4aix0.png" alt="Opened menu with a menu item showing the keyboard shortcut" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That looks pretty nice, doesn't it? &lt;/p&gt;

&lt;p&gt;You may be wondering where the &lt;code&gt;shortcut&lt;/code&gt; text is created. In the &lt;code&gt;KeyboardShortcutDemo()&lt;/code&gt; composable, it's passed like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;shortcuts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nc"&gt;DropdownMenuItemWithShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;shortcut&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shortcutAsText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;onClick&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;showMenu&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
            &lt;span class="n"&gt;shortcut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;triggerAction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, what is &lt;code&gt;shortcut.shortcutAsText&lt;/code&gt;? Kindly recall my custom &lt;code&gt;KeyboardShortcut&lt;/code&gt; class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;KeyboardShortcut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;keyAsString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;…&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;shortcutAsText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;
        &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;parts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableListOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Ctrl"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Meta"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Shift"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyAsString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"+"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was surprised to learn that there seems to be no public-facing API in Android that provides a locale-aware &lt;code&gt;String&lt;/code&gt; representation of &lt;code&gt;KeyboardShortcutInfo&lt;/code&gt;. However, a function that achieves this must be around somewhere, since the often-mentioned keyboard shortcuts dialog needs something similar, too. Since I couldn't find one, I decided to write my own. Checking the modifiers and the key code isn't too difficult, but there are some nuances that need to be taken into account. For example, on Chromebooks, &lt;em&gt;Meta&lt;/em&gt; should be &lt;em&gt;Search&lt;/em&gt; or some corresponding symbol. I omitted this for brevity. In a real-world app, you would want to take the string from the resources and handle device-specific variations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;Adding keyboard shortcut support to your app may not sound particularly fancy at first sight, but in my opinion brings a lot of added value. Do your apps already support them? How did you handle the visual representation of the shortcuts? Please share your thoughts in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ui</category>
      <category>mobile</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Quick tip: (sort of) using Quick Share on macOS</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Thu, 19 Jun 2025 10:32:45 +0000</pubDate>
      <link>https://dev.to/tkuenneth/quick-tip-sort-of-using-quick-share-on-macos-53h9</link>
      <guid>https://dev.to/tkuenneth/quick-tip-sort-of-using-quick-share-on-macos-53h9</guid>
      <description>&lt;p&gt;At the time of writing this article, there (still) is no official Quick Share implementation for macOS. While there is an open source app that partially implements the underlying protocol, and while there certainly are quite a few excellent alternate file sharing solutions, you still may want (or have) to rely on Google's version.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.android.com/intl/en_us/better-together/quick-share-app/" rel="noopener noreferrer"&gt;Windows version&lt;/a&gt; obviously won't work on your Mac, but it may very well work inside a virtualized Windows guest.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/kD4wi2W9Rzk"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;So what is happening there? I am using Parallels Desktop, which allows you to run an authorized (&lt;a href="https://kb.parallels.com/114051" rel="noopener noreferrer"&gt;that is, licensed and activated&lt;/a&gt;) version of Windows 11 for ARM. Having a Windows environment available on your Mac may come in handy for several reasons. My personal favorites are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;testing Compose Desktop apps&lt;/li&gt;
&lt;li&gt;occasionally play some PC-only games&lt;/li&gt;
&lt;li&gt;use apps that are not available natively on macOS, which, at the time of writing, was the case for Quick Share&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing Compose Desktop apps has been a game changer for me, and I will be writing about this, too. For now, let's focus on Quick Share. And virtualization. But first, a short disclaimer. I am not advocating a specific product. There are several virtualization packages on macOS. And they all have their strengths. It just happened that I once tried Parallels and liked it, so I stayed. But what I am going to describe, will likely be working on other products, too.&lt;/p&gt;

&lt;p&gt;Before Apple switched to Apple Silicon, Macs were powered by Intel chips. Well, and before that, still others. Anyway, when a virtualization software virtualizes an environment with the same processor architecture, there, inevitably, is less emulation needed than when it also needs to simulate the processor. Those packages still have to do crazy, mind-blowing stuff. But a lot of code can run natively, given that the underlying processor supports virtualization. This is the case for modern Intel and AMD chips. However, current Apple Silicon chips have only limited support for running virtualized Intel/AMD operating systems. While Apple does provide a virtualization layer called Rosetta 2, running an x86/x64 version of Windows is - at the time of writing - not easily possible. That's why Parallels relies on Windows for ARM. This version is usually run on ARM-PCs. Now, you may be thinking: What about software compatibility? Windows for ARM has its own emulation layer, which allows users to run many x86/x64 programs.&lt;/p&gt;

&lt;p&gt;Let's turn to Quick Share. The official Google Quick Share application for Windows is compatible with 64-bit versions of Windows 10 and up. Importantly, it also supports ARM-based PCs running Windows 11 and up. Since Quick Share leverages both Bluetooth and Wi-Fi for file transfer, you'll need to ensure both are enabled and working on your virtualized Windows PC and the Android device.&lt;/p&gt;

&lt;p&gt;Using Parallels, Bluetooth is activated from the menu bar (&lt;em&gt;Devices / USB &amp;amp; Bluetooth / Configure&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2bd5a9g176vnka7qgy6u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2bd5a9g176vnka7qgy6u.png" alt="Screenshot snippet showing the menu bar with an open menu: Devices / USB &amp;amp; Bluetooth"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4hyxge9bq32fmnfun6cu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4hyxge9bq32fmnfun6cu.png" alt="Configuration dialog showing the USB &amp;amp; Bluetooth section"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, check if Bluetooth is enabled and if your virtual machine is discoverable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv8td8w363q78edtjpbns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv8td8w363q78edtjpbns.png" alt="Section Bluetooth &amp;amp; devices in Windows settings"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0r9po40kvnadwmnbu1j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0r9po40kvnadwmnbu1j.png" alt="Section Home, subsection Bluetooth devices in Windows settings"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wi-Fi required a little more thought to make Quick Share work. Specifically, the &lt;em&gt;Network&lt;/em&gt; settings should look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmduig31nkrzr9u5kuhz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmduig31nkrzr9u5kuhz.png" alt="Configuration dialog showing the Network section"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source&lt;/em&gt; needs to be set to &lt;em&gt;Wi-Fi&lt;/em&gt; rather than &lt;em&gt;Shared Network&lt;/em&gt;, which is the default and recommended setting.&lt;/p&gt;

&lt;p&gt;While that's basically it, let me show you a few screenshots from the Windows app:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj8bxzlo9a3pfgrpbrrix.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj8bxzlo9a3pfgrpbrrix.png" alt="Screenshot of the Quick Share Windows app settings"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In its settings, you can specify where received files are saved. Since you can share macOS folders with the virtual Windows, it may sound like a good idea to use such folders. Unfortunately, this did not work for me. While the file was briefly visible, it vanished shortly after. Using a folder inside the Windows guest fortunately works like a charm.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa33wvgwe6owveneb4akj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa33wvgwe6owveneb4akj.png" alt="Screenshot of the main section of the Windows app"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the main section, you can easily change who can send you files. If the main window is closed, this setting is also available from the system tray:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fho6oiyrtsxop5w55ixe8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fho6oiyrtsxop5w55ixe8.png" alt="Screenshot snippet showing the system tray with the Quick Share menu"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrap-up
&lt;/h3&gt;

&lt;p&gt;I have an instance of Windows basically running all the time, since I do a lot of Compose Multiplatform / Compose Desktop coding. No, it's not because of games 🤣. Anyway, for my setup, receiving Quick Share files there and moving them to native Mac folders manually is a decent enough workflow to keep it. Granted, a native macOS version is still overdue. At least, Google could fix the "not being able to save files in networked folders" issue. &lt;/p&gt;

&lt;p&gt;What are your thoughts on this? Please share them in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>macos</category>
    </item>
    <item>
      <title>Did you know … ? Android 16 edition</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Sun, 08 Jun 2025 19:30:33 +0000</pubDate>
      <link>https://dev.to/tkuenneth/did-you-know-android-16-edition-4d57</link>
      <guid>https://dev.to/tkuenneth/did-you-know-android-16-edition-4d57</guid>
      <description>&lt;p&gt;The rollout of Android 16 is likely going to happen today (while &lt;em&gt;today&lt;/em&gt; certainly depends on your time zone 😅). In anticipation of the new release, I made a series of social media posts highlighting changes and additions that may not make it to the front page. Here's a quick collection of these posts.&lt;/p&gt;

&lt;h3&gt;
  
  
  7 new services
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqsd7qv3e22l" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqsd7qv3e22l&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Ignoring rotation and resizability change restrictions
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqos5633ns2s" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqos5633ns2s&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Embedding the Android Photo Picker
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqmx44ht3c2g" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqmx44ht3c2g&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A hint at virtual threads
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqkcmaava22y" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqkcmaava22y&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Supplemental descriptions for views
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqh4y3mvqk2u" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqh4y3mvqk2u&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  New key codes
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqfcc6umfc2f" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqfcc6umfc2f&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Elegant font APIs deprecated and disabled
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqu2fdx6d22f" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqu2fdx6d22f&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Good vibrations
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lqz2snwyas22" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lqz2snwyas22&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  NoWritingToolsSpan
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/tkuenneth.dev/post/3lr4lwz2dis2y" rel="noopener noreferrer"&gt;https://bsky.app/profile/tkuenneth.dev/post/3lr4lwz2dis2y&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>androiddev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
