<?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>Forem: Thomas Künneth</title>
    <description>The latest articles on Forem by Thomas Künneth (@tkuenneth).</description>
    <link>https://forem.com/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>Forem: Thomas Künneth</title>
      <link>https://forem.com/tkuenneth</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tkuenneth"/>
    <language>en</language>
    <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://forem.com/tkuenneth/agentic-interaction-using-appfunctions-m8k</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/from-vibe-coding-to-reality-building-marvinsync-171h</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/refuelling-your-jetpack-d8i</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/setting-up-local-codeberg-runners-4eif</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/f-droid-on-chromeos-trying-to-get-behind-the-road-blocks-48f</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/first-steps-towards-codeberg-48hl</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/some-thoughts-on-keyboard-shortcuts-on-android-2pec</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/quick-tip-sort-of-using-quick-share-on-macos-53h9</link>
      <guid>https://forem.com/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://forem.com/tkuenneth/did-you-know-android-16-edition-4d57</link>
      <guid>https://forem.com/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>
    <item>
      <title>Testing Compose Desktop apps: preparations</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Mon, 12 May 2025 15:35:49 +0000</pubDate>
      <link>https://forem.com/tkuenneth/testing-compose-desktop-apps-preparations-gg</link>
      <guid>https://forem.com/tkuenneth/testing-compose-desktop-apps-preparations-gg</guid>
      <description>&lt;p&gt;Welcome to the fifth article in a series of tips and tricks about Compose Multiplatform. The content is based on a sample app called &lt;a href="https://github.com/tkuenneth/CMP-Unit-Converter" rel="noopener noreferrer"&gt;CMP Unit Converter&lt;/a&gt;. It runs on Android, iOS, and the Desktop. As its name suggests, you can convert between various units and scales. While this may provide some value, the main goal of the app and this series is to show how to use Compose Multiplatform and a couple of other multiplatform libraries while focusing on platform integration. This time, we look at how to test Compose Multiplatform apps on one particular target: the desktop. &lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Desktop anyway?
&lt;/h3&gt;

&lt;p&gt;Kotlin Multiplatform is based on the idea of compiling for different &lt;em&gt;targets&lt;/em&gt;. Targets are defined by an operating system or platform and, for native targets, a processor architecture. In the &lt;em&gt;build.gradle.kts&lt;/em&gt; file inside the &lt;em&gt;composeApp&lt;/em&gt; module, you can see a reference to the &lt;em&gt;Desktop&lt;/em&gt; target inside the &lt;code&gt;kotlin {}&lt;/code&gt; block: the invocation of &lt;code&gt;jvm("desktop")&lt;/code&gt;. So, when referring to the Desktop, we mean the Java platform and its components.&lt;/p&gt;

&lt;p&gt;Java's astonishing success lies in the ability to write code that can run on several operating systems; among others, macOS, Windows, Linux, AIX and z/OS. The key to this ability is the Java Virtual Machine, a runtime environment that executes programs represented in a machine language called bytecode. The JVM integrates the program into the host operating system and provides means for communicating with the system, and for using its resources. &lt;/p&gt;

&lt;p&gt;The JVM allows us to run bytecode programs on any operating system or platform the JVM is available on. Compose Multiplatform narrows this down a little. Version 1.8.0  of the JetBrains framework supports macOS, Windows, Windows on ARM64, Linux, and Linux on ARM64. That's three operating systems. Each of them has - or had - support for Intel/AMD and ARM architectures.&lt;/p&gt;

&lt;p&gt;Let that trickle in for a while.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Does that mean I should test my Compose Desktop app on each of these?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Glad you are asking. Well, in a way it's your choice, depending on how many users your app is targeting. At the time of writing, Apple still officially supports some Intel-based Macs with software updates. Therefore, if your app targets macOS, you may want to provide installation packages for both Intel and Apple Silicon Macs. And if you do that, you probably also want to test them. &lt;/p&gt;

&lt;p&gt;The same is true for Windows. While Windows has been an Intel/AMD stronghold for decades, we have seen a significant increase in ARM-powered PCs in recent years. Many Copilot+ PCs run Windows for ARM, so, if your app targets Windows, you likely want your app to also run on them. &lt;/p&gt;

&lt;p&gt;Finally, Linux. While I don't have statistics regarding the number of Linux for ARM installations, it feels safe to say that there aren't many. Therefore, if you want to provide your app for Linux, you might be tempted to focus on Intel/AMD. On the other hand, any additional user can make a difference, so you need to balance both costs and benefits of providing a Linux on ARM installation archive.&lt;/p&gt;

&lt;p&gt;Before we look at what happens along the way of creating an installation archive, let's focus a little more on operating systems and processor architectures. Taking into account that the JVM is the runtime environment for your app, why would you need to worry about processor architectures? Isn't bytecode a machine language in its own right? And isn't the JVM isolating your app from the rest of the system? While the JVM executes the bytecode (or compiles it ahead of time to native code so that the physical processor can run it), it does not isolate your app from the rest of the system, but rather &lt;em&gt;integrates&lt;/em&gt; it. For example, you can easily do native calls using JNI - which certainly depends on the processor architecture. Even if your app does no native calls directly, referenced libraries might. Jetpack Compose relies on Skia, after all.&lt;/p&gt;

&lt;p&gt;Consequently, you should test your app on at least the primary processor architecture of an operating system or platform. If you provide versions of your app for additional processor architectures, you certainly need to test these, too. I'll return to this later.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to create an installation archive
&lt;/h3&gt;

&lt;p&gt;Kotlin and Compose Multiplatform projects heavily rely on Gradle. As you can see in the following screenshot, you can choose from quite a few tasks to create native installation archives.&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%2Fm36925xsdjsfs0w8plze.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%2Fm36925xsdjsfs0w8plze.png" alt="Screenshot of the Gradle tool window in IntelliJ" width="498" height="772"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some of them can be used only on the platform you want to create an installation archive for, for example &lt;code&gt;packageDmg&lt;/code&gt; on macOS, and &lt;code&gt;packageMsi&lt;/code&gt; on Windows. Here's why: under the hood, the &lt;code&gt;jpackage&lt;/code&gt; tool is executed. It invokes other executables from the Java Development Kit, like &lt;code&gt;jlink&lt;/code&gt;, but also some native installer tools like the &lt;em&gt;WiX Toolset&lt;/em&gt; on Windows (which is not supported on macOS and Linux). &lt;/p&gt;

&lt;p&gt;&lt;code&gt;jlink&lt;/code&gt; creates a minimal, application-specific Java runtime environment by including only the necessary modules from a full JDK. But which JDK is used? While there is a corresponding command line parameter, in a Kotlin Multiplatform project you can specify the JDK 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;compose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desktop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;application&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mainClass&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your.main.Class"&lt;/span&gt;
        &lt;span class="n"&gt;javaHome&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't set the &lt;code&gt;javaHome&lt;/code&gt; property explicitly, the &lt;code&gt;jpackage&lt;/code&gt; tool which is invoked by, for example, the &lt;code&gt;createMsi&lt;/code&gt; task, will typically use the JDK that Gradle itself is using. &lt;/p&gt;

&lt;p&gt;Let's recap: to have your app using a particular Java Runtime, a straightforward way to achieve this is to set the &lt;code&gt;javaHome&lt;/code&gt; property in the &lt;em&gt;build.gradle.kts&lt;/em&gt; file of your &lt;em&gt;composeApp&lt;/em&gt; module. &lt;/p&gt;

&lt;p&gt;At this point, you may be asking yourself why this is important. Kindly recall that an operating system may support more than one processor architecture. You would then have two different JDKs, each of which you could easily select using the technique described above. Here's an example: Windows on ARM has a built-in emulation layer that allows the seamless execution of Intel/AMD-based apps although the primary processor architecture is ARM64. Consequently, you can install the JDK for ARM64 and Intel/AMD at the same time. To build your app, you need to specify which one to use. &lt;/p&gt;

&lt;p&gt;macOS running on Apple Silicon and Linux on ARM also allow the execution of Intel/AMD-based apps, but through different mechanisms and with varying degrees of compatibility and performance. Apple uses a translation layer called Rosetta 2. On Linux you would likely be using QEMU User-Mode Emulation. ...or something else that I will be mentioning a little later...&lt;/p&gt;

&lt;p&gt;How straightforward - or complicated - it is to install two JDKs with the same version number but a different processor architecture depends on the underlying operating system. On macOS, Java Development Kits are usually put inside &lt;code&gt;/Library/Java/JavaVirtualMachines&lt;/code&gt;. Each version resides in a subdirectory, for example &lt;code&gt;amazon-corretto-17.jdk&lt;/code&gt;. Since the name does not contain architecture-related information, you need to put the second one &lt;em&gt;somewhere else&lt;/em&gt;, which implies that without further modifications, macOS won't know it's there. If that second version is only referenced in the &lt;em&gt;build.gradle.kts&lt;/em&gt; file, that certainly does not matter.&lt;/p&gt;

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

&lt;p&gt;This has been quite a ride, hasn't it? Let's recap. Your developer machine is running an operating system that may or may not be able to run apps targeting a different processor architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS on Intel hardware can only run apps made for macOS on Intel&lt;/li&gt;
&lt;li&gt;macOS on Apple Silicon can run ARM64 and Intel apps (through Rosetta 2)&lt;/li&gt;
&lt;li&gt;Windows on Intel/AMD can only run apps made for Intel/AMD&lt;/li&gt;
&lt;li&gt;Windows on ARM can run both Intel/AMD (through emulation) and ARM64 apps natively&lt;/li&gt;
&lt;li&gt;Linux on Intel/AMD can only run apps made for Intel/AMD&lt;/li&gt;
&lt;li&gt;Linux on ARM can natively run ARM64 apps. Running Intel/AMD apps is possible through emulation, but this requires specific setup and may have performance implications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Regardless of the processor architecture, to test software made for a particular operating system, you need a box that runs this OS. That box can either be a physical or virtual one. Virtualization on Windows for Intel/AMD has come a long way. There are quite a few well-known products that allow you to virtualize both Windows and Linux. On Linux, the most common hypervisor is KVM (Kernel-based Virtual Machine). It allows you to easily and seamlessly virtualize both Windows and Linux. What about macOS - can it be virtualized, too? While technically possible, macOS must only be used on an Apple-branded computer.&lt;/p&gt;

&lt;p&gt;On several occasions, I mentioned that, using Parallels Desktop, you can easily test your Compose Desktop app on macOS, Linux and Windows. Now that I have laid the theoretical groundwork, we'll get practical in the next installment of this series.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>jetpackcompose</category>
      <category>multiplatform</category>
    </item>
    <item>
      <title>Android Manifest file checklist</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Sun, 06 Apr 2025 10:02:28 +0000</pubDate>
      <link>https://forem.com/tkuenneth/android-manifest-file-checklist-4ne2</link>
      <guid>https://forem.com/tkuenneth/android-manifest-file-checklist-4ne2</guid>
      <description>&lt;p&gt;Welcome to the fourth article in a series of tips and tricks about Compose Multiplatform. The content is based on a sample app called &lt;a href="https://github.com/tkuenneth/CMP-Unit-Converter" rel="noopener noreferrer"&gt;CMP Unit Converter&lt;/a&gt;. It runs on Android, iOS, and the Desktop. As its name suggests, you can convert between various units and scales. While this may provide some value, the main goal of the app and this series is to show how to use Compose Multiplatform and a couple of other multiplatform libraries while focusing on &lt;strong&gt;platform integration&lt;/strong&gt;. This time, we look at the Android manifest file. &lt;/p&gt;

&lt;p&gt;The &lt;a href="https://kmp.jetbrains.com/" rel="noopener noreferrer"&gt;Kotlin Multiplatform wizard&lt;/a&gt; for sure is a great way to start your projects. The manifest file for a project I created on 2025-04-05 looks like this:&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: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;application&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: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:roundIcon=&lt;/span&gt;&lt;span class="s"&gt;"@mipmap/ic_launcher_round"&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;"@android:style/Theme.Material.Light.NoActionBar"&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:exported=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
    &lt;span class="na"&gt;android:configChanges=&lt;/span&gt;&lt;span class="s"&gt;"orientation|screenSize|
                           screenLayout|keyboardHidden|
                           mnc|colorMode|density|fontScale|
                           fontWeightAdjustment|keyboard|
                           layoutDirection|locale|
                           mcc|navigation|
                           smallestScreenSize|touchscreen|uiMode"&lt;/span&gt;
    &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;".MainActivity"&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;/activity&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;h3&gt;
  
  
  The application
&lt;/h3&gt;

&lt;p&gt;The purpose of a project wizard is to provide only a basic setup. Therefore, the manifest contains only what is absolutely necessary. Consequently, there are no entries for permissions, further activities, services, broadcast receivers, and content providers. All these are, by the way, called &lt;a href="https://developer.android.com/guide/topics/manifest/manifest-intro#components" rel="noopener noreferrer"&gt;App components&lt;/a&gt; in the &lt;em&gt;App manifest overview&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;☑ Add required permissions and app components&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.android.com/guide/topics/manifest/application-element" rel="noopener noreferrer"&gt;application element&lt;/a&gt; contains&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the declaration of the application. This element contains subelements that declare each of the application's components and has attributes that can affect all the components.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of its attributes, &lt;code&gt;android:enableOnBackInvokedCallback&lt;/code&gt; (which can also be specified for an activity) is as of 2025-04-05 not set by the Kotlin Multiplatform wizard. The flag lets you opt out of predictive system animations at the activity level, which is something you should &lt;strong&gt;not&lt;/strong&gt; do. Therefore:&lt;/p&gt;

&lt;p&gt;☑ Set &lt;code&gt;android:enableOnBackInvokedCallback&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;An attribute you should consider adding is &lt;code&gt;android:appCategory&lt;/code&gt;. It declares the &lt;a href="https://developer.android.com/guide/topics/manifest/application-element#appCategory" rel="noopener noreferrer"&gt;category&lt;/a&gt; of your app. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Categories are used to cluster multiple apps together into meaningful groups, such as when summarizing battery, network, or disk usage. Only define this value for apps that fit well into one of the specific categories.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;☑ Consider adding &lt;code&gt;android:appCategory&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Did you know there is the &lt;code&gt;android:hasFragileUserData&lt;/code&gt; &lt;a href="https://developer.android.com/guide/topics/manifest/application-element#fragileuserdata" rel="noopener noreferrer"&gt;attribute&lt;/a&gt;? It controls whether&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;to show the user a prompt to keep the app's data when the user uninstalls the app. The default value is &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, I am not advocating to set it. But I feel it should be on a checklist.&lt;/p&gt;

&lt;p&gt;☑ Carefully consider adding &lt;code&gt;android:hasFragileUserData&lt;/code&gt; (you probably should not add it, after all)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;android:theme&lt;/code&gt; is a&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;reference to a style resource defining a default theme for all activities in the application. Individual activities can override the default by setting their own &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element#theme" rel="noopener noreferrer"&gt;theme&lt;/a&gt; attributes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As of 2025-04-05, the Kotlin Multiplatform wizard sets it to &lt;code&gt;@android:style/Theme.Material.Light.NoActionBar&lt;/code&gt;. If you run your Compose Multiplatform app, everything appears to work fine, including toggling between light and dark mode. However, there's a subtle issue. To understand which, let's remind ourselves what &lt;code&gt;android:theme="@android:style/Theme.Material.Light.NoActionBar"&lt;/code&gt; does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It sets the initial theme for your application (or activity) to light mode.&lt;/li&gt;
&lt;li&gt;It makes sure there is no action bar. Having no action bar is likely what you want because a common Compose pattern is to have a &lt;code&gt;Scaffold()&lt;/code&gt; with a top app bar. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The theme setting affects the activity window and how Android draws the initial UI before your Compose code or Android &lt;code&gt;View&lt;/code&gt;s are inflated. A part of this initial user interface is the splash screen. Putting &lt;code&gt;android:theme="@android:style/Theme.Material.Light.NoActionBar"&lt;/code&gt; in your manifest means the splash screen background will always appear light, regardless if the system is in light or dark mode. Since modern Android apps certainly should support light and dark mode, I encourage you to change the theme. &lt;/p&gt;

&lt;p&gt;While there is no &lt;code&gt;Theme.Material.DayNight.NoActionBar&lt;/code&gt;, you could use &lt;code&gt;Theme.DeviceDefault.DayNight&lt;/code&gt; but would also need to remove the action bar (for example in a custom theme with &lt;code&gt;Theme.DeviceDefault.DayNight&lt;/code&gt; as its parent). This feels unnecessarily complicated. The easiest way is to include &lt;a href="https://developer.android.com/jetpack/androidx/releases/appcompat" rel="noopener noreferrer"&gt;Jetpack Appcompat&lt;/a&gt; and use &lt;code&gt;android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"&lt;/code&gt;. While this works exactly as expected, please keep in mind that any library contributes to the overall app size.&lt;/p&gt;

&lt;p&gt;Here's one final aspect: Do you need to use activities solely based on &lt;code&gt;View&lt;/code&gt;s? Such activities may be your own or provided by a third party library like scanner or proprietary sign-in components. They deserve a proper theme treatment. That is, not being tied to light mode.&lt;/p&gt;

&lt;p&gt;☑ Set a theme that supports light and dark mode in &lt;code&gt;android:theme&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Activities
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element" rel="noopener noreferrer"&gt;activity element&lt;/a&gt; declares&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;an activity (an &lt;a href="https://developer.android.com/reference/android/app/Activity" rel="noopener noreferrer"&gt;Activity&lt;/a&gt; subclass) that implements part of the application's visual user interface. All activities must be represented by &lt;code&gt;&amp;lt;activity&amp;gt;&lt;/code&gt; elements in the manifest file. Any that aren't declared there aren't seen by the system and never run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of its attributes, &lt;code&gt;android:windowSoftInputMode&lt;/code&gt;, is as of 2025-04-05 not set by the Kotlin Multiplatform wizard. The &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element#wsoft" rel="noopener noreferrer"&gt;attribute&lt;/a&gt; controls how&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the main window of the activity interacts with the window containing the on-screen soft keyboard.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are two things to consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should the soft keyboard be visible when the activity becomes the focus of user attention?&lt;/li&gt;
&lt;li&gt;Should the activity window become smaller to make room for the soft keyboard?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A couple of values control these aspects. They can be combined: &lt;code&gt;stateUnspecified&lt;/code&gt;, &lt;code&gt;stateUnchanged&lt;/code&gt;, &lt;code&gt;stateHidden&lt;/code&gt;, &lt;code&gt;stateAlwaysHidden&lt;/code&gt;, &lt;code&gt;stateVisible&lt;/code&gt;, &lt;code&gt;stateAlwaysVisible&lt;/code&gt;, &lt;code&gt;adjustUnspecified&lt;/code&gt;, &lt;code&gt;adjustResize&lt;/code&gt;, &lt;code&gt;adjustPan&lt;/code&gt;, &lt;code&gt;adjustNothing&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stateUnspecified&lt;/code&gt; is the default setting for the behavior of the soft keyboard. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Whether the soft keyboard is hidden or visible isn't specified. The system chooses an appropriate state or relies on the setting in the theme.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;adjustUnspecified&lt;/code&gt; is the default setting for the behavior of the main window.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Whether the activity's main window resizes to make room for the soft keyboard or the contents of the window pan to make the current focus visible on-screen is unspecified. The system automatically selects one of these modes depending on whether the content of the window has any layout views that can scroll their contents. If there is such a view, the window resizes, on the assumption that scrolling can make all of the window's contents visible within a smaller area.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both defaults sound like a good choice for many apps. Therefore, it seems safe to just omit &lt;code&gt;android:windowSoftInputMode&lt;/code&gt;. However, these mechanics had been designed for a &lt;code&gt;View&lt;/code&gt;-based world. So, what does &lt;strong&gt;&lt;em&gt;The system automatically selects one of these modes depending on whether the content of the window has any layout views that can scroll their contents&lt;/em&gt;&lt;/strong&gt; mean in a Jetpack Compose app? Please recall that &lt;code&gt;setContent {}&lt;/code&gt; just creates a &lt;code&gt;ComposeView&lt;/code&gt;, which hosts the Compose hierarchy. In the document &lt;a href="https://developer.android.com/develop/ui/compose/layouts/insets" rel="noopener noreferrer"&gt;Display content edge-to-edge in your app and handle window insets in Compose&lt;/a&gt;, Google suggests to add &lt;code&gt;android:windowSoftInputMode="adjustResize"&lt;/code&gt; to the manifest because this&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;setting allows your app to receive the size of the software IME as insets, which you can use to pad and lay out content appropriately when the IME appears and disappears in your app&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;em&gt;CMP Unit Converter&lt;/em&gt;, I have added this manifest attribute and made sure the content is scrollable. This results in a very nice user experience even on a smartphone in landscape mode, where space is notoriously scarce.&lt;/p&gt;

&lt;p&gt;☑ Add &lt;code&gt;android:windowSoftInputMode&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The last item on my checklist is likely going to be controversial. &lt;a href="https://developer.android.com/guide/topics/manifest/activity-element#config" rel="noopener noreferrer"&gt;android:configChanges&lt;/a&gt; lists configuration changes an activity wants to handle itself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a configuration change occurs at runtime, the activity shuts down and restarts by default, but declaring a configuration with this attribute prevents the activity from restarting. Instead, the activity remains running and its &lt;a href="https://developer.android.com/reference/android/app/Activity#onConfigurationChanged(android.content.res.Configuration)" rel="noopener noreferrer"&gt;onConfigurationChanged()&lt;/a&gt; method is called.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As of 2025-04-05, the documentation lists 18 values; the Kotlin Multiplatform wizard requests to handle 17 through &lt;code&gt;onConfigurationChanged()&lt;/code&gt;. The only one not being requested is &lt;code&gt;grammaticalGender&lt;/code&gt; (which was introduced with API level 34). Here's a question: Why handling &lt;code&gt;locale&lt;/code&gt; but not &lt;code&gt;grammaticalGender&lt;/code&gt;? This omission may likely have been just a glitch, but it begs an important additional question: Why this long list?&lt;/p&gt;

&lt;p&gt;The documentation clearly says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: Use this attribute only in special cases to improve application performance and responsiveness. For more information, see &lt;a href="https://developer.android.com/guide/topics/resources/runtime-changes" rel="noopener noreferrer"&gt;Handle configuration changes&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;strong&gt;Handle configuration changes&lt;/strong&gt; document is a must-read. Don't just take a glimpse, don't just browse through it. Read it. Read it again. Read it once more. Then, and only then, ask yourself which configuration changes you really want to opt out from. Below are some quotes from this document I find particularly important:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: &lt;code&gt;Activity&lt;/code&gt; recreation due to configuration changes is only one of the cases in which the system might destroy an &lt;code&gt;Activity&lt;/code&gt; and recreate it later. For more information, read about the &lt;a href="https://developer.android.com/guide/components/activities/intro-activities#mtal" rel="noopener noreferrer"&gt;Activity lifecycle&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This means, your app needs proper lifecycle management anyway. We have established patterns for this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Even when you disable activity recreation for a given configuration change, the change itself continues to occur. Disabling &lt;code&gt;Activity&lt;/code&gt; recreation transfers the responsibility of handling that configuration change to the &lt;code&gt;Activity&lt;/code&gt;. If you disable &lt;code&gt;Activity&lt;/code&gt; recreation, your app must appropriately handle the change when it does occur.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Section &lt;a href="https://developer.android.com/guide/topics/resources/runtime-changes#react-changes-compose" rel="noopener noreferrer"&gt;React to configuration changes in Jetpack Compose&lt;/a&gt; starts with&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Jetpack Compose lets your app more easily react to configuration changes. However, if you disable &lt;code&gt;Activity&lt;/code&gt; recreation for all configuration changes where it is possible to do so, your app still must correctly handle configuration changes. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So, how should &lt;code&gt;android:configChanges&lt;/code&gt; look like? First, please make sure to also have read and understood paragraphs &lt;strong&gt;Avoid opting out as a quick fix&lt;/strong&gt; and &lt;strong&gt;Don't avoid configuration changes&lt;/strong&gt;. Now, there certainly is no definitive answer. But I firmly believe that a list as long as the one the Kotlin Multiplatform wizard creates, requires a lot of careful consideration. If in doubt, opt out from less, and let the system handle configuration changes by recreating the activity.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;Now on Android&lt;/em&gt; app &lt;a href="https://github.com/android/nowinandroid" rel="noopener noreferrer"&gt;source code&lt;/a&gt; is often cited as a great resource for learning how to do things. So, to conclude this, let's peek into its manifest file and seek inspiration.&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%2Fbba7w7namtgg5rqkyf4k.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%2Fbba7w7namtgg5rqkyf4k.png" alt="Screenshot of the manifest file as of 202504-06" width="800" height="581"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's not many, right?&lt;/p&gt;

&lt;p&gt;☑ Carefully consider which values to set in &lt;code&gt;android:configChanges&lt;/code&gt;&lt;/p&gt;

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

&lt;p&gt;I would like to point out that I am neither saying nor implying that the long list the Kotlin Multiplatform wizard uses is wrong. What I definitively advocate is to carefully consider how the manifest file of your Compose Multiplatform app should look like. It's your obligation to make sure its content is correct. No wizard can provide more than a humble suggestion.&lt;/p&gt;

&lt;p&gt;I do hope you find this checklist useful. Kindly share your thoughts in the comments.&lt;/p&gt;

</description>
      <category>cmp</category>
      <category>kmp</category>
      <category>android</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Open the default browser across platforms</title>
      <dc:creator>Thomas Künneth</dc:creator>
      <pubDate>Sat, 29 Mar 2025 11:18:43 +0000</pubDate>
      <link>https://forem.com/tkuenneth/open-the-default-browser-across-platforms-3cbh</link>
      <guid>https://forem.com/tkuenneth/open-the-default-browser-across-platforms-3cbh</guid>
      <description>&lt;p&gt;Welcome to the third article in a series of tips and tricks about Compose Multiplatform. The content is based on a sample app called &lt;a href="https://github.com/tkuenneth/CMP-Unit-Converter" rel="noopener noreferrer"&gt;CMP Unit Converter&lt;/a&gt;. It runs on Android, iOS, and the Desktop. As its name suggests, you can convert between various units. While this may provide some value, the main goal is to show how to use Compose Multiplatform and a couple of other multiplatform libraries, focusing on platform integration. This time, we will be looking at &lt;strong&gt;opening the default browser across platforms&lt;/strong&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%2Fd222evddajbug9lv9kk4.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%2Fd222evddajbug9lv9kk4.png" alt="CMP Unit Converter running on a foldable" width="800" height="705"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So, why would you want to do that? Well, one obvious reason is that it's part of the app's purpose. For example, &lt;em&gt;CMP Unit Converter&lt;/em&gt; shows some supporting information at the right hand side of the converter area. Which unit or scale was last selected? What's the essence of that unit or scale? To learn more, the user can get all insights about the unit or scale by clicking the &lt;em&gt;Read more on Wikipedia&lt;/em&gt; button.&lt;/p&gt;

&lt;p&gt;At this point, the app has two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Show the web page inside the app&lt;/li&gt;
&lt;li&gt;Let the default browser do its job&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Certainly, embedding the browser into the app offers a very cohesive experience. However, what happens if the user has more browsers installed? How do we handle navigation inside the browser? Are we sure the user &lt;em&gt;wants&lt;/em&gt; to read the web page inside the app? A lot of apps try to solve this by adding a &lt;em&gt;Show web pages inside the app&lt;/em&gt; toggle. But is that really necessary? Why not keeping things simple and just letting the app do its job that was made for showing web content?&lt;/p&gt;

&lt;h3&gt;
  
  
  Fire and forget
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;CMP Unit Converter&lt;/em&gt; does not interact with the web page. It relies on a simple fire and forget scenario. Let's define a simple &lt;code&gt;expect&lt;/code&gt; function in &lt;em&gt;commonMain&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="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are passing the url as a &lt;code&gt;String&lt;/code&gt; because it is easily usable on all platforms. If needed, more specific objects can be created from it by platform-specific code. &lt;code&gt;completionHandler&lt;/code&gt; is something I borrowed from iOS. So, let's look at the corresponding implementation.&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;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&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="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="nc"&gt;NSURL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;URLWithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&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="nc"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
      &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emptyMap&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt;
      &lt;span class="n"&gt;completionHandler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;completionHandler&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;openURL()&lt;/code&gt; is an asynchronous operation; the function returns immediately. Once the asynchronous task is finished (either successfully or not), the completion handler will be invoked. &lt;code&gt;options&lt;/code&gt; allows you to configure how the url will be opened. For a list of possible keys to include in this map, see &lt;a href="https://developer.apple.com/documentation/uikit/uiapplication/openexternalurloptionskey" rel="noopener noreferrer"&gt;UIApplication.OpenExternalURLOptionsKey&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Having a completion handler that tells us whether opening the web page was successful certainly is more than fire and forget, but on the other hand we want to notify our users if something went wrong. So let's see how we achieve this on Android.&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;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&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="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="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&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;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="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_VIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&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;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ActivityNotFoundException&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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 we don't know which app will be reacting upon &lt;code&gt;Intent.ACTION_VIEW&lt;/code&gt;, we need to use &lt;code&gt;startActivity()&lt;/code&gt; which really means &lt;em&gt;fire and forget&lt;/em&gt;. The only thing we can and should do is catch &lt;code&gt;ActivityNotFoundException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Finally, let's look at the Desktop.&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;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;openInBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&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="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="nf"&gt;browse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;completionHandler&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I maintain a file called &lt;em&gt;DesktopHelpers.kt&lt;/em&gt; in &lt;em&gt;desktopMain&lt;/em&gt; which heavily uses &lt;code&gt;java.awt.Desktop&lt;/code&gt;. Sadly, this class has never been particularly well-known. It was first introduced all the way back in Java Platform, Standard Edition 6, and received major additions in Java 9.&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;browse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&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="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="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Desktop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDesktop&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;result&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;isSupported&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Desktop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BROWSE&lt;/span&gt;&lt;span class="p"&gt;))&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="nf"&gt;browse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;URI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SecurityException&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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;Using Desktop features always consists of these steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Get a &lt;code&gt;Desktop&lt;/code&gt; instance by using &lt;code&gt;Desktop.getDesktop()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Check if the required function is available using &lt;code&gt;isSupported()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Invoke the function&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, the code snippet above invokes &lt;code&gt;completionHandler()&lt;/code&gt; with a &lt;code&gt;true&lt;/code&gt; value if &lt;code&gt;Desktop.Action.BROWSE&lt;/code&gt; is a supported action and &lt;code&gt;browse(URI.create(url))&lt;/code&gt; does not throw an exception.&lt;/p&gt;

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

&lt;p&gt;Besides &lt;code&gt;Desktop.Action.BROWSE&lt;/code&gt; there are a few other actions available. For example, you can invoke an email client and set &lt;em&gt;Preferences&lt;/em&gt;, as well as, &lt;em&gt;About&lt;/em&gt; handlers. I might return to this in future parts of this series. &lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>kmp</category>
      <category>cmp</category>
    </item>
  </channel>
</rss>
