<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Robin</title>
    <description>The latest articles on DEV Community by Robin (@robingenz).</description>
    <link>https://dev.to/robingenz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F366375%2F4d5117ce-2de2-42d6-9fc7-af64a6c61139.jpg</url>
      <title>DEV Community: Robin</title>
      <link>https://dev.to/robingenz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/robingenz"/>
    <language>en</language>
    <item>
      <title>Google Play DEX Code Optimization in Capacitor Apps</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Sat, 10 Oct 2026 15:30:20 +0000</pubDate>
      <link>https://dev.to/capawesome/google-play-dex-code-optimization-in-capacitor-apps-5k</link>
      <guid>https://dev.to/capawesome/google-play-dex-code-optimization-in-capacitor-apps-5k</guid>
      <description>&lt;p&gt;If Play Console shows a "DEX code optimization" finding for your Capacitor app, the Android project template is the cause. From February 2027, Google Play requires at least 25% obfuscation, optimization and shrinking for every app with more than 10 MB of DEX code, and the Capacitor Android template ships with R8 turned off. In this post, we walk through the fix: two lines in &lt;code&gt;build.gradle&lt;/code&gt;, a short &lt;code&gt;proguard-rules.pro&lt;/code&gt;, and one check before you upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Capacitor 8 Android app&lt;/li&gt;
&lt;li&gt;Android Gradle Plugin (AGP) 8.x&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below comes from our own production app at Capawesome, which uses about 40 plugins and runs on Capacitor 8.5.0 with AGP 8.13.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Play's DEX optimization requirement
&lt;/h2&gt;

&lt;p&gt;Google Play measures three percentages for every app bundle you upload: obfuscation, optimization and shrinking. A Capacitor app built from the default template scores close to zero on all three. The Android project that &lt;code&gt;npx cap add android&lt;/code&gt; generates sets &lt;code&gt;minifyEnabled false&lt;/code&gt; in the release build type, so R8 never touches your DEX code. For our app, Play Console reported 1% obfuscation.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://support.google.com/googleplay/android-developer/answer/17492799" rel="noopener noreferrer"&gt;Play Console technical quality requirements&lt;/a&gt; say that from February 2027, apps need at least 25% in each category, and that "not meeting a requirement can affect an app's visibility and publishing capabilities on Google Play". The thresholds apply to apps with more than 10 MB of DEX code and to games with more than 50 MB. Capacitor, Firebase and Play Services dependencies put a plugin-heavy app over the 10 MB line.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.android.com/google/play/vitals/code-optimization" rel="noopener noreferrer"&gt;Android vitals page on DEX code optimization&lt;/a&gt; explains that for apps built with AGP 8.10 or higher, Play reads them from the &lt;code&gt;r8.json&lt;/code&gt; file inside the bundle. Any shrinker counts, and R8 is the one every Android project already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable R8
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;android/app/build.gradle&lt;/code&gt;, set &lt;code&gt;minifyEnabled true&lt;/code&gt; in the release build type and switch the default rules file to the optimizing variant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;android&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;minifyEnabled&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="n"&gt;proguardFiles&lt;/span&gt; &lt;span class="nf"&gt;getDefaultProguardFile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'proguard-android-optimize.txt'&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'proguard-rules.pro'&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template points to &lt;code&gt;proguard-android.txt&lt;/code&gt;. That file contains &lt;code&gt;-dontoptimize&lt;/code&gt;, which blocks most of what R8 could do, and AGP 9 no longer accepts it by default. We covered that build failure in &lt;a href="https://capawesome.io/blog/how-to-fix-capacitor-plugin-build-errors-with-agp-9/" rel="noopener noreferrer"&gt;How to Fix Capacitor Plugin Build Errors with AGP 9&lt;/a&gt;, so switching now avoids it later.&lt;/p&gt;

&lt;p&gt;R8 has run in full mode by default &lt;a href="https://developer.android.com/build/releases/past-releases/agp-8-0-0-release-notes" rel="noopener noreferrer"&gt;since AGP 8.0&lt;/a&gt;. That detail is behind the keep rule in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules you need
&lt;/h2&gt;

&lt;p&gt;On top of the rules that Capacitor, AGP and the large SDKs contribute on their own, a Capacitor app needs these lines in &lt;code&gt;android/app/proguard-rules.pro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Capacitor annotations: required in R8 full mode (ionic-team/capacitor#8589)
-keep @interface com.getcapacitor.** { *; }

# Plugin-specific rules from each plugin's README
-keep class io.capawesome.capacitorjs.plugins.** { *; }

# Optional backends that plugins probe for at runtime (see missing_rules.txt)
-dontwarn io.requery.android.database.**
-dontwarn net.zetetic.database.**
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first rule is the one every Capacitor app needs, and we'll explain it below. The second line is an example of a plugin-specific rule. Most Capacitor plugins ship no consumer rules, and a plugin that relies on reflection or serialization documents its rule in its README. The &lt;a href="https://capawesome.io/docs/sdks/capacitor/sqlite/" rel="noopener noreferrer"&gt;Capacitor SQLite plugin&lt;/a&gt; does exactly that for the &lt;code&gt;io.capawesome.capacitorjs.plugins&lt;/code&gt; package. The &lt;code&gt;-dontwarn&lt;/code&gt; lines depend on which plugins you use; the "Missing class errors" section shows how to find yours.&lt;/p&gt;

&lt;p&gt;The rest is already handled for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capacitor's consumer rules&lt;/strong&gt;: &lt;code&gt;@capacitor/android&lt;/code&gt; declares its own &lt;code&gt;proguard-rules.pro&lt;/code&gt; as consumer rules. They keep every class annotated with &lt;code&gt;@CapacitorPlugin&lt;/code&gt;, its public &lt;code&gt;@PluginMethod&lt;/code&gt; methods, and its &lt;code&gt;@PermissionCallback&lt;/code&gt; and &lt;code&gt;@ActivityCallback&lt;/code&gt; methods, even private ones. Capacitor resolves those callbacks by method name at runtime, so permission requests and activity results survive R8 without any rule of yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AGP's defaults&lt;/strong&gt;: they keep &lt;code&gt;@JavascriptInterface&lt;/code&gt; methods, which is how Capacitor's &lt;code&gt;MessageHandler&lt;/code&gt; receives calls from JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large SDKs&lt;/strong&gt;: Firebase and Play Billing ship consumer rules of their own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the keep rule
&lt;/h2&gt;

&lt;p&gt;Without the &lt;code&gt;@interface&lt;/code&gt; rule, the build passes and the app crashes on startup. Capacitor's consumer rules keep the classes annotated with &lt;code&gt;@CapacitorPlugin&lt;/code&gt;, but not the annotation types themselves. Here is what the first launch of our minified build looked like in logcat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL EXCEPTION: CapacitorPlugins
java.lang.RuntimeException: java.lang.reflect.InvocationTargetException
Caused by: java.lang.NullPointerException
    at com.getcapacitor.Plugin.getPermissionStates
    at com.getcapacitor.Plugin.checkPermissions
    at ...FirebaseMessagingPlugin.checkPermissions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the Android documentation on &lt;a href="https://developer.android.com/topic/performance/app-optimization/full-mode" rel="noopener noreferrer"&gt;R8 full mode&lt;/a&gt;, R8 strips annotations from classes that are not explicitly kept. Once the &lt;code&gt;CapacitorPlugin&lt;/code&gt; annotation is gone, &lt;code&gt;getAnnotation(CapacitorPlugin.class)&lt;/code&gt; can only return &lt;code&gt;null&lt;/code&gt;. R8 then removes the &lt;code&gt;pluginAnnotation&lt;/code&gt; field from &lt;code&gt;PluginHandle&lt;/code&gt;, and &lt;code&gt;Bridge.getPermissionStates()&lt;/code&gt; dereferences &lt;code&gt;null&lt;/code&gt; the first time any plugin checks its permissions. All plugins still register, because registration only needs the class.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-keep @interface com.getcapacitor.** { *; }&lt;/code&gt; rule keeps every annotation type in the package. That covers &lt;code&gt;@CapacitorPlugin&lt;/code&gt;, &lt;code&gt;@Permission&lt;/code&gt;, &lt;code&gt;@PermissionCallback&lt;/code&gt;, &lt;code&gt;@ActivityCallback&lt;/code&gt;, &lt;code&gt;@PluginMethod&lt;/code&gt; and the legacy &lt;code&gt;@NativePlugin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Capacitor does not ship this rule in its consumer rules yet. &lt;a href="https://github.com/ionic-team/capacitor/issues/8589" rel="noopener noreferrer"&gt;Issue #8589&lt;/a&gt; describes the same behavior with the same workaround and is open as of this writing. Capacitor 8.5.2 merged &lt;a href="https://github.com/ionic-team/capacitor/pull/8400" rel="noopener noreferrer"&gt;a null check in &lt;code&gt;getPermissionStates()&lt;/code&gt;&lt;/a&gt;, so on 8.5.2 the app no longer crashes. Instead, &lt;code&gt;checkPermissions()&lt;/code&gt; returns an empty permission map. You still need the keep rule on 8.5.2, because the null check only hides the symptom.&lt;/p&gt;

&lt;p&gt;You could also avoid the crash by disabling full mode with &lt;code&gt;android.enableR8.fullMode=false&lt;/code&gt; in &lt;code&gt;gradle.properties&lt;/code&gt;. That costs you optimization across the whole app and all its dependencies, which is the exact number Play measures. The keep rule is the cheaper fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Missing class errors
&lt;/h2&gt;

&lt;p&gt;R8 fails the build with "Missing class" errors when a plugin was compiled against a library your app does not bundle. Some Capacitor plugins do this on purpose. The Capacitor SQLite plugin compiles against both SQLCipher (&lt;code&gt;net.zetetic.database.**&lt;/code&gt;) and requery's &lt;code&gt;sqlite-android&lt;/code&gt; (&lt;code&gt;io.requery.android.database.**&lt;/code&gt;) as &lt;code&gt;compileOnly&lt;/code&gt; dependencies, and bundles only the one you select in &lt;code&gt;variables.gradle&lt;/code&gt;. At runtime it probes for them with &lt;code&gt;Class.forName&lt;/code&gt;. The missing classes are expected, but R8 can't know that, so the two &lt;code&gt;-dontwarn&lt;/code&gt; lines above tell it.&lt;/p&gt;

&lt;p&gt;Whenever R8 reports missing classes, it writes a ready-to-paste file to &lt;code&gt;android/app/build/outputs/mapping/release/missing_rules.txt&lt;/code&gt;. Read each entry before you paste it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;-dontwarn&lt;/code&gt; for an optional backend that the plugin checks for at runtime is correct.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;-dontwarn&lt;/code&gt; for a class a plugin calls unconditionally hides a real &lt;code&gt;NoClassDefFoundError&lt;/code&gt; until a user hits it. The right fix there is to add the dependency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verify locally
&lt;/h2&gt;

&lt;p&gt;Debug builds never run R8 with this configuration, so the release build is the only one that tells you whether your rules are complete. It also has to be signed before you can install it. The signing properties that Android Studio injects into Gradle let you sign a local release build with the debug keystore, without touching &lt;code&gt;build.gradle&lt;/code&gt;:&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;cd &lt;/span&gt;android
./gradlew assembleRelease &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-Pandroid&lt;/span&gt;.injected.signing.store.file&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.android/debug.keystore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-Pandroid&lt;/span&gt;.injected.signing.store.password&lt;span class="o"&gt;=&lt;/span&gt;android &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-Pandroid&lt;/span&gt;.injected.signing.key.alias&lt;span class="o"&gt;=&lt;/span&gt;androiddebugkey &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-Pandroid&lt;/span&gt;.injected.signing.key.password&lt;span class="o"&gt;=&lt;/span&gt;android
adb &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; app/build/outputs/apk/release/app-release.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the build installed, run three checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch logcat&lt;/strong&gt; for &lt;code&gt;FATAL EXCEPTION&lt;/code&gt;, &lt;code&gt;ClassNotFoundException&lt;/code&gt; and &lt;code&gt;NoSuchMethodException&lt;/code&gt; while the app starts. Then run every flow that requests a permission or waits for another activity, such as the camera, a file picker or a settings screen. The annotation crash shows up within the first second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open &lt;code&gt;mapping.txt&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;android/app/build/outputs/mapping/release/&lt;/code&gt; and confirm that your plugin classes still appear under their original names. A renamed plugin class means a keep rule is missing. Archive this file with every release, because you'll need it for production stack traces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the Play numbers&lt;/strong&gt; from the bundle before you upload it. Build it with &lt;code&gt;./gradlew bundleRelease&lt;/code&gt; and run &lt;code&gt;unzip -p app/build/outputs/bundle/release/app-release.aab BUNDLE-METADATA/com.android.tools/r8.json&lt;/code&gt;. Play Console shows the same percentages for that upload in the app bundle explorer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Our advice: enable R8 now, not when the February 2027 deadline arrives. The first minified build of a plugin-heavy Capacitor app surfaces every missing rule at once, and you want that to happen on an emulator, not in a Play release. Treat the release build as a standing test target. Run your permission and activity-result flows on it before every upload, and check &lt;code&gt;missing_rules.txt&lt;/code&gt; again whenever you add a plugin. For the other Android build pitfalls Capacitor apps run into, see &lt;a href="https://capawesome.io/blog/troubleshooting-capacitor-android-issues/" rel="noopener noreferrer"&gt;The Android Troubleshooting Guide for Capacitor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/blog/google-play-dex-code-optimization-in-capacitor-apps/" rel="noopener noreferrer"&gt;full post on the Capawesome blog&lt;/a&gt; also has an FAQ on mapping files for Play and Crashlytics and on resource shrinking, and if you have questions, drop them in the comments below.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ionic</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Add Geofencing to an Ionic App</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Fri, 09 Oct 2026 15:30:05 +0000</pubDate>
      <link>https://dev.to/capawesome/how-to-add-geofencing-to-an-ionic-app-1ca1</link>
      <guid>https://dev.to/capawesome/how-to-add-geofencing-to-an-ionic-app-1ca1</guid>
      <description>&lt;p&gt;In this tutorial, we build a store reminder page in Ionic Angular: switch on a toggle for a store, and when you later walk within 200 meters of it, your phone reminds you to open your shopping list, even if the app was closed hours ago. You will learn how to request background location in two steps, register and remove geofences, show a native notification per geofence and read the transitions your app missed.&lt;/p&gt;

&lt;p&gt;Most Ionic geofencing tutorials still use &lt;code&gt;cordova-plugin-geofence&lt;/code&gt;, whose last release (0.7.0) dates from 2017. We use a Capacitor plugin built on the native region monitoring of Android and iOS instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An Ionic Angular app on Capacitor 8 with standalone components.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/" rel="noopener noreferrer"&gt;Capacitor Geofences plugin&lt;/a&gt;. Full transparency: we build this plugin at Capawesome. It is part of &lt;a href="https://capawesome.io/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;, a paid subscription. For setup, see the &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section of the docs.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ACCESS_BACKGROUND_LOCATION&lt;/code&gt; permission on Android and the two location usage strings on iOS (see below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All plugin logic lives in one &lt;code&gt;GeofenceService&lt;/code&gt; with signals. The plugin calls are plain TypeScript, so they work in Ionic React or Vue too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform setup
&lt;/h2&gt;

&lt;p&gt;The plugin needs no &lt;code&gt;capacitor.config.ts&lt;/code&gt; keys. On Android, it already declares &lt;code&gt;ACCESS_FINE_LOCATION&lt;/code&gt;, &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; and &lt;code&gt;RECEIVE_BOOT_COMPLETED&lt;/code&gt;, and you add background location yourself in &lt;code&gt;android/app/src/main/AndroidManifest.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is left to you because Google Play reviews it: &lt;a href="https://support.google.com/googleplay/android-developer/answer/9799150" rel="noopener noreferrer"&gt;the background location policy&lt;/a&gt; requires it to serve core functionality, a Permissions Declaration Form in the Play Console, and a prominent in-app disclosure before the request. For our page, that's a short screen before the first toggle triggers the permission flow.&lt;/p&gt;

&lt;p&gt;On iOS, add both usage descriptions to &lt;code&gt;ios/App/App/Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;NSLocationWhenInUseUsageDescription
The app needs access to your location to remind you when you are near a store.
NSLocationAlwaysAndWhenInUseUsageDescription
The app needs access to your location to remind you when you are near a store, even while it is closed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per Apple's &lt;a href="https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services" rel="noopener noreferrer"&gt;authorization guide&lt;/a&gt;, "authorization requests fail immediately if the required keys aren't present", and the plugin rejects with an explicit error in that case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request permissions
&lt;/h2&gt;

&lt;p&gt;Geofencing needs background location (Always on iOS). Both platforms expect it as a second step, so we call &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#requestpermissions" rel="noopener noreferrer"&gt;&lt;code&gt;requestPermissions(...)&lt;/code&gt;&lt;/a&gt; first with &lt;code&gt;location&lt;/code&gt; and then with &lt;code&gt;backgroundLocation&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;backgroundLocation&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backgroundLocation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications&lt;/span&gt;&lt;span class="dl"&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="kc"&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;From Android 11, the &lt;a href="https://developer.android.com/develop/sensors-and-location/location/permissions/background" rel="noopener noreferrer"&gt;background permission dialog&lt;/a&gt; no longer offers the option, so the plugin opens the location settings where the user picks "Allow all the time". On iOS, it shows the prompt that upgrades to Always, and Apple notes you "can make the request only once". The third call covers notifications, required on Android 13+.&lt;/p&gt;

&lt;p&gt;Ask in context, when the user switches on the first toggle. With only foreground location, &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#addgeofences" rel="noopener noreferrer"&gt;&lt;code&gt;addGeofences(...)&lt;/code&gt;&lt;/a&gt; rejects with &lt;code&gt;PERMISSION_DENIED&lt;/code&gt;. Once &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#checkpermissions" rel="noopener noreferrer"&gt;&lt;code&gt;checkPermissions()&lt;/code&gt;&lt;/a&gt; reports &lt;code&gt;denied&lt;/code&gt;, a button calling &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#opensettings" rel="noopener noreferrer"&gt;&lt;code&gt;openSettings()&lt;/code&gt;&lt;/a&gt; is the way back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Register a geofence
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;addGeofences(...)&lt;/code&gt; hands circular regions to the operating system, each with an &lt;code&gt;id&lt;/code&gt;, a center and a &lt;code&gt;radius&lt;/code&gt; in meters. Here is the service with a &lt;code&gt;Store&lt;/code&gt; model, &lt;code&gt;add(...)&lt;/code&gt; and a notification helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GeofenceTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-geofences&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;providedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GeofenceService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;activeIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;lastTransition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addGeofences&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;geofences&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;notifyOnEnter&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="na"&gt;notifyOnExit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reminderFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ids&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="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;reminderFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are near &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open your shopping list before you go in.&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// requestPermissions() from the previous section&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using the store's own &lt;code&gt;id&lt;/code&gt; links the geofence to your data. Enter and exit both default to &lt;code&gt;true&lt;/code&gt;; we only want arrivals. Android adds dwell via &lt;code&gt;androidNotifyOnDwell&lt;/code&gt; and &lt;code&gt;androidLoiteringDelay&lt;/code&gt;, iOS has none. Geofences never expire, so you remove them yourself.&lt;/p&gt;

&lt;p&gt;Why 200 meters? Apple's &lt;a href="https://developer.apple.com/documentation/corelocation/monitoring-the-user-s-proximity-to-geographic-regions" rel="noopener noreferrer"&gt;region monitoring sample&lt;/a&gt; uses it, and Apple's archived guide assumes "approximately 200 meters" as the minimum distance for testing purposes. Android's &lt;a href="https://developer.android.com/develop/sensors-and-location/location/geofencing" rel="noopener noreferrer"&gt;geofencing guide&lt;/a&gt; suggests at least 100 to 150 meters. At the top end, iOS reports a monitoring failure for regions above &lt;code&gt;maximumRegionMonitoringDistance&lt;/code&gt;, so the plugin clamps the radius to that maximum.&lt;/p&gt;

&lt;p&gt;The OS caps the count at 20 regions per app on iOS and 100 on Android. Beyond that, &lt;code&gt;addGeofences(...)&lt;/code&gt; rejects with &lt;code&gt;GEOFENCE_LIMIT_EXCEEDED&lt;/code&gt;, so register the nearest stores and swap them as the user moves. Also, Android fires an enter transition right away if the device is already inside, while iOS waits for a boundary crossing. &lt;a href="https://capawesome.io/blog/why-are-your-geofences-not-triggering-on-ios/" rel="noopener noreferrer"&gt;Why Are Your Geofences Not Triggering on iOS?&lt;/a&gt; explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle transitions
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#addlistenergeofencetransition-" rel="noopener noreferrer"&gt;&lt;code&gt;geofenceTransition&lt;/code&gt;&lt;/a&gt; event delivers each transition under &lt;code&gt;event.transition&lt;/code&gt;, with a &lt;code&gt;geofenceId&lt;/code&gt;, a &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;'ENTER'&lt;/code&gt;, &lt;code&gt;'EXIT'&lt;/code&gt; or &lt;code&gt;'DWELL'&lt;/code&gt;) and a &lt;code&gt;timestamp&lt;/code&gt;. &lt;code&gt;latitude&lt;/code&gt; and &lt;code&gt;longitude&lt;/code&gt; are always &lt;code&gt;null&lt;/code&gt; on iOS, so look stores up by &lt;code&gt;geofenceId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The event only fires while the app is running with the listener attached, and background transitions are not replayed. Use it to update the open page, not for anything the user must not miss. The listener is registered in &lt;code&gt;start()&lt;/code&gt; below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notify the user
&lt;/h2&gt;

&lt;p&gt;The per-geofence &lt;code&gt;notification&lt;/code&gt; (our &lt;code&gt;reminderFor(...)&lt;/code&gt;) is how you reach a user whose app is closed. The operating system shows it on a transition even when the app is in the background or not running. The text is fixed at registration and shows on exits too, which is another reason &lt;code&gt;notifyOnExit&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Skip &lt;code&gt;@capacitor/local-notifications&lt;/code&gt; here: a notification scheduled from the listener can't cover the closed app and might duplicate the plugin's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queue missed transitions
&lt;/h2&gt;

&lt;p&gt;The transition queue is off until you call &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#setconfig" rel="noopener noreferrer"&gt;&lt;code&gt;setConfig(...)&lt;/code&gt;&lt;/a&gt; with &lt;code&gt;maxSize&lt;/code&gt;. Then the plugin records every transition on the device whether or not your app runs. The queue survives restarts and reboots, holds 1,000 entries by default and drops the oldest when full.&lt;/p&gt;

&lt;p&gt;The rest of the service enables the queue, listens, drains with &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#getqueuedtransitions" rel="noopener noreferrer"&gt;&lt;code&gt;getQueuedTransitions(...)&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#deletequeuedtransitions" rel="noopener noreferrer"&gt;&lt;code&gt;deleteQueuedTransitions(...)&lt;/code&gt;&lt;/a&gt;, and restores and removes geofences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;maxSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;geofenceTransition&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastTransition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drainQueue&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visibilitychange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;visibilityState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visible&lt;/span&gt;&lt;span class="dl"&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drainQueue&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;async&lt;/span&gt; &lt;span class="nf"&gt;drainQueue&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hasMore&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasMore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getQueuedTransitions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteQueuedTransitions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;upToId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transitions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;hasMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasMore&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;async&lt;/span&gt; &lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;geofences&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getGeofences&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;geofence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;geofence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Geofences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeGeofences&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;activeId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;activeId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;id&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;persist(...)&lt;/code&gt; is your own storage or API call. Since reading and deleting are separate, a crash in between keeps the transitions queued, and &lt;code&gt;upToId&lt;/code&gt; deletes everything up to that &lt;code&gt;id&lt;/code&gt;. Live transitions are queued too, so the queue is your source of truth.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setConfig(...)&lt;/code&gt; replaces the whole config, so if you add a native upload &lt;code&gt;url&lt;/code&gt; later, pass it with &lt;code&gt;maxSize&lt;/code&gt; (&lt;a href="https://capawesome.io/blog/announcing-the-capacitor-geofences-plugin/#upload-transitions-to-your-server" rel="noopener noreferrer"&gt;details in the announcement post&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Remove geofences
&lt;/h2&gt;

&lt;p&gt;Geofences persist across launches, so &lt;code&gt;restore()&lt;/code&gt; reads the toggle state from &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#getgeofences" rel="noopener noreferrer"&gt;&lt;code&gt;getGeofences()&lt;/code&gt;&lt;/a&gt;. On Android, the plugin re-registers them after a reboot or app update; on iOS, the OS persists the regions. A toggle off calls &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#removegeofences" rel="noopener noreferrer"&gt;&lt;code&gt;removeGeofences(...)&lt;/code&gt;&lt;/a&gt;, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/#removeallgeofences" rel="noopener noreferrer"&gt;&lt;code&gt;removeAllGeofences()&lt;/code&gt;&lt;/a&gt; clears everything on sign-out.&lt;/p&gt;

&lt;p&gt;The standalone page renders one toggle per store and the last live transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OnInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;IonContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonNote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonToggle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;IonToolbar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ionic/angular/standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GeofenceService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./geofence.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-stores&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;IonContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonItem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonNote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonToggle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IonToolbar&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`


        Store reminders




        @for (store of stores; track store.id) {


              {{ store.name }}


        }

      @if (geofenceService.lastTransition(); as transition) {
        {{ transition.type }} at {{ transition.geofenceId }}
      }

  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StoresPage&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;OnInit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;geofenceService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GeofenceService&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;stores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Store&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;store-berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Berlin Mitte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;52.52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;13.405&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;store-cupertino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cupertino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;37.33182&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;122.03118&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;ngOnInit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geofenceService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geofenceService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&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="nx"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geofenceService&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="nx"&gt;store&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="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;geofenceService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real app, &lt;code&gt;stores&lt;/code&gt; comes from your API, and &lt;code&gt;start()&lt;/code&gt; runs once at app startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Test with real movement before shipping: install a build on a phone, register a store a few hundred meters away, close the app and walk across the boundary. Simulators confirm permissions and that &lt;code&gt;getGeofences()&lt;/code&gt; returns your regions, but delivery timing only shows on a device. If the reminder works on Android but not on iPhone, the iOS troubleshooting post above lists the causes.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/blog/how-to-add-geofencing-to-an-ionic-app/" rel="noopener noreferrer"&gt;full post on the Capawesome blog&lt;/a&gt; includes an FAQ on closed apps, region limits and React or Vue, and if you have questions, ask in the comments.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>angular</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Reading Step Counts on iOS and Android with Capacitor</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:30:11 +0000</pubDate>
      <link>https://dev.to/capawesome/reading-step-counts-on-ios-and-android-with-capacitor-5bgo</link>
      <guid>https://dev.to/capawesome/reading-step-counts-on-ios-and-android-with-capacitor-5bgo</guid>
      <description>&lt;p&gt;Showing a user's daily step count sounds like a one-liner: read the step records for today and add them up. In practice that number comes out higher than the one in the Health app, because the phone, a watch and fitness apps all record the same walk. In this guide, you'll see how to read step counts in a Capacitor app from Apple HealthKit and Health Connect, get today's total and a 7-day chart, and avoid counting steps twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Capacitor 8 app.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/" rel="noopener noreferrer"&gt;Capacitor Health plugin&lt;/a&gt;. Full disclosure: we build this plugin at Capawesome, and it's part of &lt;a href="https://capawesome.io/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;, a paid subscription. For setup, see the &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt;: &lt;code&gt;android.permission.health.READ_STEPS&lt;/code&gt; declared in your own &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, the privacy policy &lt;code&gt;meta-data&lt;/code&gt; entry from the plugin docs, and &lt;code&gt;minSdkVersion&lt;/code&gt; set to 26.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt;: the HealthKit capability enabled and &lt;code&gt;NSHealthShareUsageDescription&lt;/code&gt; in &lt;code&gt;Info.plist&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where steps come from
&lt;/h2&gt;

&lt;p&gt;On iOS, steps live in Apple HealthKit; on Android, in Health Connect. Both hold individual samples, each with a time range, a value and the app or device that wrote it. Apple's &lt;a href="https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/stepcount" rel="noopener noreferrer"&gt;&lt;code&gt;stepCount&lt;/code&gt; documentation&lt;/a&gt; notes that "the system automatically records samples on iPhone and Apple Watch", and a running app can add its own samples for the same walk. So your morning commute may sit in the store two or three times.&lt;/p&gt;

&lt;p&gt;Health Connect ships with Android 14 (API level 34) and later. On Android 9 to 13, it's a separate Play Store app, which matters for the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check availability
&lt;/h2&gt;

&lt;p&gt;Not every device has a health store, so start with &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#isavailable" rel="noopener noreferrer"&gt;&lt;code&gt;isAvailable()&lt;/code&gt;&lt;/a&gt;. It resolves with &lt;code&gt;available&lt;/code&gt; and a &lt;code&gt;reason&lt;/code&gt;, which is &lt;code&gt;null&lt;/code&gt; when the store is ready and otherwise one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;health-connect-not-installed&lt;/code&gt;: Health Connect is missing (Android 9 to 13).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;health-connect-update-required&lt;/code&gt;: the installed Health Connect app is too old.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;not-supported&lt;/code&gt;: no health store at all, for example Android below 9, iPadOS before 17 or visionOS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the first two, &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#installhealthconnect" rel="noopener noreferrer"&gt;&lt;code&gt;installHealthConnect()&lt;/code&gt;&lt;/a&gt; opens the Play Store page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ensureHealthStore&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isAvailable&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="nx"&gt;available&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="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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;health-connect-not-installed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;health-connect-update-required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;installHealthConnect&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="kc"&gt;false&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;After opening the store, check again when the user comes back, and hide the step feature completely for &lt;code&gt;not-supported&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request step permission
&lt;/h2&gt;

&lt;p&gt;Once the native setup from the prerequisites is in place, request read access for &lt;code&gt;DataType.Steps&lt;/code&gt; with &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#requestpermissions" rel="noopener noreferrer"&gt;&lt;code&gt;requestPermissions(...)&lt;/code&gt;&lt;/a&gt;. On iOS, Apple warns that "your app will crash when you request authorization" without the usage keys, so the plugin checks for them and rejects with an error instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestStepAccess&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;read&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 two platforms report the result differently.&lt;/p&gt;

&lt;p&gt;On Android, &lt;code&gt;requestPermissions(...)&lt;/code&gt; returns &lt;code&gt;denied&lt;/code&gt; right after a rejection, while &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#checkpermissions" rel="noopener noreferrer"&gt;&lt;code&gt;checkPermissions(...)&lt;/code&gt;&lt;/a&gt; only reports &lt;code&gt;granted&lt;/code&gt; or &lt;code&gt;prompt&lt;/code&gt;. After two denials, Health Connect ignores further requests, so show a button that calls &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#opensettings" rel="noopener noreferrer"&gt;&lt;code&gt;openSettings()&lt;/code&gt;&lt;/a&gt; once you see &lt;code&gt;denied&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On iOS, a read permission is never reported as &lt;code&gt;granted&lt;/code&gt;. You get &lt;code&gt;prompt&lt;/code&gt; before the first request and &lt;code&gt;unknown&lt;/code&gt; afterwards, because HealthKit hides read decisions for privacy. &lt;a href="https://developer.apple.com/documentation/healthkit/hkhealthstore/authorizationstatus%28for:%29" rel="noopener noreferrer"&gt;Apple's &lt;code&gt;authorizationStatus(for:)&lt;/code&gt; documentation&lt;/a&gt; says a denied read looks as if "there is no data of the requested type in the HealthKit store." So request access, run the query, and treat an empty result as "no data or no access".&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's total
&lt;/h2&gt;

&lt;p&gt;Today's step count is one &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#aggregate" rel="noopener noreferrer"&gt;&lt;code&gt;aggregate(...)&lt;/code&gt;&lt;/a&gt; call from local midnight until now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getTodaySteps&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="nx"&gt;startOfToday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;startOfToday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startOfToday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;day&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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;setHours(0, 0, 0, 0)&lt;/code&gt; sets midnight in the device's time zone. &lt;code&gt;toISOString()&lt;/code&gt; writes UTC, so in Berlin during summer time the start of October 8 becomes &lt;code&gt;2026-10-07T22:00:00.000Z&lt;/code&gt;, which is the same instant.&lt;/p&gt;

&lt;p&gt;You get one bucket with one value per operation, as a plain number in &lt;code&gt;count&lt;/code&gt;. The value is &lt;code&gt;null&lt;/code&gt; when there's no data. Keep &lt;code&gt;null&lt;/code&gt; apart from &lt;code&gt;0&lt;/code&gt; in your UI, since on iOS &lt;code&gt;null&lt;/code&gt; can also mean the user denied access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Daily and weekly buckets
&lt;/h2&gt;

&lt;p&gt;For a 7-day chart, keep the &lt;code&gt;day&lt;/code&gt; bucket and start at local midnight six days ago. Buckets align to &lt;code&gt;startDate&lt;/code&gt;, and &lt;code&gt;day&lt;/code&gt;, &lt;code&gt;week&lt;/code&gt; and &lt;code&gt;month&lt;/code&gt; follow the device's time zone. If you start at "now minus seven days" (as the README sample does), a call at 14:37 gives buckets from 14:37 to 14:37 that mix two calendar days. Starting at midnight fixes that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getWeekChartData&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buckets&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;day&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;operations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sum&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;buckets&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="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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;setDate()&lt;/code&gt; works in local time, so the start stays at midnight across a daylight saving change, which subtracting &lt;code&gt;6 * 24&lt;/code&gt; hours would not. For longer views, switch to &lt;code&gt;week&lt;/code&gt; or &lt;code&gt;month&lt;/code&gt; and start at midnight on the first day of your week or month.&lt;/p&gt;

&lt;p&gt;Steps support only &lt;code&gt;sum&lt;/code&gt;. Asking for &lt;code&gt;average&lt;/code&gt;, &lt;code&gt;maximum&lt;/code&gt; or &lt;code&gt;minimum&lt;/code&gt; rejects with &lt;code&gt;INVALID_AGGREGATION&lt;/code&gt;, so compute a daily average from the &lt;code&gt;day&lt;/code&gt; buckets yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The double-counting trap
&lt;/h2&gt;

&lt;p&gt;Summing the records from &lt;a href="https://capawesome.io/docs/sdks/capacitor/health/#readrecords" rel="noopener noreferrer"&gt;&lt;code&gt;readRecords(...)&lt;/code&gt;&lt;/a&gt; is the tempting shortcut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sumStepRecords&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="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readRecords&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;endDate&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="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&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;For someone with an iPhone and an Apple Watch, both devices write samples for the same minutes, and this function counts them twice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aggregate(...)&lt;/code&gt; avoids that because the platform does the math. On iOS, HealthKit computes totals with statistics queries, and &lt;a href="https://developer.apple.com/documentation/healthkit/hkstatistics" rel="noopener noreferrer"&gt;Apple's &lt;code&gt;HKStatistics&lt;/code&gt; documentation&lt;/a&gt; describes the default:&lt;/p&gt;

&lt;p&gt;&amp;gt; By default, these queries automatically merge the data from all of your data sources before performing the calculations.&lt;/p&gt;

&lt;p&gt;On Android, the &lt;a href="https://developer.android.com/health-and-fitness/guides/health-connect/develop/aggregate-data" rel="noopener noreferrer"&gt;Health Connect aggregate data guide&lt;/a&gt; (last updated September 23, 2026) states that "the Aggregate API accounts for any duplicate data and keeps only the data from the app with the highest priority." Steps are an Activity type, which that rule covers, and only the user can change the priority order.&lt;/p&gt;

&lt;p&gt;Raw records are still useful for showing where steps came from. Each record has &lt;code&gt;sourceBundleId&lt;/code&gt; and &lt;code&gt;sourceName&lt;/code&gt; (always &lt;code&gt;null&lt;/code&gt; on Android):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getStepsBySource&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="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readRecords&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceBundleId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;totals&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;Show these as what each source recorded, never as parts of a total: they won't add up to the &lt;code&gt;aggregate(...)&lt;/code&gt; result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;History on Android&lt;/strong&gt;: Health Connect allows reads from up to 30 days before the first permission grant. Google's &lt;a href="https://developer.android.com/health-and-fitness/guides/health-connect/develop/read-data#read-older-data" rel="noopener noreferrer"&gt;read data guide&lt;/a&gt; (last updated September 24, 2026) names &lt;code&gt;READ_HEALTH_DATA_HISTORY&lt;/code&gt; for older data. Support is planned in the plugin; until then, keep Android ranges inside that window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No background reads&lt;/strong&gt;: the plugin has no background or observer API, so data refreshes when the user opens the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device steps on Android 14+&lt;/strong&gt;: with SDK Extension 20 or higher, Health Connect counts steps on the device once any app holds &lt;code&gt;READ_STEPS&lt;/code&gt;. &lt;code&gt;aggregate(...)&lt;/code&gt; includes them, and since a June 2026 update they're credited to a device-specific package name instead of &lt;code&gt;android&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;aggregate(...)&lt;/code&gt; for every step number you display (today's total, a weekly chart, a monthly goal) and start each range at local midnight. Reach for &lt;code&gt;readRecords(...)&lt;/code&gt; only when users want to see which device or app recorded their steps.&lt;/p&gt;

&lt;p&gt;The full post on our blog, including an FAQ on Google Fit and older history, is at &lt;a href="https://capawesome.io/blog/how-to-read-step-counts-in-a-capacitor-app/" rel="noopener noreferrer"&gt;How to read step counts in a Capacitor app&lt;/a&gt;, and if you have questions, drop them in the comments.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>mobile</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <item>
      <title>How to Fix AdMob Banner Overlap in a Capacitor App</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:30:04 +0000</pubDate>
      <link>https://dev.to/capawesome/how-to-fix-admob-banner-overlap-in-a-capacitor-app-443k</link>
      <guid>https://dev.to/capawesome/how-to-fix-admob-banner-overlap-in-a-capacitor-app-443k</guid>
      <description>&lt;p&gt;You add an AdMob banner to your Capacitor app, run it on a device, and the Ionic tab bar is gone, hidden under the ad. In this guide, we'll look at why the banner covers your content and fix it two ways: by letting the plugin shrink the web view, or by reserving the exact banner height in CSS. We'll also cover the Ionic CSS for tab bars, content and floating action buttons, and what changes if you come from &lt;code&gt;@capacitor-community/admob&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Capacitor 8 app, ideally with Ionic Framework.&lt;/li&gt;
&lt;li&gt;Consent gathered and the Google Mobile Ads SDK initialized. The &lt;a href="https://capawesome.io/blog/how-to-handle-admob-gdpr-consent-in-a-capacitor-app/" rel="noopener noreferrer"&gt;AdMob GDPR consent guide&lt;/a&gt; walks through that part.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/" rel="noopener noreferrer"&gt;Capacitor AdMob plugin&lt;/a&gt;. For setup, see the &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section of the docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full transparency: we build this plugin at Capawesome, and it is part of &lt;a href="https://capawesome.io/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;, a paid subscription. If you need a free option, &lt;code&gt;@capacitor-community/admob&lt;/code&gt; is a maintained, MIT-licensed alternative, and the last section compares the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the banner overlaps
&lt;/h2&gt;

&lt;p&gt;The Google Mobile Ads SDK renders the banner as a native view on top of the Capacitor web view. Your HTML is laid out as if the banner did not exist, so the &lt;code&gt;ion-tab-bar&lt;/code&gt; slides under a bottom banner, a floating action button ends up behind it, or the last list row never scrolls into view.&lt;/p&gt;

&lt;p&gt;This is an old problem. Issue &lt;a href="https://github.com/capacitor-community/admob/issues/2" rel="noopener noreferrer"&gt;#2 "Ad overlapping tabs"&lt;/a&gt; on the community plugin dates back to July 2020, and edge-to-edge layouts made it worse, as &lt;a href="https://github.com/capacitor-community/admob/issues/390" rel="noopener noreferrer"&gt;#390&lt;/a&gt; from November 2025 shows for Android 15 and 16.&lt;/p&gt;

&lt;p&gt;Hardcoding &lt;code&gt;padding-bottom: 50px&lt;/code&gt; doesn't work either, because the default adaptive banner has no fixed height. Google's &lt;a href="https://developers.google.com/admob/ios/banner" rel="noopener noreferrer"&gt;iOS banner guide&lt;/a&gt; caps anchored adaptive banners at 20% of the screen height with a 150px maximum, and the Android guide describes a 50 to 150 dp range. The plugin defaults to &lt;code&gt;BannerSize.AdaptiveBanner&lt;/code&gt; and leaves the height to the SDK, so the only reliable number is the one the SDK reports after it picks a size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resize the web view
&lt;/h2&gt;

&lt;p&gt;The fix with zero CSS is &lt;code&gt;mode: 'resize'&lt;/code&gt;. Pass it to &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#showbanner" rel="noopener noreferrer"&gt;&lt;code&gt;showBanner(...)&lt;/code&gt;&lt;/a&gt; and the plugin shrinks the web view so the banner never overlaps your web content. Your page still lays itself out at full height; the page is a bit shorter.&lt;/p&gt;

&lt;p&gt;It works for top banners as well as bottom ones. Here the banner sits above an Ionic header, and we keep the returned &lt;code&gt;id&lt;/code&gt; for later calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BannerSize&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-admob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;topBannerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showTopBanner&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showBanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;adUnitId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ca-app-pub-3940256099942544/6300978111&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BannerSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AdaptiveBanner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;topBannerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&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;That ad unit is Google's Android test banner. On iOS, use &lt;code&gt;ca-app-pub-3940256099942544/2934735716&lt;/code&gt; during development.&lt;/p&gt;

&lt;p&gt;Resize mode covers most apps with one anchored banner. Reach for something else when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the banner belongs inside scrolling content (use a &lt;code&gt;frame&lt;/code&gt;; &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#showbanneroptions" rel="noopener noreferrer"&gt;&lt;code&gt;ShowBannerOptions&lt;/code&gt;&lt;/a&gt; ignores &lt;code&gt;position&lt;/code&gt; once a frame is set),&lt;/li&gt;
&lt;li&gt;your layout should continue behind the banner, like a full-screen map or game canvas,&lt;/li&gt;
&lt;li&gt;a changing viewport height makes your CSS or &lt;code&gt;resize&lt;/code&gt; listeners jump visibly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pad in overlay mode
&lt;/h2&gt;

&lt;p&gt;Overlay is the default mode. The web view stays full size, and the banner sits on top of it, anchored to the top or bottom edge and safe-area aware, so it stays clear of the home indicator and gesture bar. Keeping content out from under it is your CSS's job.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#addlistenerbannersizechanged-" rel="noopener noreferrer"&gt;&lt;code&gt;bannerSizeChanged&lt;/code&gt;&lt;/a&gt; event tells you how much space to reserve. Its payload carries the banner &lt;code&gt;id&lt;/code&gt; plus &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; in CSS pixels, so the value goes straight into a CSS custom property with no unit conversion. Register the listener once and before you call &lt;code&gt;showBanner(...)&lt;/code&gt;, otherwise the first size event can arrive before anything is listening. We keep the handle so a second call to &lt;code&gt;showBottomBanner()&lt;/code&gt; doesn't add a second handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PluginListenerHandle&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BannerSize&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-admob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BANNER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom-banner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bannerHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sizeListener&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PluginListenerHandle&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setBannerInset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--admob-banner-height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addBannerSizeListener&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="o"&gt;=&amp;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="nx"&gt;sizeListener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;sizeListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bannerSizeChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;BANNER_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;bannerHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setBannerInset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&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;const&lt;/span&gt; &lt;span class="nx"&gt;showBottomBanner&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addBannerSizeListener&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showBanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BANNER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;adUnitId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ca-app-pub-3940256099942544/6300978111&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BannerSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AdaptiveBanner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overlay&lt;/span&gt;&lt;span class="dl"&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;Why pass our own &lt;code&gt;id&lt;/code&gt;? The listener exists before &lt;code&gt;showBanner(...)&lt;/code&gt; resolves, so it can't compare against a returned identifier yet, but it can compare against a constant. The check also stops a second banner, say an inline one, from overwriting the bottom inset with its own height.&lt;/p&gt;

&lt;p&gt;The property lives on &lt;code&gt;document.documentElement&lt;/code&gt; (the &lt;code&gt;:root&lt;/code&gt; element), so any component can read it with &lt;code&gt;var(--admob-banner-height, 0px)&lt;/code&gt;. The &lt;code&gt;0px&lt;/code&gt; fallback keeps the layout correct on the web and before the first event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ionic layout specifics
&lt;/h2&gt;

&lt;p&gt;Which element gets the banner height depends on what sits at the bottom of your page. With tabs, move the &lt;code&gt;ion-tab-bar&lt;/code&gt; up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;ion-tab-bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--admob-banner-height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0px&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;Don't add &lt;code&gt;env(safe-area-inset-bottom)&lt;/code&gt; or &lt;code&gt;--ion-safe-area-bottom&lt;/code&gt; to that margin. Ionic's tab bar and footer already pad themselves for the bottom inset, and the overlay banner already sits above it, so adding it again makes the tab bar grow by the height of the gesture bar. The same goes for &lt;code&gt;ion-footer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pages without a tab bar or footer need the space inside &lt;code&gt;ion-content&lt;/code&gt;. Its &lt;code&gt;--padding-bottom&lt;/code&gt; extends the scrollable area so the last item can scroll above the banner. A bottom &lt;code&gt;ion-fab&lt;/code&gt; is positioned against &lt;code&gt;ion-content&lt;/code&gt; itself, not its scroll padding, so it needs its own margin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;ion-content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--padding-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--admob-banner-height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;ion-fab&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;vertical&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"bottom"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--admob-banner-height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0px&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 some pages run without ads, scope these rules with a class on the page component. On a tabbed page you can skip the content and FAB rules entirely, because the raised tab bar already shortens the area above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hide, resume, remove
&lt;/h2&gt;

&lt;p&gt;Hiding a banner doesn't reset the space you reserved for it, so the CSS variable has to follow visibility. &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#hidebanner" rel="noopener noreferrer"&gt;&lt;code&gt;hideBanner(...)&lt;/code&gt;&lt;/a&gt; keeps the ad loaded but invisible, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#resumebanner" rel="noopener noreferrer"&gt;&lt;code&gt;resumeBanner(...)&lt;/code&gt;&lt;/a&gt; shows it again. Reusing &lt;code&gt;BANNER_ID&lt;/code&gt;, &lt;code&gt;bannerHeight&lt;/code&gt; and &lt;code&gt;setBannerInset&lt;/code&gt; from above, one function handles both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-admob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setBannerVisible&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="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resumeBanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BANNER_ID&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;setBannerInset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bannerHeight&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Admob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hideBanner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BANNER_ID&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;setBannerInset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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;We restore the last known height on resume instead of waiting for an event, since the README doesn't say that resuming fires &lt;code&gt;bannerSizeChanged&lt;/code&gt;. When a user buys your ad-free tier, call &lt;a href="https://capawesome.io/docs/sdks/capacitor/admob/#removebanner" rel="noopener noreferrer"&gt;&lt;code&gt;removeBanner(...)&lt;/code&gt;&lt;/a&gt;, set the inset to &lt;code&gt;0&lt;/code&gt;, and call &lt;code&gt;remove()&lt;/code&gt; on &lt;code&gt;sizeListener&lt;/code&gt; before clearing it.&lt;/p&gt;

&lt;p&gt;Collapsible banners need no extra code. With &lt;code&gt;collapsible: true&lt;/code&gt;, the banner expands and collapses, firing &lt;code&gt;bannerSizeChanged&lt;/code&gt; each time, and the listener keeps the tab bar or padding in sync in both directions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community plugin comparison
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@capacitor-community/admob&lt;/code&gt; gives the same core advice: the banner is drawn above the WebView, and you pad your content from its &lt;code&gt;bannerAdSizeChanged&lt;/code&gt; event. It's free, while the Capacitor AdMob plugin requires Insiders and is still marked experimental in its README. Here's how they differ:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@capacitor-community/admob&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Capacitor AdMob plugin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Avoiding overlap&lt;/td&gt;
&lt;td&gt;Pad content from the size event, or offset with &lt;code&gt;margin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mode: 'resize'&lt;/code&gt;, or pad content from the size event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offset option&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;margin&lt;/code&gt; in dp (Android) or points (iOS)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Position values&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TOP_CENTER&lt;/code&gt;, &lt;code&gt;CENTER&lt;/code&gt;, &lt;code&gt;BOTTOM_CENTER&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'top'&lt;/code&gt;, &lt;code&gt;'bottom'&lt;/code&gt;, or a &lt;code&gt;frame&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size event&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bannerAdSizeChanged&lt;/code&gt; with &lt;code&gt;{ width, height }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bannerSizeChanged&lt;/code&gt; with &lt;code&gt;{ id, width, height }&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Units&lt;/td&gt;
&lt;td&gt;dp (Android) and points (iOS)&lt;/td&gt;
&lt;td&gt;CSS pixels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banners at once&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;td&gt;Several, each with its own &lt;code&gt;id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android SDK&lt;/td&gt;
&lt;td&gt;Legacy Google Mobile Ads SDK 25.4.x&lt;/td&gt;
&lt;td&gt;Next-Gen Google Mobile Ads SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Capawesome Insiders (paid)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The unit gap is smaller than it looks. The Android WebView sets &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio" rel="noopener noreferrer"&gt;&lt;code&gt;devicePixelRatio&lt;/code&gt;&lt;/a&gt; to the screen density factor, so one CSS pixel covers about one dp, and on iOS one CSS pixel equals one point at default zoom.&lt;/p&gt;

&lt;p&gt;If you migrate, drop the &lt;code&gt;margin&lt;/code&gt; workaround. Setting &lt;code&gt;margin&lt;/code&gt; to the tab bar height is a fixed dp value that ignores edge-to-edge insets (see #390). Use &lt;code&gt;mode: 'resize'&lt;/code&gt; or the &lt;code&gt;ion-tab-bar&lt;/code&gt; rule instead.&lt;/p&gt;

&lt;p&gt;The SDK row matters long term: Google's &lt;a href="https://developers.google.com/admob/android/deprecation" rel="noopener noreferrer"&gt;deprecation schedule&lt;/a&gt; deprecates the legacy Android SDK versions 24 and 25 on June 30, 2027, and sunsets them on June 30, 2028, after which ads are "at risk of not serving".&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;mode: 'resize'&lt;/code&gt; for a single top or bottom banner. Switch to overlay plus &lt;code&gt;bannerSizeChanged&lt;/code&gt; when content has to continue behind the banner or the viewport height must stay constant, and use a &lt;code&gt;frame&lt;/code&gt; when the banner belongs inside your content. Before shipping, test on one device with gesture navigation and one with a notch, since the inset is where most remaining overlap comes from. The &lt;a href="https://capawesome.io/blog/capacitor-edge-to-edge-and-safe-areas-guide/" rel="noopener noreferrer"&gt;edge-to-edge and safe areas guide&lt;/a&gt; covers that CSS.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/blog/how-to-fix-admob-banner-overlap-in-capacitor/" rel="noopener noreferrer"&gt;full post&lt;/a&gt; has the FAQ (including whether the reported height includes the safe-area inset) and the inline placement details, and if you have questions, drop them in the comments.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>mobile</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Downloading Files in the Background in Capacitor</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:30:05 +0000</pubDate>
      <link>https://dev.to/capawesome/downloading-files-in-the-background-in-capacitor-2956</link>
      <guid>https://dev.to/capawesome/downloading-files-in-the-background-in-capacitor-2956</guid>
      <description>&lt;p&gt;If your Capacitor app downloads a large file with &lt;code&gt;fetch&lt;/code&gt;, the download dies as soon as the user switches apps for more than a few seconds. In this post, we look at why that happens, how to hand the download to the native transfer engines of Android and iOS, and what your app sees after the OS kills it or the user force-quits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Capacitor 8 app with Android and/or iOS platforms added.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/" rel="noopener noreferrer"&gt;Capacitor File Transfer plugin&lt;/a&gt;. We build this plugin at Capawesome, and it is part of &lt;a href="https://capawesome.io/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;, a paid subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why fetch stops
&lt;/h2&gt;

&lt;p&gt;A download started with &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt; or any other JavaScript API runs in the web view, inside your app's process. When iOS suspends that process, the download stops with it. Apple's &lt;a href="https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time" rel="noopener noreferrer"&gt;background execution guide&lt;/a&gt; is clear about how fast that happens:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When your app moves to the background, the system calls your app delegate's applicationDidEnterBackground(_:) method. That method has five seconds to perform any tasks and return. Shortly after that method returns, the system puts your app into the suspended state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can request extra time with &lt;code&gt;beginBackgroundTask(...)&lt;/code&gt;, but that time is finite, and Apple warns that "if you don't end your tasks in a timely manner, the system terminates your app." A large video on a mobile connection rarely makes it.&lt;/p&gt;

&lt;p&gt;Background-mode plugins that keep the web view alive go against Apple's rule that "a background app must do as little work as possible, and preferably nothing, because it's offscreen". Even when they work, an OS kill or a force-quit still ends the download together with the app.&lt;/p&gt;

&lt;p&gt;The official plugins don't solve this either. The &lt;a href="https://capacitorjs.com/docs/apis/file-transfer" rel="noopener noreferrer"&gt;&lt;code&gt;@capacitor/file-transfer&lt;/code&gt;&lt;/a&gt; docs (version 2.0.6 on npm) describe &lt;code&gt;downloadFile(...)&lt;/code&gt; as downloading "the file to the specified destination" and say nothing about background or lifecycle behavior. The method returns a promise in JavaScript, so a relaunched app has no reference to a download the previous process started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native transfer engines
&lt;/h2&gt;

&lt;p&gt;The Capacitor File Transfer plugin gives each platform the engine the OS designed for this job.&lt;/p&gt;

&lt;p&gt;On iOS, it uses a background &lt;code&gt;URLSession&lt;/code&gt;. Apple's guide to &lt;a href="https://developer.apple.com/documentation/foundation/downloading-files-in-the-background" rel="noopener noreferrer"&gt;downloading files in the background&lt;/a&gt; explains why this works:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;With background sessions, the actual transfer is performed by a process that is separate from your app's process.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;iOS can suspend your app, or terminate it for memory, without touching the download, and then wakes or relaunches the app in the background to hand over the result. One limit: if your app starts a transfer while it's already in the background, the system &lt;a href="https://developer.apple.com/documentation/foundation/urlsessionconfiguration/isdiscretionary" rel="noopener noreferrer"&gt;treats the session as discretionary&lt;/a&gt; and decides when it runs. Start important downloads in the foreground.&lt;/p&gt;

&lt;p&gt;On Android, the plugin runs transfers in a &lt;code&gt;dataSync&lt;/code&gt; foreground service with its own OkHttp engine. Android's &lt;a href="https://developer.android.com/develop/background-work/services/fgs" rel="noopener noreferrer"&gt;foreground service docs&lt;/a&gt; require a visible notification, "to make users aware that your app is performing a task in the foreground and is consuming system resources."&lt;/p&gt;

&lt;p&gt;Android adds two more rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apps targeting Android 12+ &lt;a href="https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start" rel="noopener noreferrer"&gt;can't start a foreground service&lt;/a&gt; from the background. Since plugin version 0.1.2, a transfer that starts in that situation (for example, after waiting for Wi-Fi) runs without the service and its notification instead of failing.&lt;/li&gt;
&lt;li&gt;Android 15 added a &lt;a href="https://developer.android.com/about/versions/15/behavior-changes-15#datasync-timeout" rel="noopener noreferrer"&gt;&lt;code&gt;dataSync&lt;/code&gt; timeout&lt;/a&gt;: apps targeting API level 35+ get 6 hours per 24-hour period, shared across all their &lt;code&gt;dataSync&lt;/code&gt; services. The timer resets when the user brings the app to the foreground.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start a download
&lt;/h2&gt;

&lt;p&gt;For installation, see the &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section of the plugin docs. Once it's set up, &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#startdownload" rel="noopener noreferrer"&gt;&lt;code&gt;startDownload(...)&lt;/code&gt;&lt;/a&gt; resolves immediately with an ID while the transfer continues in native code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-file-transfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;downloadCoursePack&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startDownload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unmetered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;androidNotification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Downloading course pack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The download continues in the background.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;progress&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;return&lt;/span&gt; &lt;span class="nx"&gt;id&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;Besides &lt;code&gt;url&lt;/code&gt; and &lt;code&gt;path&lt;/code&gt;, you can pass &lt;code&gt;headers&lt;/code&gt; and &lt;code&gt;method&lt;/code&gt;. The background behavior comes from three options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;network&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;'unmetered'&lt;/code&gt; waits for Wi-Fi or a similar network. On Android, the transfer sits in the &lt;code&gt;pending&lt;/code&gt; state meanwhile. Default is &lt;code&gt;'any'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxRetries&lt;/code&gt;&lt;/strong&gt;: how often to retry after a network error, with backoff. Default is &lt;code&gt;0&lt;/code&gt;, which becomes important after an iOS force-quit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;androidNotification&lt;/code&gt;&lt;/strong&gt;: title and text of the foreground service notification. &lt;code&gt;progress: true&lt;/code&gt; adds a per-transfer progress bar, and &lt;code&gt;channelName&lt;/code&gt; defaults to &lt;code&gt;File Transfer&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Downloads are resumable by default if the server supports the HTTP &lt;code&gt;Range&lt;/code&gt; header (see &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#pause-and-resume-a-transfer" rel="noopener noreferrer"&gt;pause and resume&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Listen for results
&lt;/h2&gt;

&lt;p&gt;Since the promise resolves before a single byte arrives, the plugin reports progress and results through events. Register the listeners once at app startup, not on the screen that started the download:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-file-transfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addTransferListeners&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transferProgress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Transfer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transferCompleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Transfer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; saved to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transferFailed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Transfer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#addlistenertransferprogress-" rel="noopener noreferrer"&gt;&lt;code&gt;transferProgress&lt;/code&gt;&lt;/a&gt; fires about every 100 ms with &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;totalBytes&lt;/code&gt; and a &lt;code&gt;progress&lt;/code&gt; value between 0 and 1 (&lt;code&gt;null&lt;/code&gt; if the server sends no size). &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#addlistenertransfercompleted-" rel="noopener noreferrer"&gt;&lt;code&gt;transferCompleted&lt;/code&gt;&lt;/a&gt; carries the &lt;code&gt;path&lt;/code&gt; and HTTP &lt;code&gt;responseCode&lt;/code&gt;, and &lt;code&gt;transferFailed&lt;/code&gt; carries &lt;code&gt;errorCode&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt; and &lt;code&gt;responseCode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The plugin retains completed and failed events that fire while no listener is registered and delivers them once one is added. There's no such guarantee for progress events during suspension, so build your logic on the final events and treat progress as display only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform setup
&lt;/h2&gt;

&lt;p&gt;On iOS, you need one change. When a URL session finishes while your app isn't running, Apple's &lt;a href="https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application%28_:handleeventsforbackgroundurlsession:completionhandler:%29" rel="noopener noreferrer"&gt;&lt;code&gt;application(_:handleEventsForBackgroundURLSession:completionHandler:)&lt;/code&gt;&lt;/a&gt; docs explain that "the system launches your app in the background so that it can process the event." Forward that completion handler to the plugin from an &lt;code&gt;AppDelegate&lt;/code&gt; extension:&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;import&lt;/span&gt; &lt;span class="kt"&gt;Foundation&lt;/span&gt;

&lt;span class="kd"&gt;extension&lt;/span&gt; &lt;span class="kt"&gt;AppDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;func&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;_&lt;/span&gt; &lt;span class="nv"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;handleEventsForBackgroundURLSession&lt;/span&gt; &lt;span class="nv"&gt;identifier&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="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&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;Void&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;NotificationCenter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Notification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.capawesome.capacitorjs.plugins.filetransfer.handleEventsForBackgroundURLSession"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nv"&gt;object&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;Without it, transfers still complete, but iOS may not wake the app to deliver the final events promptly. No &lt;code&gt;Info.plist&lt;/code&gt; entry or background mode is needed.&lt;/p&gt;

&lt;p&gt;On Android, the plugin's own manifest declares the permissions and the &lt;code&gt;dataSync&lt;/code&gt; service. The only runtime step is the notification permission on Android 13+, via &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#requestpermissions" rel="noopener noreferrer"&gt;&lt;code&gt;requestPermissions()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-file-transfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestNotificationPermission&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;notifications&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&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 the user denies it, the notification is hidden but the transfer still runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives what
&lt;/h2&gt;

&lt;p&gt;How far a download gets depends on how the app was closed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App state&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backgrounded&lt;/td&gt;
&lt;td&gt;Keeps running in the &lt;code&gt;dataSync&lt;/code&gt; foreground service.&lt;/td&gt;
&lt;td&gt;Keeps running in the background &lt;code&gt;URLSession&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Killed by the OS for memory&lt;/td&gt;
&lt;td&gt;Interrupted, then restored as &lt;code&gt;failed&lt;/code&gt;; downloads can be resumed.&lt;/td&gt;
&lt;td&gt;Finished by the system and delivered when the app relaunches.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Force-quit by the user&lt;/td&gt;
&lt;td&gt;Same as an OS kill.&lt;/td&gt;
&lt;td&gt;Canceled by the system; retried on relaunch if &lt;code&gt;maxRetries&lt;/code&gt; is above 0, otherwise restored as &lt;code&gt;failed&lt;/code&gt; with a &lt;code&gt;transferFailed&lt;/code&gt; event.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The iOS force-quit row comes straight from Apple's &lt;a href="https://developer.apple.com/documentation/foundation/urlsessionconfiguration/background%28withidentifier:%29" rel="noopener noreferrer"&gt;&lt;code&gt;background(withIdentifier:)&lt;/code&gt;&lt;/a&gt; docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This behavior applies only for normal termination of the app by the system. If the user terminates the app from the multitasking screen, the system cancels all of the session's background transfers. In addition, the system does not automatically relaunch apps that were force quit by the user. The user must explicitly relaunch the app before transfers can begin again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the retry can only happen on relaunch, and only with &lt;code&gt;maxRetries&lt;/code&gt; above 0. On Android, an interrupted download waits in &lt;code&gt;failed&lt;/code&gt; until you call &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#resumetransferbyid" rel="noopener noreferrer"&gt;&lt;code&gt;resumeTransferById(...)&lt;/code&gt;&lt;/a&gt;, which continues from the bytes already on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restore after relaunch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/#gettransfers" rel="noopener noreferrer"&gt;&lt;code&gt;getTransfers()&lt;/code&gt;&lt;/a&gt; returns every transfer the plugin knows about, including ones restored from the previous process. Call it on startup, after registering your listeners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-file-transfer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;restoreDownloads&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;transfers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;FileTransfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTransfers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;transfer&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;download&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;transfer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&lt;/span&gt;&lt;span class="dl"&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;Each &lt;code&gt;Transfer&lt;/code&gt; has its &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;bytes&lt;/code&gt; and &lt;code&gt;totalBytes&lt;/code&gt;, so you don't need your own persistence layer. A &lt;code&gt;running&lt;/code&gt; download keeps reporting progress, a &lt;code&gt;pending&lt;/code&gt; one waits for its network, and a &lt;code&gt;failed&lt;/code&gt; one gets a resume button.&lt;/p&gt;

&lt;p&gt;Listener order matters here. A download that iOS completed after an OS kill produces a &lt;code&gt;transferCompleted&lt;/code&gt; event on relaunch, held until the first listener registers. If you only register listeners on the downloads screen, that event shows up there, possibly long after the file exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Use a native transfer for any download that takes more than a few seconds or that the user should be able to walk away from, and keep &lt;code&gt;fetch&lt;/code&gt; for small requests that finish while the screen is open. Set &lt;code&gt;maxRetries&lt;/code&gt; above 0 for user-started downloads so an iOS force-quit costs a relaunch instead of the download, and call &lt;code&gt;getTransfers()&lt;/code&gt; on every launch so interrupted Android downloads get their resume button.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/blog/how-to-download-files-in-the-background-in-capacitor/" rel="noopener noreferrer"&gt;full post&lt;/a&gt; has an FAQ and more details, and if you have questions, drop them in the comments.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>mobile</category>
      <category>typescript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Capacitor Barcode Scanning Without Google Play Services</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Fri, 02 Oct 2026 15:42:31 +0000</pubDate>
      <link>https://dev.to/capawesome/capacitor-barcode-scanning-without-google-play-services-3d1f</link>
      <guid>https://dev.to/capawesome/capacitor-barcode-scanning-without-google-play-services-3d1f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://capawesome.io/blog/capacitor-barcode-scanning-without-google-play-services/" rel="noopener noreferrer"&gt;Capawesome blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Barcode scanning works on Android devices without Google Play services, as long as the ML Kit model ships inside your app instead of being downloaded through Play services. That makes a Capacitor barcode scanner without Google Play services possible on Huawei phones released after May 2019, Amazon Fire tablets, and AOSP-based kiosk and rugged devices. This guide explains the bundled and unbundled ML Kit models, which methods of the free, open-source &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/" rel="noopener noreferrer"&gt;Capacitor ML Kit Barcode Scanning plugin&lt;/a&gt; rely on Play services, and how the &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/" rel="noopener noreferrer"&gt;Capacitor Barcode Scanner plugin&lt;/a&gt; avoids Play services on every path. The ML Kit plugin is an unofficial plugin maintained by Capawesome and is not affiliated with Google. The Barcode Scanner plugin is paid and part of the Capawesome &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Insiders&lt;/a&gt; subscription.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F50wpzv8k3u58p57rmy5d.png" alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" width="800" height="111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ML Kit barcode scanning comes as a bundled model linked into the app (about 2.4 MB) or an unbundled one downloaded via Google Play services (about 200 KB).&lt;/li&gt;
&lt;li&gt;In the free Capacitor ML Kit Barcode Scanning plugin, &lt;code&gt;startScan()&lt;/code&gt; and &lt;code&gt;readBarcodesFromImage()&lt;/code&gt; use the bundled model, while &lt;code&gt;scan()&lt;/code&gt; runs the Google code scanner and needs Google Play services.&lt;/li&gt;
&lt;li&gt;The Capacitor Barcode Scanner plugin, part of the paid Capawesome Insiders subscription, uses the bundled model for every Android method and AVFoundation plus Vision on iOS.&lt;/li&gt;
&lt;li&gt;The Capacitor Google Play Services plugin's &lt;code&gt;getStatus()&lt;/code&gt; resolves to &lt;code&gt;SERVICE_MISSING&lt;/code&gt; on devices without Play services, so an app can fall back from &lt;code&gt;scan()&lt;/code&gt; to &lt;code&gt;startScan()&lt;/code&gt; at runtime.&lt;/li&gt;
&lt;li&gt;Only Android is affected, since iOS has no Google Play services and the ML Kit plugin's iOS model ships with the &lt;code&gt;GoogleMLKit/BarcodeScanning&lt;/code&gt; pod.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Devices without Play services
&lt;/h2&gt;

&lt;p&gt;Google describes Google Play services as "a core set of on-device background services that run on all Google-certified Android devices" in its &lt;a href="https://developers.google.com/android/guides/overview" rel="noopener noreferrer"&gt;Play services overview&lt;/a&gt;. Several groups of Android devices ship without it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Huawei phones.&lt;/strong&gt; Models released after May 2019 launched without Google Play services (&lt;a href="https://www.androidauthority.com/huawei-google-android-ban-988382/" rel="noopener noreferrer"&gt;Android Authority&lt;/a&gt;) and run Huawei's HMS Core instead. Huawei's own &lt;a href="https://consumer.huawei.com/en/support/content/en-us15841718/" rel="noopener noreferrer"&gt;support article&lt;/a&gt; states that "certain Android apps require support from GMS Core and may not be able to run on HMS Core".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Fire tablets and Fire TV.&lt;/strong&gt; Amazon's &lt;a href="https://developer.amazon.com/docs/app-porting/port-existing-app.html" rel="noopener noreferrer"&gt;porting guide&lt;/a&gt; warns that an existing Android app "might use Google Play services, which isn't supported on Fire OS".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise, kiosk and rugged devices.&lt;/strong&gt; Microsoft Intune offers an &lt;a href="https://learn.microsoft.com/en-us/intune/device-enrollment/android/setup-aosp-corporate-userless" rel="noopener noreferrer"&gt;AOSP management solution&lt;/a&gt; for corporate-owned devices that are "Not integrated with Google Mobile Services". Some rugged and kiosk devices ship AOSP builds without GMS, and Zebra's &lt;a href="https://developer.zebra.com/blog/zebras-gms-restricted-mode" rel="noopener noreferrer"&gt;GMS Restricted mode&lt;/a&gt; can switch Play services off on devices that have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta Quest.&lt;/strong&gt; Meta's &lt;a href="https://developers.meta.com/horizon/documentation/android-apps/unsupported-dependencies/" rel="noopener noreferrer"&gt;Horizon OS documentation&lt;/a&gt; states that "Horizon OS does not include Google Mobile Services, so calls into GMS APIs fail".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mainland China.&lt;/strong&gt; Google's &lt;a href="https://developer.android.com/training/wearables/creating-app-china" rel="noopener noreferrer"&gt;Wear OS guide for China&lt;/a&gt; tells developers to "account for handsets without Google Play services pre-installed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;De-Googled systems.&lt;/strong&gt; On &lt;a href="https://wiki.lineageos.org/gapps/" rel="noopener noreferrer"&gt;LineageOS&lt;/a&gt; and &lt;a href="https://grapheneos.org/usage#sandboxed-google-play" rel="noopener noreferrer"&gt;GrapheneOS&lt;/a&gt;, Google Play is an optional, separate install.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A scanner that depends on Play services breaks on these devices. One example from outside Capacitor is &lt;a href="https://github.com/expo/expo/issues/39449" rel="noopener noreferrer"&gt;expo/expo#39449&lt;/a&gt;, where expo-camera, a React Native library, crashed with an &lt;code&gt;ActivityNotFoundException&lt;/code&gt; on Honor and Huawei devices because it tried to launch Google ML Kit's barcode scanning activity, which those devices don't have. For an enterprise app rolled out to a fleet of AOSP handhelds, these devices can be every install. If you distribute to Huawei users through their own store, &lt;a href="https://capawesome.io/blog/how-to-publish-a-capacitor-app-on-huawei-appgallery/" rel="noopener noreferrer"&gt;Publish a Capacitor App on Huawei AppGallery&lt;/a&gt; covers the release side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundled vs. unbundled model
&lt;/h2&gt;

&lt;p&gt;Capacitor barcode scanning works without Google Play services when the Android side uses the bundled ML Kit model, because that model is statically linked into the app and nothing has to be downloaded through Play services. Google's &lt;a href="https://developers.google.com/ml-kit/vision/barcode-scanning/android" rel="noopener noreferrer"&gt;Barcode Scanning guide for Android&lt;/a&gt; offers the model in two variants, and the last three rows below quote its comparison table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Unbundled&lt;/th&gt;
&lt;th&gt;Bundled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gradle artifact&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.google.android.gms:play-services-mlkit-barcode-scanning&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;com.google.mlkit:barcode-scanning&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Implementation&lt;/td&gt;
&lt;td&gt;Model is dynamically downloaded via Google Play Services.&lt;/td&gt;
&lt;td&gt;Model is statically linked to your app at build time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App size&lt;/td&gt;
&lt;td&gt;About 200 KB size increase.&lt;/td&gt;
&lt;td&gt;About 2.4 MB size increase.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initialization time&lt;/td&gt;
&lt;td&gt;Might have to wait for model to download before first use.&lt;/td&gt;
&lt;td&gt;Model is available immediately.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The unbundled model lives in Play services and is shared between apps. Google states that if the app doesn't request the download ahead of time, "the model is downloaded the first time you run the scanner. Requests you make before the download has completed produce no results." On a device without Play services, there is nothing to download it from. Google's advice on &lt;a href="https://developers.google.com/ml-kit/tips/installation-paths" rel="noopener noreferrer"&gt;choosing between the two&lt;/a&gt; lists full functionality right after installation and offline use as reasons for bundled, and a smaller app plus automatic model updates through Play services as reasons for unbundled.&lt;/p&gt;

&lt;p&gt;The Google code scanner is a third option, a complete scanning screen rather than a model. According to Google's &lt;a href="https://developers.google.com/ml-kit/vision/barcode-scanning/code-scanner" rel="noopener noreferrer"&gt;code scanner guide&lt;/a&gt;, its "implementation resides entirely within Google Play services", and the installation paths page lists it as unbundled only. Google's document scanner is unbundled only as well, which is why the &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/" rel="noopener noreferrer"&gt;Capacitor Document Scanner plugin&lt;/a&gt; requires Play services on Android.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;com.google.mlkit.vision.DEPENDENCIES&lt;/code&gt; meta-data in &lt;code&gt;AndroidManifest.xml&lt;/code&gt; only applies to modules that Play services delivers. It tells Play services to download them while the app is installed from the Play Store, so it has no effect on a bundled model and does nothing on devices without the Play Store.&lt;/p&gt;

&lt;p&gt;To see which variant a plugin uses, read the artifact its &lt;code&gt;android/build.gradle&lt;/code&gt; declares directly. &lt;code&gt;com.google.mlkit:barcode-scanning&lt;/code&gt; is the bundled model, and &lt;code&gt;com.google.android.gms:play-services-mlkit-barcode-scanning&lt;/code&gt; is the unbundled one. The resolved dependency tree won't tell you, because the bundled artifact's own POM depends on &lt;code&gt;play-services-mlkit-barcode-scanning&lt;/code&gt;, so that client library shows up in bundled setups too. A &lt;code&gt;com.google.android.gms&lt;/code&gt; library inside your APK is also not the same thing as the Google Play services app on the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ML Kit plugin
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/" rel="noopener noreferrer"&gt;Capacitor ML Kit Barcode Scanning plugin&lt;/a&gt; declares two Google SDKs in its &lt;a href="https://github.com/capawesome-team/capacitor-mlkit/blob/main/packages/barcode-scanning/android/build.gradle" rel="noopener noreferrer"&gt;Android &lt;code&gt;build.gradle&lt;/code&gt;&lt;/a&gt;, and each method uses one of them. The first, &lt;code&gt;com.google.mlkit:barcode-scanning:17.3.0&lt;/code&gt;, is the bundled model, which the plugin has used since its first release in March 2023. The second, &lt;code&gt;com.google.android.gms:play-services-code-scanner:16.1.0&lt;/code&gt;, is the Google code scanner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#startscan" rel="noopener noreferrer"&gt;&lt;code&gt;startScan(...)&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#readbarcodesfromimage" rel="noopener noreferrer"&gt;&lt;code&gt;readBarcodesFromImage(...)&lt;/code&gt;&lt;/a&gt; run on the bundled model. &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#scan" rel="noopener noreferrer"&gt;&lt;code&gt;scan(...)&lt;/code&gt;&lt;/a&gt; opens the Google code scanner, which the plugin's API reference describes as "only available on devices with Google Play Services installed". Our answers on GitHub confirm the split. Asked in &lt;a href="https://github.com/capawesome-team/capacitor-mlkit/issues/174" rel="noopener noreferrer"&gt;issue #174&lt;/a&gt; whether the plugin supports Huawei devices without Google Play services, we replied: "Yes, Huawei devices are supported but you have to use the &lt;code&gt;startScan(...)&lt;/code&gt; method and build the UI yourself." In &lt;a href="https://github.com/capawesome-team/capacitor-mlkit/issues/219" rel="noopener noreferrer"&gt;issue #219&lt;/a&gt;, a user's scanner module download failed in production with status &lt;code&gt;FAILED = 5&lt;/code&gt;, and we named both artifacts: "&lt;code&gt;com.google.mlkit:barcode-scanning&lt;/code&gt; is used by the &lt;code&gt;startScan(...)&lt;/code&gt; method and is bundled. &lt;code&gt;com.google.android.gms:play-services-code-scanner&lt;/code&gt; is used by the &lt;code&gt;scan(...)&lt;/code&gt; method and cannot be bundled as far as I know."&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;startScan()&lt;/code&gt; session needs the camera permission, a listener for the &lt;code&gt;barcodesScanned&lt;/code&gt; event, and a CSS class that hides the web view elements covering the camera:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor-mlkit/barcode-scanning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startScan&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;camera&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&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="nx"&gt;camera&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;barcode-scanner-active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;barcodesScanned&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;barcodes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startScan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stopScan&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;barcode-scanner-active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAllListeners&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopScan&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;barcode-scanner-active&lt;/code&gt; class is yours to define. &lt;code&gt;startScan()&lt;/code&gt; renders the camera behind the web view, so the class has to make every element between the camera and the user transparent. Version 7 of the plugin replaced the older single-barcode event with &lt;code&gt;barcodesScanned&lt;/code&gt;, which delivers an array in &lt;code&gt;event.barcodes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trade-off is the UI and the permission. With &lt;code&gt;startScan()&lt;/code&gt;, you build the scanning screen and declare the &lt;code&gt;CAMERA&lt;/code&gt; permission yourself. With &lt;code&gt;scan()&lt;/code&gt;, Google provides the screen and your app needs no camera permission on Android, but only on devices with Play services. The ML Kit plugin also parses structured payloads. Each &lt;code&gt;Barcode&lt;/code&gt; carries a &lt;code&gt;valueType&lt;/code&gt; and, depending on the content, fields such as &lt;code&gt;wifi&lt;/code&gt;, &lt;code&gt;contactInfo&lt;/code&gt;, &lt;code&gt;driverLicense&lt;/code&gt; or &lt;code&gt;calendarEvent&lt;/code&gt;, which the Insiders plugin does not offer yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect Play services first
&lt;/h2&gt;

&lt;p&gt;With the &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/" rel="noopener noreferrer"&gt;Capacitor Google Play Services plugin&lt;/a&gt;, an app on the free ML Kit plugin can use &lt;code&gt;scan()&lt;/code&gt; where Play services exists and fall back to &lt;code&gt;startScan()&lt;/code&gt; everywhere else. The plugin is free, Android-only and depends only on the &lt;code&gt;play-services-base&lt;/code&gt; library, whose availability check "works on any Android device, including devices without Google Play Services". &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#getstatus" rel="noopener noreferrer"&gt;&lt;code&gt;getStatus()&lt;/code&gt;&lt;/a&gt; resolves to &lt;code&gt;SERVICE_MISSING&lt;/code&gt; on devices where Play services is not installed at all, which the plugin documentation names for Huawei devices with Huawei Mobile Services, Amazon Fire devices and custom Android builds. The following function picks the scan method from that status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor-mlkit/barcode-scanning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GooglePlayServices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome/capacitor-google-play-services&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scanBarcodeOnAndroid&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;GooglePlayServices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStatus&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="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;startScan&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;barcodes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;barcodes&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;startScan&lt;/code&gt; is the function from the previous section. Run this branch on Android only, since the Google Play Services plugin has no iOS or web implementation. Because &lt;code&gt;scan()&lt;/code&gt; requires Google Play services, make the check before the call rather than reacting to a failed scan. The function falls back for every status other than &lt;code&gt;SUCCESS&lt;/code&gt;. &lt;code&gt;SERVICE_MISSING&lt;/code&gt; is the Huawei and Fire OS case, while &lt;code&gt;SERVICE_DISABLED&lt;/code&gt;, &lt;code&gt;SERVICE_VERSION_UPDATE_REQUIRED&lt;/code&gt; and &lt;code&gt;SERVICE_UPDATING&lt;/code&gt; mean Play services is installed but not usable yet, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#makeavailable" rel="noopener noreferrer"&gt;&lt;code&gt;makeAvailable()&lt;/code&gt;&lt;/a&gt; prompts the user to fix that before you offer &lt;code&gt;scan()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;SUCCESS&lt;/code&gt; status does not guarantee that the scanner module is on the device yet. The ML Kit plugin's documentation recommends &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#isgooglebarcodescannermoduleavailable" rel="noopener noreferrer"&gt;&lt;code&gt;isGoogleBarcodeScannerModuleAvailable()&lt;/code&gt;&lt;/a&gt; as the plugin-level check before &lt;code&gt;scan()&lt;/code&gt; on Android, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#installgooglebarcodescannermodule" rel="noopener noreferrer"&gt;&lt;code&gt;installGoogleBarcodeScannerModule()&lt;/code&gt;&lt;/a&gt; starts the download when the module is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Insiders plugin
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/" rel="noopener noreferrer"&gt;Capacitor Barcode Scanner plugin&lt;/a&gt; uses the bundled ML Kit model for every Android method, including its ready-made scanner UI. Its documentation lists this as a feature: "Uses the bundled ML Kit model, so scanning works offline and without Google Play services." The Android notes add the cost, saying scanning "does not require Google Play services, but the model increases your app size by a few megabytes."&lt;/p&gt;

&lt;p&gt;The fullscreen &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/#scan" rel="noopener noreferrer"&gt;&lt;code&gt;scan(...)&lt;/code&gt;&lt;/a&gt;, the embedded &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/#startscan" rel="noopener noreferrer"&gt;&lt;code&gt;startScan(...)&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/#readbarcodesfromimage" rel="noopener noreferrer"&gt;&lt;code&gt;readBarcodesFromImage(...)&lt;/code&gt;&lt;/a&gt; all run on that model, so there is no Play services branch to write. The scanner UI ships inside the plugin rather than as a Play services module, which is why the plugin's migration table marks &lt;code&gt;isGoogleBarcodeScannerModuleAvailable()&lt;/code&gt; as "Not needed". &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/#isavailable" rel="noopener noreferrer"&gt;&lt;code&gt;isAvailable()&lt;/code&gt;&lt;/a&gt; only checks whether the device has a camera. The plugin declares the camera permission in its own manifest, so neither the &lt;code&gt;DEPENDENCIES&lt;/code&gt; meta-data nor any other manifest change is required. On iOS, the plugin is built on AVFoundation and Vision only, with no third-party pods.&lt;/p&gt;

&lt;p&gt;A fullscreen scan with a themed UI takes one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BarcodeFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-barcode-scanner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scanParcelLabel&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;barcodes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;BarcodeScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;BarcodeFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Code128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BarcodeFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DataMatrix&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;accentColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ff9500&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;beep&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="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hold the label inside the frame.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Scan Parcel&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="nx"&gt;barcodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;rawValue&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;Nothing in this call goes through Play services, so the code path is the same on a Pixel, a Huawei phone and an AOSP handheld. For scanning inside your own layout, &lt;a href="https://capawesome.io/blog/capacitor-embedded-barcode-scanner/" rel="noopener noreferrer"&gt;Embedded Barcode Scanner with Camera Preview in Capacitor&lt;/a&gt; covers the embedded &lt;code&gt;startScan()&lt;/code&gt; mode with a native camera preview. The plugin is part of the paid Capawesome &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Insiders&lt;/a&gt; subscription, and the &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section of the plugin documentation covers the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;p&gt;Choosing between the two plugins depends on whether any device that runs your app lacks Play services, who builds the scanner UI, and whether a subscription fits your project. The three paths compare as follows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Needs Google Play services&lt;/th&gt;
&lt;th&gt;UI&lt;/th&gt;
&lt;th&gt;Camera permission&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ML Kit plugin, &lt;code&gt;startScan()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Your own, behind the web view&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML Kit plugin, &lt;code&gt;scan()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, on Android&lt;/td&gt;
&lt;td&gt;Google code scanner&lt;/td&gt;
&lt;td&gt;Not on Android&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Barcode Scanner plugin, all methods&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Themeable fullscreen UI or embedded native preview&lt;/td&gt;
&lt;td&gt;Yes, declared by the plugin&lt;/td&gt;
&lt;td&gt;Insiders subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both bundled paths make the app larger by the amount in Google's table above. The &lt;code&gt;scan()&lt;/code&gt; path keeps the app smaller, but it depends on Play services and on the module download before its first use. On devices that have Play services, it is also the shortest path, since there is no UI to build. Apps that read Wi-Fi codes, contact cards or driver licenses should factor in the ML Kit plugin's structured payload parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Capacitor barcode scanning work without Google Play services?
&lt;/h3&gt;

&lt;p&gt;Yes, when the Android side uses the bundled ML Kit model, which is linked into the app at build time. The free Capacitor ML Kit Barcode Scanning plugin uses it for &lt;code&gt;startScan()&lt;/code&gt; and &lt;code&gt;readBarcodesFromImage()&lt;/code&gt;, and the Capacitor Barcode Scanner plugin uses it for every method. Only the ML Kit plugin's &lt;code&gt;scan()&lt;/code&gt; needs Google Play services, because it runs the Google code scanner.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the ML Kit plugin work on Huawei devices?
&lt;/h3&gt;

&lt;p&gt;Yes, with &lt;code&gt;startScan()&lt;/code&gt;. Our answer on GitHub is that "Huawei devices are supported but you have to use the &lt;code&gt;startScan(...)&lt;/code&gt; method and build the UI yourself." The ready-made &lt;code&gt;scan()&lt;/code&gt; screen comes from Google Play services, which Huawei phones released after May 2019 don't have.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does the bundled model add to the app size?
&lt;/h3&gt;

&lt;p&gt;Google puts the bundled barcode model at "About 2.4 MB size increase", compared to "About 200 KB size increase" for the unbundled variant. The Capacitor Barcode Scanner plugin's documentation describes the same cost as "a few megabytes".&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need the &lt;code&gt;DEPENDENCIES&lt;/code&gt; meta-data?
&lt;/h3&gt;

&lt;p&gt;Only if you use the ML Kit plugin's &lt;code&gt;scan()&lt;/code&gt;. The &lt;code&gt;barcode_ui&lt;/code&gt; value from the plugin's setup asks Google Play services to download the Google code scanner module while the app is installed from the Play Store; without it, Play services downloads the module the first time the scanner runs. The meta-data has no effect on &lt;code&gt;startScan()&lt;/code&gt; and does nothing on devices without the Play Store. The Capacitor Barcode Scanner plugin needs no manifest changes at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does barcode scanning work offline?
&lt;/h3&gt;

&lt;p&gt;Yes, once the model is on the device. Google's &lt;a href="https://developers.google.com/ml-kit" rel="noopener noreferrer"&gt;ML Kit overview&lt;/a&gt; states that ML Kit processing happens on-device and "also works while offline". A bundled model is available from installation, while the unbundled model and the Google code scanner module have to be downloaded through Play services before their first use. On iOS, both plugins work offline, since the ML Kit iOS SDK comes as a pod with no Play services variant and the Capacitor Barcode Scanner plugin uses AVFoundation and Vision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the Capacitor Barcode Scanner plugin free?
&lt;/h3&gt;

&lt;p&gt;No. It's part of the paid Capawesome &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Insiders&lt;/a&gt; subscription, which covers all Insiders plugins and priority support. The Capacitor ML Kit Barcode Scanning plugin and the Capacitor Google Play Services plugin are free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/capacitor-embedded-barcode-scanner/" rel="noopener noreferrer"&gt;Embedded Barcode Scanner with Camera Preview in Capacitor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/how-to-publish-a-capacitor-app-on-huawei-appgallery/" rel="noopener noreferrer"&gt;Publish a Capacitor App on Huawei AppGallery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/announcing-the-capacitor-document-scanner-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor Document Scanner Plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/announcing-the-capacitor-background-geolocation-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor Background Geolocation Plugin&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try Capawesome Cloud
&lt;/h2&gt;

&lt;p&gt;One Android build can go to both Google Play and Huawei AppGallery through Capawesome Cloud destinations. The &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/" rel="noopener noreferrer"&gt;Huawei AppGallery destination&lt;/a&gt; uploads the build to AppGallery Connect and, depending on the release status, submits it for review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://capawesome.io" rel="noopener noreferrer"&gt;Try Capawesome Cloud Free&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Use the bundled model as soon as one device that runs your app ships without Google Play services. On the free plugin, that means &lt;code&gt;startScan()&lt;/code&gt; with your own UI and a &lt;code&gt;getStatus()&lt;/code&gt; check deciding where &lt;code&gt;scan()&lt;/code&gt; is safe. If you'd rather not build and maintain a scanner screen, the &lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/" rel="noopener noreferrer"&gt;Capacitor Barcode Scanner plugin&lt;/a&gt; gives you the fullscreen UI and the embedded view on the bundled model. Keep &lt;code&gt;scan()&lt;/code&gt; alone only when every device is Google-certified. To ship the same build to Huawei users, read &lt;a href="https://capawesome.io/blog/how-to-publish-a-capacitor-app-on-huawei-appgallery/" rel="noopener noreferrer"&gt;Publish a Capacitor App on Huawei AppGallery&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have any questions, join us on the &lt;a href="https://discord.gg/VCXxSVjefW" rel="noopener noreferrer"&gt;Capawesome Discord server&lt;/a&gt;. To stay updated on the latest news, subscribe to the &lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Capawesome newsletter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>android</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>MapLibre vs. Google Maps: Which Map SDK to Use?</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:30:27 +0000</pubDate>
      <link>https://dev.to/capawesome/maplibre-vs-google-maps-which-map-sdk-to-use-plc</link>
      <guid>https://dev.to/capawesome/maplibre-vs-google-maps-which-map-sdk-to-use-plc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://capawesome.io/blog/maplibre-vs-google-maps/" rel="noopener noreferrer"&gt;Capawesome blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MapLibre is a free, open-source map renderer that draws tiles from a provider you choose, while Google Maps is a data platform billed per SKU once a free monthly cap runs out. That makes MapLibre vs. Google Maps a decision about data more than rendering. Pick Google when your app needs its places, Street View or traffic, and MapLibre when the map shows your own points, routes and areas. This post compares 2026 pricing, the map ID billing trap, tile costs, terms, rendering, offline support and search, with a Capacitor focus on &lt;a href="https://capacitorjs.com/docs/apis/google-maps" rel="noopener noreferrer"&gt;&lt;code&gt;@capacitor/google-maps&lt;/code&gt;&lt;/a&gt; and the free &lt;a href="https://capawesome.io/docs/sdks/capacitor/maplibre/" rel="noopener noreferrer"&gt;Capacitor MapLibre plugin&lt;/a&gt;. The Capacitor MapLibre plugin is built and maintained by Capawesome and is not affiliated with or endorsed by the MapLibre organization.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdhpifss4ot0ay7zgkvz.png" alt="Thousands of teams ship faster with Capawesome Cloud Native Builds and Live Updates" width="800" height="111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Maps Platform has no base fee; since March 1, 2025, each SKU has a free monthly cap of 10,000 (Essentials), 5,000 (Pro) or 1,000 (Enterprise) events.&lt;/li&gt;
&lt;li&gt;Native map loads on Android and iOS are free and unlimited only without a map ID; with one, they bill as Dynamic Maps at $7.00 per 1,000 after 10,000.&lt;/li&gt;
&lt;li&gt;The MapLibre renderer and the Capacitor MapLibre plugin are free; 1.5 million tile requests a month cost $0 on OpenFreeMap and $500 on CARTO's commercial plan.&lt;/li&gt;
&lt;li&gt;Google's terms forbid tile caching and Places content on non-Google maps; EEA terms since July 8, 2025 lift the non-Google-map rule but bar Places API content from any map.&lt;/li&gt;
&lt;li&gt;MapLibre Native supports offline regions through &lt;code&gt;OfflineManager&lt;/code&gt; on Android and &lt;code&gt;MLNOfflineStorage&lt;/code&gt; on iOS, but the Capacitor MapLibre plugin does not expose them yet.&lt;/li&gt;
&lt;li&gt;MapLibre itself has no Street View, traffic layer, indoor data or places database, so the data your app needs decides the choice more than the price does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Renderer vs. platform
&lt;/h2&gt;

&lt;p&gt;MapLibre is a rendering engine without map data of its own. The &lt;a href="https://maplibre.org/" rel="noopener noreferrer"&gt;MapLibre organization&lt;/a&gt; maintains MapLibre GL JS for browsers and MapLibre Native for Android, iOS and desktop, both forked from Mapbox's libraries before Mapbox moved them to a non-open-source license in December 2020. A &lt;a href="https://github.com/maplibre/maplibre/blob/main/CHARTER.md" rel="noopener noreferrer"&gt;charter&lt;/a&gt; governs the project through a five-person board on which no single company may hold more than one seat, and sponsors include Amazon Web Services, Microsoft and MapTiler. A style document following the MapLibre Style Spec decides where the tiles, fonts and sprites come from.&lt;/p&gt;

&lt;p&gt;Google Maps Platform sells three layers under one account: the rendering SDKs (Maps SDK for Android and iOS, Maps JavaScript API), Google's map data, and web services such as Places, Routes and Geocoding. Each of them requires a &lt;a href="https://developers.google.com/maps/get-started" rel="noopener noreferrer"&gt;billing-enabled Cloud project&lt;/a&gt; and an API key, on mobile as well as on the Web.&lt;/p&gt;

&lt;p&gt;The Capacitor plugins mirror that split. &lt;code&gt;@capacitor/google-maps&lt;/code&gt; wraps the Google rendering SDKs and has no API for Street View, Places or Directions, which remain separate Google Maps Platform services. The &lt;a href="https://capawesome.io/docs/sdks/capacitor/maplibre/" rel="noopener noreferrer"&gt;Capacitor MapLibre plugin&lt;/a&gt; wraps MapLibre Native on Android and iOS and MapLibre GL JS on the Web, where &lt;code&gt;maplibre-gl&lt;/code&gt; has been an optional peer dependency since plugin version 0.2.0. With MapLibre, the data source is a separate decision, and most of this comparison is about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Maps pricing
&lt;/h2&gt;

&lt;p&gt;Google Maps Platform has no monthly base fee in 2026. Every billable event, such as a map load or an autocomplete request, belongs to a SKU with a free monthly cap, and you pay a listed price per 1,000 events above that cap. Optional subscriptions from $100 a month swap this for a fixed price, and native mobile map loads without a map ID cost nothing at any volume.&lt;/p&gt;

&lt;p&gt;Since &lt;a href="https://developers.google.com/maps/billing-and-pricing/march-2025" rel="noopener noreferrer"&gt;March 1, 2025&lt;/a&gt;, the free cap depends on the SKU category and replaces the former $200 monthly credit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Essentials: 10,000 events per month&lt;/li&gt;
&lt;li&gt;Pro: 5,000 events per month&lt;/li&gt;
&lt;li&gt;Enterprise: 1,000 events per month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the SKUs a typical map screen triggers, with first-tier prices in USD from Google's &lt;a href="https://developers.google.com/maps/billing-and-pricing/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; as of September 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SKU&lt;/th&gt;
&lt;th&gt;Free per month&lt;/th&gt;
&lt;th&gt;Price per 1,000&lt;/th&gt;
&lt;th&gt;Triggered by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maps SDK&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Native map load without a map ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Maps&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$7.00&lt;/td&gt;
&lt;td&gt;Web map load, native map load with a map ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Street View&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;$14.00&lt;/td&gt;
&lt;td&gt;Street View panorama&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autocomplete Requests&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$2.83&lt;/td&gt;
&lt;td&gt;Search box request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Place Details Essentials / Pro / Enterprise&lt;/td&gt;
&lt;td&gt;10,000 / 5,000 / 1,000&lt;/td&gt;
&lt;td&gt;$5.00 / $17.00 / $20.00&lt;/td&gt;
&lt;td&gt;Place details lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geocoding&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;Address to coordinates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute Routes Essentials&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$5.00&lt;/td&gt;
&lt;td&gt;Route request (Routes API)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zooming and panning do not create extra billable events. Above 100,000 events a month, prices drop in volume tiers that count usage across all projects on a billing account; Dynamic Maps falls to $5.60 per 1,000 up to 500,000 loads. Calculated from these list prices, a month of web map loads costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50,000 loads: $280&lt;/li&gt;
&lt;li&gt;100,000 loads: $630&lt;/li&gt;
&lt;li&gt;250,000 loads: $1,470&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The optional &lt;a href="https://mapsplatform.google.com/resources/blog/announcing-google-maps-platform-subscriptions-lower-costs-with-predictable-pricing/" rel="noopener noreferrer"&gt;subscriptions&lt;/a&gt; launched on November 5, 2025 and put all included SKUs under one combined monthly call limit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starter: $100 per month for 50,000 calls, Dynamic Maps and Geocoding only&lt;/li&gt;
&lt;li&gt;Essentials: $275 per month for 100,000 calls&lt;/li&gt;
&lt;li&gt;Pro: $1,200 per month for 250,000 calls, adding Pro SKUs such as Place Details Pro&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Starter covers the 50,000-load month above for $100 instead of $280. Google does not block calls beyond a plan's limit and &lt;a href="https://developers.google.com/maps/billing-and-pricing/subscriptions" rel="noopener noreferrer"&gt;bills the overage&lt;/a&gt; at pay-as-you-go prices, while free SKUs such as Maps SDK don't count toward it. New customers get a $300 trial credit that expires after 90 days. The &lt;a href="https://developers.google.com/maps/demo-key" rel="noopener noreferrer"&gt;Maps Demo Key&lt;/a&gt; skips billing details, but Google limits it to prototyping and documents it for the Maps JavaScript API and web services only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map ID trap
&lt;/h2&gt;

&lt;p&gt;A map ID moves native map loads from the free Maps SDK SKU to Dynamic Maps. Google's &lt;a href="https://developers.google.com/maps/billing-and-pricing/sku-details" rel="noopener noreferrer"&gt;SKU definition&lt;/a&gt; counts an Android or iOS map load as Maps SDK only when the map is "not loaded with a map ID". &lt;code&gt;@capacitor/google-maps&lt;/code&gt; has accepted &lt;code&gt;androidMapId&lt;/code&gt; and &lt;code&gt;iOSMapId&lt;/code&gt; since version 5.4.0, and cloud-based map styling needs them. With a map ID set, native map loads on that platform draw from the Dynamic Maps cap of 10,000 and cost $7.00 per 1,000 after that.&lt;/p&gt;

&lt;p&gt;For an app with 100,000 native map loads a month, that is the difference between $0 and $630, calculated from the list prices.&lt;/p&gt;

&lt;p&gt;Styling without a map ID goes through the plugin's &lt;code&gt;styles&lt;/code&gt; option, which takes JSON map styles. &lt;a href="https://github.com/ionic-team/capacitor-google-maps/issues/96" rel="noopener noreferrer"&gt;Issue #96&lt;/a&gt;, open since August 2025, reports that &lt;code&gt;styles&lt;/code&gt; cannot be set when a map ID is present, so the two approaches don't combine. Unlimited mobile map loads therefore hold only for maps without a map ID, which rules out cloud-based styling.&lt;/p&gt;

&lt;p&gt;The Web has no such exception. The Maps JavaScript API bills every load as Dynamic Maps with or without a map ID, and the plugin's web implementation assigns a map ID automatically when you don't pass one.&lt;/p&gt;

&lt;h2&gt;
  
  
  MapLibre tile costs
&lt;/h2&gt;

&lt;p&gt;The Capacitor MapLibre plugin is free under the MIT license and needs no API key, so the running cost of a MapLibre map is whatever the tile provider charges. &lt;a href="https://capawesome.io/docs/sdks/capacitor/maplibre/#createmap" rel="noopener noreferrer"&gt;&lt;code&gt;createMap(...)&lt;/code&gt;&lt;/a&gt; takes a style URL, and the style points the map at the provider's tiles, fonts and sprites. This example uses OpenFreeMap, which needs no key and no registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MapLibre&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome/capacitor-maplibre&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;MapLibre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mapId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stores&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;elementId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;styleUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tiles.openfreemap.org/styles/liberty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;52.52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;13.405&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&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 returned promise resolves after the Liberty style has loaded from OpenFreeMap, with no account or key involved. Moving to another provider later means a different &lt;code&gt;styleUrl&lt;/code&gt;, as long as the new style follows the MapLibre Style Spec. These are the main options as of September 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;th&gt;Paid&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://openfreemap.org/" rel="noopener noreferrer"&gt;OpenFreeMap&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No request limits&lt;/td&gt;
&lt;td&gt;None, donation-funded&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://versatiles.org/" rel="noopener noreferrer"&gt;Versatiles&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Free, self-hostable&lt;/td&gt;
&lt;td&gt;None, donation-funded&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://carto.com/basemaps/apikey/" rel="noopener noreferrer"&gt;CARTO basemaps&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1M requests/month commercial, 5M non-commercial&lt;/td&gt;
&lt;td&gt;$500/month up to 10M&lt;/td&gt;
&lt;td&gt;Yes, free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.maptiler.com/cloud/pricing/" rel="noopener noreferrer"&gt;MapTiler Cloud&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;100k requests/month, non-commercial&lt;/td&gt;
&lt;td&gt;Flex $30/month for 500k, then $0.15 per 1,000&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://stadiamaps.com/pricing/" rel="noopener noreferrer"&gt;Stadia Maps&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;200k credits/month, non-commercial&lt;/td&gt;
&lt;td&gt;Starter $20/month for 1M credits, then $0.03 per 1,000&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://docs.protomaps.com/deploy/cloudflare" rel="noopener noreferrer"&gt;Protomaps&lt;/a&gt; on Cloudflare R2&lt;/td&gt;
&lt;td&gt;R2 free tier&lt;/td&gt;
&lt;td&gt;Workers $5/month incl. 10M requests&lt;/td&gt;
&lt;td&gt;No, own URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/location/pricing/" rel="noopener noreferrer"&gt;Amazon Location Service&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;500k tiles/month for 3 months, new customers&lt;/td&gt;
&lt;td&gt;$0.04 per 1,000 tiles&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://www.mapbox.com/pricing" rel="noopener noreferrer"&gt;Mapbox&lt;/a&gt; vector tiles&lt;/td&gt;
&lt;td&gt;200k requests/month&lt;/td&gt;
&lt;td&gt;$0.25 per 1,000&lt;/td&gt;
&lt;td&gt;Yes, access token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CARTO has required a free API key since 2026 and may watermark tiles requested without one. MapTiler's free plan requires the MapTiler logo and suspends maps for the rest of the month at the limit, and MapTiler bills MapLibre usage by request because sessions only apply to its own SDK. Mapbox's guide to using its tiles with MapLibre covers GL JS only. Protomaps packs the whole basemap into one archive that you host yourself, though its docs warn that R2 is known to have higher latency.&lt;/p&gt;

&lt;p&gt;To compare costs, assume 100,000 map views a month at 15 vector tiles per view. The 15-tile figure is how Jawg and Azure Maps define a map view, not a measurement of a Capacitor app. The resulting 1.5 million tile requests cost, calculated from the list prices above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenFreeMap or Versatiles: $0&lt;/li&gt;
&lt;li&gt;Stadia Maps Starter: $35&lt;/li&gt;
&lt;li&gt;Amazon Location Service, after the free period: $60&lt;/li&gt;
&lt;li&gt;MapTiler Flex: $180&lt;/li&gt;
&lt;li&gt;Mapbox vector tiles: $325&lt;/li&gt;
&lt;li&gt;CARTO commercial plan: $500&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same 100,000 views as Google web map loads come to the $630 from the pricing section, and to nothing as native Google map loads without a map ID. OpenFreeMap offers no SLA and no support, so an app that depends on its map should keep a paid provider or a self-hosted archive as the fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terms and lock-in
&lt;/h2&gt;

&lt;p&gt;Google's &lt;a href="https://cloud.google.com/maps-platform/terms" rel="noopener noreferrer"&gt;Maps Platform Terms&lt;/a&gt;, last modified August 26, 2026, contain four rules that shape any map feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3.2.2(b) Attribution&lt;/strong&gt;: show all attribution Google provides, without modifying, obscuring or deleting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3.2.3(a) No scraping&lt;/strong&gt;: no pre-fetching, storing or bulk downloading of Google Maps content, map tiles included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3.2.3(b) No caching&lt;/strong&gt;: only where the Maps Service Specific Terms allow it, and they grant nothing for map tiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3.2.3(e) No use with non-Google maps&lt;/strong&gt;: Google Maps Core Services may not be used with or near a non-Google map, which covers Places content on one and Street View on the same screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule 3.2.3(e) is where the lock-in sits. Once an app shows Places results next to a Google map, switching the renderer means replacing the search as well.&lt;/p&gt;

&lt;p&gt;Since July 8, 2025, projects linked to EEA billing accounts can fall under separate EEA terms. According to Google's &lt;a href="https://developers.google.com/maps/comms/eea/faq" rel="noopener noreferrer"&gt;EEA FAQ&lt;/a&gt;, those terms remove the non-Google-map rule, but content from the Places API may no longer be displayed with any map, a Google map included. The Places UI Kit takes its place and works with third-party maps.&lt;/p&gt;

&lt;p&gt;The MapLibre side has obligations too. OpenStreetMap data, the base of most free tiles, is licensed under the &lt;a href="https://www.openstreetmap.org/copyright" rel="noopener noreferrer"&gt;ODbL&lt;/a&gt;, which requires visible credit to OpenStreetMap and its contributors and puts altered data you distribute under the same license. Make sure your map screen shows that credit and whatever your tile provider asks for. OSM's own tile server is not meant as a production basemap, since its &lt;a href="https://operations.osmfoundation.org/policies/tiles/" rel="noopener noreferrer"&gt;tile policy&lt;/a&gt; promises best-effort availability without an SLA and blocks generic user agents. Lock-in on this side stays low, because the Style Spec is an open format and leaving a provider means pointing at another style rather than rewriting the map.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering
&lt;/h2&gt;

&lt;p&gt;Both Capacitor plugins draw a native map view on Android and iOS. The differences sit in the rendering backends and in how that view shares the screen with the web view.&lt;/p&gt;

&lt;p&gt;MapLibre Native replaced OpenGL ES with Metal on iOS in &lt;a href="https://github.com/maplibre/maplibre-native/releases/tag/ios-v6.0.0" rel="noopener noreferrer"&gt;version 6.0.0&lt;/a&gt;, released January 16, 2024. On Android, Vulkan support arrived in 11.7.0 and became the default backend of the &lt;code&gt;android-sdk&lt;/code&gt; package in 13.0.0. The Capacitor MapLibre plugin uses the &lt;code&gt;org.maplibre.gl:android-sdk-vulkan-opengl&lt;/code&gt; artifact, version 13.5.0 by default, which switches between OpenGL and Vulkan at runtime. MapLibre renders any style JSON that follows the Style Spec, &lt;a href="https://capawesome.io/docs/sdks/capacitor/maplibre/#setstyle" rel="noopener noreferrer"&gt;&lt;code&gt;setStyle(...)&lt;/code&gt;&lt;/a&gt; swaps it while the app runs, and the Maputnik editor edits styles visually. The Google Maps SDKs offer the map types normal, hybrid, satellite and terrain, plus the two styling paths from the map ID section.&lt;/p&gt;

&lt;p&gt;Inside a Capacitor app, the layering differs per plugin. &lt;code&gt;@capacitor/google-maps&lt;/code&gt; renders beneath the web view on Android only; on iOS it renders the map directly into the web view, so no transparency is needed there. The Capacitor MapLibre plugin renders behind the web view on Android and iOS, so the map element and its ancestors need transparent backgrounds on both platforms. &lt;a href="https://capawesome.io/blog/alternative-to-the-capacitor-google-maps-plugin/" rel="noopener noreferrer"&gt;A Google Maps Alternative for Capacitor Apps&lt;/a&gt; walks through that transparency setup.&lt;/p&gt;

&lt;p&gt;Globe view and 3D terrain exist in MapLibre GL JS, which added globe mode in &lt;a href="https://github.com/maplibre/maplibre-gl-js/releases/tag/v5.0.0" rel="noopener noreferrer"&gt;version 5.0.0&lt;/a&gt;. Neither is in a released MapLibre Native version yet, and native globe support is still an &lt;a href="https://github.com/maplibre/maplibre-native/issues/3161" rel="noopener noreferrer"&gt;open issue&lt;/a&gt;. On the Web, GL JS has required WebGL2 since version 6.0.0, which dropped WebGL1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline maps
&lt;/h2&gt;

&lt;p&gt;Google's terms leave no room for offline maps in your app. The no-caching and no-pre-fetching rules in section 3.2.3 forbid storing tiles, &lt;code&gt;@capacitor/google-maps&lt;/code&gt; has no offline API, and a closed &lt;a href="https://github.com/ionic-team/capacitor-google-maps/issues/61" rel="noopener noreferrer"&gt;issue&lt;/a&gt; titled "Map goes blank when internet connection is lost" shows what users see without a connection.&lt;/p&gt;

&lt;p&gt;MapLibre Native supports offline maps at the SDK level. On Android, &lt;a href="https://maplibre.org/maplibre-native/android/api/-map-libre%20-native%20-android/org.maplibre.android.offline/-offline-manager/index.html" rel="noopener noreferrer"&gt;&lt;code&gt;OfflineManager&lt;/code&gt;&lt;/a&gt; lists and creates offline regions and downloads the resources they need. On iOS, &lt;a href="https://maplibre.org/maplibre-native/ios/latest/documentation/maplibre/mlnofflinestorage" rel="noopener noreferrer"&gt;&lt;code&gt;MLNOfflineStorage&lt;/code&gt;&lt;/a&gt; manages offline packs and ambient caching. The Capacitor MapLibre plugin does not expose either API yet, as its FAQ notes: "Offline tile management is not part of this version of the plugin." Styles, fonts, sprites and tiles load from the network.&lt;/p&gt;

&lt;p&gt;Any offline storage also has to respect the tile provider's terms. CARTO's basemap terms forbid on-device caching beyond 30 days, and OSM's tile policy forbids apps from offering prefetch features. OpenFreeMap publishes weekly planet downloads, including MBTiles, for teams that want to host the tiles themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data and search
&lt;/h2&gt;

&lt;p&gt;Google's advantage is its data. Its December 2024 &lt;a href="https://mapsplatform.google.com/resources/blog/build-more-for-free-and-access-more-discounts-online-with-google-maps-platform-updates/" rel="noopener noreferrer"&gt;pricing announcement&lt;/a&gt; describes a map with information on over 250 million businesses and places. The Google Maps SDKs add Street View, a live traffic layer (&lt;code&gt;enableTrafficLayer&lt;/code&gt; in the Capacitor plugin), indoor floor plans (&lt;code&gt;enableIndoorMaps&lt;/code&gt;) and satellite imagery as a built-in map type. MapLibre has no equivalent for Street View, traffic or indoor data. Satellite imagery depends on the provider, for example MapTiler Satellite, Stadia Maps from its Standard plan, or the free Versatiles orthophotos.&lt;/p&gt;

&lt;p&gt;The open alternatives come with open licenses. OpenStreetMap data falls under the ODbL described in the terms section. &lt;a href="https://docs.overturemaps.org/attribution/" rel="noopener noreferrer"&gt;Overture Maps&lt;/a&gt; publishes its base, buildings, divisions and transportation themes under the ODbL as well, while its places theme mixes sources under CDLA Permissive 2.0, Apache 2.0 (Foursquare) and CC0 (AllThePlaces).&lt;/p&gt;

&lt;p&gt;Search needs a separate service, because MapLibre ships no Places API. These services fill the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nominatim&lt;/strong&gt;: the public OpenStreetMap geocoder allows an absolute maximum of 1 request per second and &lt;a href="https://operations.osmfoundation.org/policies/nominatim/" rel="noopener noreferrer"&gt;forbids client-side autocomplete&lt;/a&gt;, so it suits occasional lookups, not a search box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MapTiler and Stadia Maps&lt;/strong&gt;: both sell geocoding next to their tiles, and MapTiler includes 1,000 free search sessions a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native geocoding&lt;/strong&gt;: the &lt;a href="https://capawesome.io/docs/sdks/capacitor/geocoder/" rel="noopener noreferrer"&gt;Capacitor Geocoder plugin&lt;/a&gt;, part of the paid &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt; subscription, uses the platform geocoder on Android and iOS without an API key and offers Google Maps or OpenStreetMap as providers on the Web.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Outside the EEA, section 3.2.3(e) rules out showing Google geocoding or Places results on a MapLibre map. The same applies to results from the Geocoder plugin's Google Maps provider on the Web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to pick
&lt;/h2&gt;

&lt;p&gt;The MapLibre vs. Google Maps choice follows the data your screens need, with cost as the tiebreaker.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your app needs&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Street View, live traffic or indoor maps&lt;/td&gt;
&lt;td&gt;Google Maps&lt;/td&gt;
&lt;td&gt;No MapLibre equivalent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Places search or Directions already in use&lt;/td&gt;
&lt;td&gt;Google Maps&lt;/td&gt;
&lt;td&gt;3.2.3(e) ties Places to a Google map outside the EEA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A native-only map with Google data, no cloud styling&lt;/td&gt;
&lt;td&gt;Google Maps&lt;/td&gt;
&lt;td&gt;Map loads stay free without a map ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own points, routes and areas&lt;/td&gt;
&lt;td&gt;MapLibre&lt;/td&gt;
&lt;td&gt;Any Style Spec tile provider works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No billing account, e.g. open-source or client apps&lt;/td&gt;
&lt;td&gt;MapLibre&lt;/td&gt;
&lt;td&gt;The plugin needs no key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A high-traffic web map&lt;/td&gt;
&lt;td&gt;MapLibre&lt;/td&gt;
&lt;td&gt;1.5M tiles cost $0 to $500, Google web loads $630&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud-styled native maps at volume&lt;/td&gt;
&lt;td&gt;MapLibre&lt;/td&gt;
&lt;td&gt;A map ID bills native loads as Dynamic Maps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Place details next to a map in the EEA&lt;/td&gt;
&lt;td&gt;Places UI Kit, on either map&lt;/td&gt;
&lt;td&gt;EEA terms bar Places API content from any map&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On mobile, a Google map without a map ID costs nothing to load, so price alone rarely justifies a switch there. On the Web and with cloud styling, Google bills every load above the free cap, and at 100,000 views a month every provider in the tile comparison costs less.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much does Google Maps cost per month in 2026?
&lt;/h3&gt;

&lt;p&gt;There is no base fee. Every SKU has a free monthly cap, and you pay a listed price per 1,000 events above it, such as $7.00 for web map loads after the first 10,000. Native map loads without a map ID stay free at any volume. Optional subscriptions start at $100 a month for 50,000 calls. Apart from the prototyping-only Demo Key, every setup needs a billing-enabled Cloud project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is MapLibre free to use commercially?
&lt;/h3&gt;

&lt;p&gt;Yes. MapLibre GL JS is licensed under BSD-3-Clause and MapLibre Native under BSD-2-Clause, and the Capacitor MapLibre plugin is MIT-licensed. Tile providers set their own rules. OpenFreeMap allows commercial use, while the free tiers of MapTiler and Stadia Maps exclude it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an API key to use MapLibre?
&lt;/h3&gt;

&lt;p&gt;Not for MapLibre or the Capacitor MapLibre plugin. The tile provider may need one. OpenFreeMap and Versatiles work without a key, CARTO issues a free one, and MapTiler, Stadia Maps and Amazon Location Service require their own. The plugin's default demo style is meant for testing, not production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I show Google Places results on a MapLibre map?
&lt;/h3&gt;

&lt;p&gt;Not outside the EEA, where section 3.2.3(e) of Google's terms forbids displaying Places content on a non-Google map. Projects under the EEA terms lose that rule, but Places API content may then not be displayed with any map. For those projects, Google offers the Places UI Kit, which works with any map, MapLibre included.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does MapLibre work offline in a Capacitor app?
&lt;/h3&gt;

&lt;p&gt;Not yet. MapLibre Native can download offline regions, but the Capacitor MapLibre plugin does not expose that API, so styles and tiles load from the network. Google Maps is no alternative here, because its terms forbid caching and pre-fetching tiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I migrate from @capacitor/google-maps to MapLibre?
&lt;/h3&gt;

&lt;p&gt;Yes for the map itself, but plan for what surrounds it. Outside the EEA, Places search and Google geocoding have to move to another provider together with the map. Clustering has no counterpart in the Capacitor MapLibre plugin, and satellite or terrain views need a tile provider that serves them. &lt;a href="https://capawesome.io/blog/alternative-to-the-capacitor-google-maps-plugin/" rel="noopener noreferrer"&gt;A Google Maps Alternative for Capacitor Apps&lt;/a&gt; shows the MapLibre side of markers, polylines and the camera.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/alternative-to-the-capacitor-google-maps-plugin/" rel="noopener noreferrer"&gt;A Google Maps Alternative for Capacitor Apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/announcing-the-capacitor-maplibre-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor MapLibre Plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/capacitor-device-sensors-guide/" rel="noopener noreferrer"&gt;The Complete Guide to Capacitor Device Sensors&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stay in the loop
&lt;/h2&gt;

&lt;p&gt;New plugins and releases, including updates to the Capacitor MapLibre plugin, go out in the Capawesome newsletter first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Subscribe to the Capawesome Newsletter&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Start from the list of Google data your screens show. If it includes Street View, live traffic, indoor maps or Places results beside the map, use Google Maps, keep native maps free by leaving out the map ID, and estimate the remaining SKUs before launch. If the list is empty, use MapLibre with a tile provider from the table and budget for tile requests instead of map loads. For apps that record the routes they draw, &lt;a href="https://capawesome.io/blog/announcing-the-capacitor-background-geolocation-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor Background Geolocation Plugin&lt;/a&gt; covers the tracking side.&lt;/p&gt;

&lt;p&gt;If you have any questions, join us on the &lt;a href="https://discord.gg/VCXxSVjefW" rel="noopener noreferrer"&gt;Capawesome Discord server&lt;/a&gt;. To stay updated on the latest news, subscribe to the &lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Capawesome newsletter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>mobile</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Scan a Document to PDF in a Capacitor App</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Wed, 30 Sep 2026 15:31:59 +0000</pubDate>
      <link>https://dev.to/capawesome/how-to-scan-a-document-to-pdf-in-a-capacitor-app-p2a</link>
      <guid>https://dev.to/capawesome/how-to-scan-a-document-to-pdf-in-a-capacitor-app-p2a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://capawesome.io/blog/how-to-scan-a-document-to-pdf-in-a-capacitor-app/" rel="noopener noreferrer"&gt;Capawesome blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A signed contract that comes out of a scanner as a folder of JPEGs is hard to email, print or file, so most apps want one PDF instead. The &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/" rel="noopener noreferrer"&gt;Capacitor Document Scanner plugin&lt;/a&gt; returns that PDF next to the individual page images when you pass &lt;code&gt;generatePdf: true&lt;/code&gt; to &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/#scandocument" rel="noopener noreferrer"&gt;&lt;code&gt;scanDocument(...)&lt;/code&gt;&lt;/a&gt;. This guide shows how to scan a document to PDF in a Capacitor app and what to do with the file afterwards, from opening it in a native viewer to printing it, sharing it and keeping it past the next app launch. The scanner and the Capacitor Printer plugin are part of &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;; the PDF viewer, the file opener and the official Share and Filesystem plugins are free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy0jx9tg3zanm0a2anmv5.png" alt="Ship a fix in one command with Capawesome Cloud Live Updates, no store review" width="800" height="111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scanDocument({ generatePdf: true })&lt;/code&gt; returns one combined PDF in &lt;code&gt;pdf&lt;/code&gt; and still returns every page as a JPEG in &lt;code&gt;scannedImages&lt;/code&gt;, from the same scan.&lt;/li&gt;
&lt;li&gt;On Android, Google's ML Kit generates the PDF; on iOS, VisionKit returns only page images, and the plugin composes an image-only PDF from them.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;pdf&lt;/code&gt; value is a file URL in the app's cache that the PDF Viewer, Printer, File Opener and &lt;code&gt;@capacitor/share&lt;/code&gt; plugins accept without conversion.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pageLimit&lt;/code&gt; defaults to 10 and caps the PDF as well, so raise it for longer documents; on iOS, extra pages are dropped after scanning.&lt;/li&gt;
&lt;li&gt;The Capacitor Document Scanner plugin clears its cache files when it loads on the next app launch, so copy the PDF to &lt;code&gt;Directory.Data&lt;/code&gt; in the same flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One PDF per scan
&lt;/h2&gt;

&lt;p&gt;To turn scanned pages into a single PDF in Capacitor, call &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/#scandocument" rel="noopener noreferrer"&gt;&lt;code&gt;scanDocument(...)&lt;/code&gt;&lt;/a&gt; of the &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/" rel="noopener noreferrer"&gt;Capacitor Document Scanner plugin&lt;/a&gt; with &lt;code&gt;generatePdf: true&lt;/code&gt;. The result then holds one combined PDF of all scanned pages in &lt;code&gt;pdf&lt;/code&gt;, and the pages themselves as JPEG files in &lt;code&gt;scannedImages&lt;/code&gt;. The plugin is part of &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;. To install it, please refer to the &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section in the plugin documentation, and do the same for each companion plugin below. &lt;a href="https://capawesome.io/blog/announcing-the-capacitor-document-scanner-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor Document Scanner Plugin&lt;/a&gt; covers setup and the Android scanner options, so this guide starts at the scan call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DocumentScanner&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-document-scanner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scanToPdf&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scannedImages&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;DocumentScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scanDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;generatePdf&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scannedImages&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="nx"&gt;error&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="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SCAN_CANCELED&lt;/span&gt;&lt;span class="dl"&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the user backs out of the scanner, the promise rejects with the &lt;code&gt;SCAN_CANCELED&lt;/code&gt; error code. That is a normal outcome, so the function returns &lt;code&gt;null&lt;/code&gt; instead of throwing. &lt;code&gt;pdf&lt;/code&gt; is typed &lt;code&gt;string | null&lt;/code&gt; because it is only set when &lt;code&gt;generatePdf&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;; the snippets in the following sections take it as a &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two platforms produce the PDF differently. On Android, ML Kit generates it. Google's &lt;a href="https://developers.google.com/ml-kit/vision/doc-scanner/android" rel="noopener noreferrer"&gt;Android guide&lt;/a&gt; states that the scanner can return both PDF and JPEG files for a scan, depending on the formats an app requests with &lt;code&gt;setResultFormats&lt;/code&gt;, and it recommends requesting only the formats you need, since generating document files takes time and processing power. The scanner itself is a Google Play services module rather than part of your APK. Google lists its app size impact as a "~300KB download size increase", and it only runs on devices with Play services, which is what &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/#isavailable" rel="noopener noreferrer"&gt;&lt;code&gt;isAvailable()&lt;/code&gt;&lt;/a&gt; checks on Android.&lt;/p&gt;

&lt;p&gt;On iOS, Apple's &lt;a href="https://developer.apple.com/documentation/visionkit/vndocumentcameraviewcontroller" rel="noopener noreferrer"&gt;&lt;code&gt;VNDocumentCameraViewController&lt;/code&gt;&lt;/a&gt; returns page images and no PDF. Apple leaves the export to the app: "With the collection of scanned images, your app can create a digital version of the physical document and export the scanned images to PDF." The plugin does that export with one PDF page per scanned page, and the result is an image-only PDF without a text layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page limit and quality
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;pageLimit&lt;/code&gt; option caps the PDF along with the images. It defaults to &lt;code&gt;10&lt;/code&gt;, so a 14-page contract never fits into one PDF with default options. Raise the limit for long documents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DocumentScanner&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-document-scanner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scanContract&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;DocumentScanner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scanDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;generatePdf&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="na"&gt;pageLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&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="nx"&gt;pdf&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 Android, ML Kit enforces the limit inside the scanner UI. VisionKit has no public API to stop the scanner after a set number of pages, so on iOS the plugin truncates the result after scanning, and the pages past the limit are missing from the PDF as well. Set &lt;code&gt;pageLimit&lt;/code&gt; above the longest document your users handle, or mention the limit in your own UI before the scanner opens.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;imageQuality&lt;/code&gt; option does not shrink the PDF. It re-encodes only the JPEG pages and leaves the PDF untouched on both platforms, and the plugin has no option for the PDF's page size, resolution or compression. Lower &lt;code&gt;imageQuality&lt;/code&gt; when you store or upload the JPEGs, not to make &lt;code&gt;pdf&lt;/code&gt; smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open the PDF in-app
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/pdf-viewer/" rel="noopener noreferrer"&gt;Capacitor PDF Viewer plugin&lt;/a&gt; shows the scanned PDF in a fullscreen native viewer with a toolbar, paging and pinch-to-zoom, without the user leaving your app. It is free. Pass the scanner's &lt;code&gt;pdf&lt;/code&gt; value as &lt;code&gt;path&lt;/code&gt; to &lt;a href="https://capawesome.io/docs/sdks/capacitor/pdf-viewer/#open" rel="noopener noreferrer"&gt;&lt;code&gt;open(...)&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfViewer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome/capacitor-pdf-viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openScan&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="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PdfViewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rental contract&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;showShareButton&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pass a &lt;code&gt;title&lt;/code&gt;. The viewer falls back to the file name, and the scanner names its files with a generated ID, not with anything a user would recognize. &lt;code&gt;showShareButton&lt;/code&gt; adds a share button to the viewer toolbar on Android and iOS; on Android, it depends on the same file provider setup as the Share plugin (see Share or hand off).&lt;/p&gt;

&lt;p&gt;The viewer renders differently per platform. On iOS, the plugin uses Apple's PDFKit and needs no configuration. On Android, it uses the android-pdf-viewer library, which bundles the Pdfium native libraries and adds about 10 to 16 MB (uncompressed, across all ABIs) to your app. Published as an Android App Bundle, each device downloads only the libraries for its own ABI. The Android viewer does not support text selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Print the PDF
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/printer/" rel="noopener noreferrer"&gt;Capacitor Printer plugin&lt;/a&gt; prints the scanned PDF with &lt;a href="https://capawesome.io/docs/sdks/capacitor/printer/#printpdf" rel="noopener noreferrer"&gt;&lt;code&gt;printPdf(...)&lt;/code&gt;&lt;/a&gt;, which presents the platform's printing UI on Android and iOS. Like the scanner, it is a &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt; plugin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Printer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-printer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;printScan&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="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Printer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;printPdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rental contract&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pdf&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;name&lt;/code&gt; sets the name of the print job and defaults to &lt;code&gt;Document&lt;/code&gt;, so pass the same label you gave the viewer. The Printer plugin can print the scanned images as well, for example through &lt;a href="https://capawesome.io/docs/sdks/capacitor/printer/#printfile" rel="noopener noreferrer"&gt;&lt;code&gt;printFile(...)&lt;/code&gt;&lt;/a&gt;, when the user needs a single page rather than the whole document. &lt;a href="https://capawesome.io/blog/exploring-the-capacitor-printer-api/" rel="noopener noreferrer"&gt;Exploring the Capacitor Printer API&lt;/a&gt; covers the remaining print methods, from base64 data to HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share or hand off
&lt;/h2&gt;

&lt;p&gt;The free &lt;a href="https://capacitorjs.com/docs/apis/share" rel="noopener noreferrer"&gt;&lt;code&gt;@capacitor/share&lt;/code&gt;&lt;/a&gt; plugin from the Capacitor team opens the platform's share sheet, so the user can send the PDF to another app, such as a mail client or a messenger. Its &lt;code&gt;files&lt;/code&gt; option takes an array of &lt;code&gt;file://&lt;/code&gt; URLs on Android and iOS, and the scanner's &lt;code&gt;pdf&lt;/code&gt; value is such a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Share&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor/share&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shareScan&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="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rental contract&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pdf&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;title&lt;/code&gt; becomes the subject when the user shares to email. If the user closes the sheet without picking an app, &lt;code&gt;share()&lt;/code&gt; rejects with the message &lt;code&gt;Share canceled&lt;/code&gt;, which deserves the same handling as a canceled scan.&lt;/p&gt;

&lt;p&gt;Sharing the scanned PDF on Android needs no configuration. The Share documentation gives the reason:&lt;/p&gt;

&lt;p&gt;&amp;gt; By default, Capacitor apps only allow to share files from caches folder. To make other Android folders shareable, they have to be added in &lt;code&gt;android/app/src/main/res/xml/file_paths.xml&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;The scanner writes to that cache folder, and the &lt;code&gt;file_paths.xml&lt;/code&gt; of a default Capacitor app already declares it with a `` entry. The PDF Viewer share button and the File Opener plugin pass files to other apps through the same file provider, so they work on the cached PDF without changes too.&lt;/p&gt;

&lt;p&gt;To open the PDF in another app rather than send it, use the free &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-opener/" rel="noopener noreferrer"&gt;Capacitor File Opener plugin&lt;/a&gt;. &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-opener/#openfile" rel="noopener noreferrer"&gt;&lt;code&gt;openFile(...)&lt;/code&gt;&lt;/a&gt; determines the MIME type on its own:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { FileOpener } from '@capawesome-team/capacitor-file-opener';&lt;/p&gt;

&lt;p&gt;const openInDefaultApp = async (pdf: string) =&amp;gt; {&lt;br&gt;
  await FileOpener.openFile({ path: pdf });&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;On iOS, the call shows a Quick Look preview inside your app, with the system share button. On Android, it opens the PDF in the user's default PDF app, or shows the app chooser if no default is set. Compared with the PDF Viewer plugin, File Opener leaves the choice of viewer to the platform, while the PDF Viewer plugin lets you set the title and the initial page and listen for page changes.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keep the PDF
&lt;/h2&gt;

&lt;p&gt;The Capacitor Document Scanner plugin writes the PDF to the app's cache directory and deletes it again on the next app launch. From the plugin's FAQ:&lt;/p&gt;

&lt;p&gt;&amp;gt; Stale files created by the plugin are cleaned up automatically when the plugin is loaded. Copy the files to a persistent location if you need to keep them.&lt;/p&gt;

&lt;p&gt;The official &lt;a href="https://capacitorjs.com/docs/apis/filesystem" rel="noopener noreferrer"&gt;&lt;code&gt;@capacitor/filesystem&lt;/code&gt;&lt;/a&gt; plugin, which is free, copies the PDF to a persistent directory:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { Directory, Filesystem } from '@capacitor/filesystem';&lt;/p&gt;

&lt;p&gt;const keepScan = async (pdf: string) =&amp;gt; {&lt;br&gt;
  const { uri } = await Filesystem.copy({&lt;br&gt;
    from: pdf,&lt;br&gt;
    to: 'contract.pdf',&lt;br&gt;
    toDirectory: Directory.Data,&lt;br&gt;
  });&lt;br&gt;
  return uri;&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Without a &lt;code&gt;directory&lt;/code&gt; option, the Filesystem plugin reads &lt;code&gt;from&lt;/code&gt; as a full &lt;code&gt;file://&lt;/code&gt; path, and &lt;code&gt;toDirectory&lt;/code&gt; places the copy in &lt;code&gt;Directory.Data&lt;/code&gt;, which is the app's files directory on Android and the Documents directory on iOS. Files there are deleted when the app is uninstalled, not on the next launch. &lt;code&gt;copy(...)&lt;/code&gt; resolves with the &lt;code&gt;uri&lt;/code&gt; of the copy, which you store with the document record. Give every document its own file name, since a fixed name like &lt;code&gt;contract.pdf&lt;/code&gt; only fits one.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-manager/" rel="noopener noreferrer"&gt;Capacitor File Manager plugin&lt;/a&gt;, an Insiders plugin, does the same with &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-manager/#copyfile" rel="noopener noreferrer"&gt;&lt;code&gt;copyFile(...)&lt;/code&gt;&lt;/a&gt;, which copies natively with constant memory usage and takes &lt;code&gt;file://&lt;/code&gt; URIs throughout its API. &lt;a href="https://capawesome.io/blog/android-scoped-storage-in-capacitor-apps/#which-directory-to-use" rel="noopener noreferrer"&gt;Android Scoped Storage in Capacitor Apps, Explained&lt;/a&gt; compares the directories an Android app can write to without a permission. On iOS, the Filesystem documentation lists two &lt;code&gt;Info.plist&lt;/code&gt; keys, &lt;code&gt;UIFileSharingEnabled&lt;/code&gt; and &lt;code&gt;LSSupportsOpeningDocumentsInPlace&lt;/code&gt;, that make the app's files appear in the Files app when set to &lt;code&gt;YES&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Moving the PDF out of the cache has one consequence on Android. The app's files directory is not in Capacitor's default &lt;code&gt;file_paths.xml&lt;/code&gt;, so the Share plugin, the File Opener plugin and the PDF Viewer share button cannot pass the copy to another app until you declare it. Add a `&lt;code&gt; entry to &lt;/code&gt;android/app/src/main/res/xml/file_paths.xml`:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;





&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two entries are the Capacitor defaults, and `` adds the app's files directory.&lt;/p&gt;

&lt;p&gt;If the scan goes to a backend instead of staying on the device, the &lt;a href="https://capawesome.io/docs/sdks/capacitor/file-transfer/" rel="noopener noreferrer"&gt;Capacitor File Transfer plugin&lt;/a&gt;, also an Insiders plugin, uploads files in the background; &lt;a href="https://capawesome.io/blog/announcing-the-capacitor-file-transfer-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor File Transfer Plugin&lt;/a&gt; walks through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I turn scanned pages into a single PDF in Capacitor?
&lt;/h3&gt;

&lt;p&gt;Call &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/#scandocument" rel="noopener noreferrer"&gt;&lt;code&gt;scanDocument(...)&lt;/code&gt;&lt;/a&gt; of the &lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/" rel="noopener noreferrer"&gt;Capacitor Document Scanner plugin&lt;/a&gt; with &lt;code&gt;generatePdf: true&lt;/code&gt;. The result contains one combined PDF in &lt;code&gt;pdf&lt;/code&gt; and the page images in &lt;code&gt;scannedImages&lt;/code&gt;. ML Kit generates the PDF on Android, the plugin composes it from the VisionKit page images on iOS, and it holds at most &lt;code&gt;pageLimit&lt;/code&gt; pages (default 10).&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the scanned PDF searchable?
&lt;/h3&gt;

&lt;p&gt;The Capacitor Document Scanner plugin runs no OCR, and on iOS the PDF has no text layer. If you need the text of a document, run &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/text-recognition/#processimage" rel="noopener noreferrer"&gt;&lt;code&gt;processImage(...)&lt;/code&gt;&lt;/a&gt; of the free &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/text-recognition/" rel="noopener noreferrer"&gt;Capacitor ML Kit Text Recognition plugin&lt;/a&gt; on the JPEG pages in &lt;code&gt;scannedImages&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does scan-to-PDF work on the web?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;scanDocument(...)&lt;/code&gt; rejects with an unimplemented error on the web. The PDF Viewer plugin has no web implementation either, since browsers display a PDF in an `&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>android</category>
      <category>ios</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Use Gemini Nano in a Capacitor App</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:30:33 +0000</pubDate>
      <link>https://dev.to/capawesome/how-to-use-gemini-nano-in-a-capacitor-app-5fgf</link>
      <guid>https://dev.to/capawesome/how-to-use-gemini-nano-in-a-capacitor-app-5fgf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://capawesome.io/blog/how-to-use-gemini-nano-in-a-capacitor-app/" rel="noopener noreferrer"&gt;Capawesome blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Gemini Nano is Google's on-device language model for Android, and a Capacitor app can prompt it without an API key, a server or a per-request bill. The &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/" rel="noopener noreferrer"&gt;Capacitor LLM plugin&lt;/a&gt; runs it through AICore, the Android system service that manages the model on supported phones. This guide to Gemini Nano in a Capacitor app walks through the device list, the availability check, the model download with a determinate progress bar, text generation and streaming, and the quota and foreground rules that AICore enforces. The plugin is part of Capawesome Insiders, a paid subscription. If you only need summaries, proofreading or rewriting on Android, the free Capacitor ML Kit GenAI plugins cover those tasks, and a later section compares the two options.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdhpifss4ot0ay7zgkvz.png" alt="Thousands of teams ship faster with Capawesome Cloud Native Builds and Live Updates" width="800" height="111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AICore stores one shared copy of Gemini Nano per device, so the model can already be &lt;code&gt;available&lt;/code&gt; before your app downloads anything.&lt;/li&gt;
&lt;li&gt;The Capacitor LLM plugin's &lt;code&gt;getAvailability()&lt;/code&gt; never rejects and reports four statuses on Android: &lt;code&gt;available&lt;/code&gt;, &lt;code&gt;downloadable&lt;/code&gt;, &lt;code&gt;downloading&lt;/code&gt; and &lt;code&gt;unavailable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;During &lt;code&gt;downloadModel()&lt;/code&gt;, the &lt;code&gt;downloadProgress&lt;/code&gt; event reports a value between 0 and 1, so the download can drive a determinate progress bar.&lt;/li&gt;
&lt;li&gt;AICore answers too many requests with a &lt;code&gt;BUSY&lt;/code&gt; quota error that calls for exponential backoff, and allows inference only while your app is the top foreground app.&lt;/li&gt;
&lt;li&gt;Prompt API devices include the Pixel 9, Pixel 10, Galaxy S26 and OnePlus 15, and each needs API level 26 or higher and a locked bootloader.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How Gemini Nano runs
&lt;/h2&gt;

&lt;p&gt;Gemini Nano runs inside AICore, which Google describes as "an Android system service that enables on-device execution of GenAI foundation models". Your app never ships the model, because AICore handles its distribution and its updates. Apps reach it through Google's &lt;a href="https://developers.google.com/ml-kit/genai" rel="noopener noreferrer"&gt;ML Kit GenAI APIs&lt;/a&gt;, which come in two kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt API&lt;/strong&gt;: takes free-form prompts. This is the API the &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/" rel="noopener noreferrer"&gt;Capacitor LLM plugin&lt;/a&gt; calls on Android.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature-specific APIs&lt;/strong&gt;: Summarization, Proofreading, Rewriting and Image Description each handle one fixed task. A sixth API covers speech recognition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google offers the Prompt API and the four feature-specific APIs as beta, "not subject to any SLA or deprecation policy", and Speech Recognition as alpha. The Prompt API moved from alpha to beta on January 28, 2026.&lt;/p&gt;

&lt;p&gt;Because the APIs run on the device, Google states that input, inference and output data are processed locally and that the features keep working without a reliable internet connection. Prompts and responses stay on the device. Google's &lt;a href="https://developers.google.com/ml-kit/genai-terms" rel="noopener noreferrer"&gt;ML Kit GenAI terms&lt;/a&gt; still require you to inform users about Google's processing of metrics data, so cover that in your privacy policy. According to Google's &lt;a href="https://android-developers.googleblog.com/2026/07/android-on-device-inference.html" rel="noopener noreferrer"&gt;July 2026 Android Developers blog post&lt;/a&gt;, Gemini Nano now runs on over 140 million devices.&lt;/p&gt;

&lt;p&gt;The Capacitor LLM plugin exposes the Prompt API to your web code through one TypeScript interface. It is part of &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt;, a paid subscription. To install the Capacitor LLM plugin, please refer to the &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section in the plugin documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supported devices
&lt;/h2&gt;

&lt;p&gt;Gemini Nano runs only on the devices Google lists for each ML Kit GenAI API, and the &lt;a href="https://developers.google.com/ml-kit/genai#prompt-device" rel="noopener noreferrer"&gt;Prompt API device list&lt;/a&gt; is the one that applies to the Capacitor LLM plugin. It names phones and tablets from 14 brands and groups them by the Nano version each device runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gemini Nano version&lt;/th&gt;
&lt;th&gt;Example devices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nano-v2&lt;/td&gt;
&lt;td&gt;OnePlus 13, Xiaomi 15, Galaxy Z Fold7, Honor Magic 7, realme GT 7 Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nano-v3&lt;/td&gt;
&lt;td&gt;Pixel 9 and Pixel 10 (including Pro, Pro XL and Pro Fold), Galaxy S26 series, OnePlus 15, OPPO Find X9, vivo X300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nano-v4&lt;/td&gt;
&lt;td&gt;Pixel 11 (including Pro, Pro XL and Pro Fold), Galaxy Z Flip8, Galaxy Z Fold8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The version affects the output. Google notes that "different versions of Gemini Nano may return different output from the same prompt", so a prompt tuned on a Pixel 10 needs a second look on a nano-v2 phone such as the Xiaomi 15.&lt;/p&gt;

&lt;p&gt;Every device on the list also has to meet the requirements Google repeats on each API page. It must run Android API level 26 or higher, and the APIs are not supported on devices with an unlocked bootloader. Google names MediaTek Dimensity, Qualcomm Snapdragon and Google Tensor as the supported chip platforms.&lt;/p&gt;

&lt;p&gt;On iOS, the same plugin talks to Apple Intelligence instead, and &lt;a href="https://capawesome.io/blog/how-to-use-apple-intelligence-in-a-capacitor-app/" rel="noopener noreferrer"&gt;How to Use Apple Intelligence in a Capacitor App&lt;/a&gt; covers that side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check availability
&lt;/h2&gt;

&lt;p&gt;The Capacitor LLM plugin reports whether Gemini Nano is ready with &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#getavailability" rel="noopener noreferrer"&gt;&lt;code&gt;getAvailability()&lt;/code&gt;&lt;/a&gt;, a method that never rejects. On the web, or anywhere else without a system model, it resolves with &lt;code&gt;unavailable&lt;/code&gt;, so you can call it unconditionally at startup. Android reports four of the plugin's statuses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning on Android&lt;/th&gt;
&lt;th&gt;What the screen shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;available&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The model is downloaded and ready.&lt;/td&gt;
&lt;td&gt;The feature.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloadable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The device supports Gemini Nano, but the model is not on it yet.&lt;/td&gt;
&lt;td&gt;A download button.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A download is running.&lt;/td&gt;
&lt;td&gt;A waiting indicator.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unavailable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gemini Nano cannot run on this device right now.&lt;/td&gt;
&lt;td&gt;Nothing, or a fallback.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The following function turns the status into one of four UI states and keeps it current. It reads the status once, then subscribes to the &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#addlisteneravailabilitychange-" rel="noopener noreferrer"&gt;&lt;code&gt;availabilityChange&lt;/code&gt;&lt;/a&gt; event, which fires when the status changes, for example when a download completes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AvailabilityStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AssistantState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;needs-download&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toAssistantState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AvailabilityStatus&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AssistantState&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloadable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;needs-download&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observeAssistantState&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="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AssistantState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAvailability&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toAssistantState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;availabilityChange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toAssistantState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin watches the status only while at least one listener is attached, so call &lt;code&gt;remove()&lt;/code&gt; on the returned handle when the screen closes. The &lt;code&gt;default&lt;/code&gt; branch covers &lt;code&gt;unavailable&lt;/code&gt; as well as the statuses that only iOS reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download the model
&lt;/h2&gt;

&lt;p&gt;Gemini Nano has to be downloaded to the device before a Capacitor app can use it, but the download may already have happened. AICore keeps one shared copy of the model for all apps on the device, so if another app triggered the download earlier, &lt;code&gt;getAvailability()&lt;/code&gt; reports &lt;code&gt;available&lt;/code&gt; and your app can skip this step. The four feature-specific ML Kit APIs also fetch a small adapter model per feature on top of that shared base model.&lt;/p&gt;

&lt;p&gt;When the status is &lt;code&gt;downloadable&lt;/code&gt;, &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#downloadmodel" rel="noopener noreferrer"&gt;&lt;code&gt;downloadModel()&lt;/code&gt;&lt;/a&gt; starts the download and resolves once it completes. Along the way, the &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#addlistenerdownloadprogress-" rel="noopener noreferrer"&gt;&lt;code&gt;downloadProgress&lt;/code&gt;&lt;/a&gt; event reports &lt;code&gt;progress&lt;/code&gt; as a value between &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;, which maps directly onto a `` element. Google publishes no download size, and its &lt;a href="https://firebase.google.com/docs/ai-logic/hybrid/android/get-started?api=dev" rel="noopener noreferrer"&gt;Firebase documentation&lt;/a&gt; says the download time "depends on many factors, including your network", so show the bar instead of a spinner.&lt;/p&gt;

&lt;p&gt;Start the download from a clear user action, such as an "Enable reply drafts" button, and offer it before the user reaches the screen that needs the model:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { Llm } from '@capawesome-team/capacitor-llm';&lt;/p&gt;

&lt;p&gt;const downloadGeminiNano = async (onProgress: (progress: number) =&amp;gt; void) =&amp;gt; {&lt;br&gt;
  const listener = await Llm.addListener('downloadProgress', event =&amp;gt; {&lt;br&gt;
    onProgress(event.progress);&lt;br&gt;
  });&lt;br&gt;
  try {&lt;br&gt;
    await Llm.downloadModel();&lt;br&gt;
  } finally {&lt;br&gt;
    await listener.remove();&lt;br&gt;
  }&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const enableReplyDrafts = async () =&amp;gt; {&lt;br&gt;
  showProgressBar(0);&lt;br&gt;
  try {&lt;br&gt;
    await downloadGeminiNano(showProgressBar);&lt;br&gt;
    showReplyDrafts();&lt;br&gt;
  } catch {&lt;br&gt;
    showRetryButton();&lt;br&gt;
  }&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;The screen can also open in the &lt;code&gt;downloading&lt;/code&gt; state. That happens when your app started the download in an earlier session, or when another app started it. Show an indeterminate indicator in that case, and let the &lt;code&gt;availabilityChange&lt;/code&gt; listener from the previous section switch the screen to ready once the status turns &lt;code&gt;available&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On a supported phone, &lt;code&gt;unavailable&lt;/code&gt; is not always final. After a device is set up or reset, AICore first has to download its configuration, and Google's &lt;a href="https://developers.google.com/ml-kit/genai/summarization/android#common-setup-issues" rel="noopener noreferrer"&gt;setup notes&lt;/a&gt; say that this "usually takes a few minutes to a few hours" while the device is online. Check the status again on the next app start instead of hiding the feature for good. If &lt;code&gt;downloadModel()&lt;/code&gt; itself rejects, the retry button above covers it. Google's advice for network errors during a download is to keep the connection, wait a few minutes and retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate and stream
&lt;/h2&gt;

&lt;p&gt;Text generation in the Capacitor LLM plugin happens inside a chat, which carries instructions (the system prompt) and the conversation history. The example in this section drafts replies to customer reviews in a shop app, so the owner edits a suggestion instead of starting from an empty field. &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#createchat" rel="noopener noreferrer"&gt;&lt;code&gt;createChat(...)&lt;/code&gt;&lt;/a&gt; sets the tone once, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#generatetext" rel="noopener noreferrer"&gt;&lt;code&gt;generateText(...)&lt;/code&gt;&lt;/a&gt; returns a complete draft:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { Llm } from '@capawesome-team/capacitor-llm';&lt;/p&gt;

&lt;p&gt;const createReviewReplyChat = async () =&amp;gt; {&lt;br&gt;
  const { id } = await Llm.createChat({&lt;br&gt;
    instructions:&lt;br&gt;
      'You draft replies to customer reviews for an online shop. Thank the customer, respond to each point they raise, stay under 80 words and never promise refunds.',&lt;br&gt;
    temperature: 0.3,&lt;br&gt;
    maxOutputTokens: 200,&lt;br&gt;
  });&lt;br&gt;
  return id;&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const draftReply = async (chatId: string, review: string) =&amp;gt; {&lt;br&gt;
  const { text } = await Llm.generateText({&lt;br&gt;
    chatId,&lt;br&gt;
    prompt: &lt;code&gt;Draft a reply to this review:\n\n${review}&lt;/code&gt;,&lt;br&gt;
  });&lt;br&gt;
  return text;&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;The Android API has no native multi-turn sessions, so the plugin keeps the chat history in memory and includes it in every prompt. That history counts toward the input limit of about 4,000 tokens. Create one chat per review, and delete it with &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#deletechat" rel="noopener noreferrer"&gt;&lt;code&gt;deleteChat(...)&lt;/code&gt;&lt;/a&gt; once the reply is sent.&lt;/p&gt;

&lt;p&gt;The other two Android limits apply per chat or per request: &lt;code&gt;temperature&lt;/code&gt; must be between &lt;code&gt;0.0&lt;/code&gt; and &lt;code&gt;1.0&lt;/code&gt;, and &lt;code&gt;maxOutputTokens&lt;/code&gt; can be at most &lt;code&gt;4096&lt;/code&gt;. A low temperature such as &lt;code&gt;0.3&lt;/code&gt; gives more predictable drafts. Only one generation runs per chat at a time, and a second call rejects with &lt;code&gt;GENERATION_IN_PROGRESS&lt;/code&gt;, so disable the button while a draft is being written.&lt;/p&gt;

&lt;p&gt;A full draft takes several seconds to generate. Google's &lt;a href="https://android-developers.googleblog.com/2025/05/on-device-gen-ai-apis-ml-kit-gemini-nano.html" rel="noopener noreferrer"&gt;May 2025 benchmark&lt;/a&gt; measured a decode speed of 11 tokens per second on a Pixel 9 Pro, so a reply of a few sentences takes several seconds. &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#streamtext" rel="noopener noreferrer"&gt;&lt;code&gt;streamText(...)&lt;/code&gt;&lt;/a&gt; shows the draft while Gemini Nano writes it. The plugin emits a &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#addlistenertextchunk-" rel="noopener noreferrer"&gt;&lt;code&gt;textChunk&lt;/code&gt;&lt;/a&gt; event per piece, tagged with its &lt;code&gt;chatId&lt;/code&gt;, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/#cancelgeneration" rel="noopener noreferrer"&gt;&lt;code&gt;cancelGeneration(...)&lt;/code&gt;&lt;/a&gt; backs a discard button:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { Llm } from '@capawesome-team/capacitor-llm';&lt;/p&gt;

&lt;p&gt;const streamReply = async (chatId: string, review: string, onDraft: (draft: string) =&amp;gt; void) =&amp;gt; {&lt;br&gt;
  let draft = '';&lt;br&gt;
  const listener = await Llm.addListener('textChunk', event =&amp;gt; {&lt;br&gt;
    if (event.chatId === chatId) {&lt;br&gt;
      draft += event.text;&lt;br&gt;
      onDraft(draft);&lt;br&gt;
    }&lt;br&gt;
  });&lt;br&gt;
  try {&lt;br&gt;
    const { text } = await Llm.streamText({&lt;br&gt;
      chatId,&lt;br&gt;
      prompt: &lt;code&gt;Draft a reply to this review:\n\n${review}&lt;/code&gt;,&lt;br&gt;
    });&lt;br&gt;
    return text;&lt;br&gt;
  } catch (error) {&lt;br&gt;
    if ((error as { code?: string }).code === 'GENERATION_CANCELED') {&lt;br&gt;
      return null;&lt;br&gt;
    }&lt;br&gt;
    throw error;&lt;br&gt;
  } finally {&lt;br&gt;
    await listener.remove();&lt;br&gt;
  }&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const discardReply = async (chatId: string) =&amp;gt; {&lt;br&gt;
  await Llm.cancelGeneration({ chatId });&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;A canceled generation rejects with &lt;code&gt;GENERATION_CANCELED&lt;/code&gt;, which the example turns into &lt;code&gt;null&lt;/code&gt;. On Android, cancellation is best-effort, and a few more chunks may arrive after the user taps discard, so have the UI ignore updates for a draft it has already thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quota and foreground rules
&lt;/h2&gt;

&lt;p&gt;AICore decides how often and when your app may run Gemini Nano, and two rules from Google's &lt;a href="https://developers.google.com/ml-kit/genai#quota-per" rel="noopener noreferrer"&gt;ML Kit GenAI overview&lt;/a&gt; apply to every Capacitor app that uses it. The first one is a quota:&lt;/p&gt;

&lt;p&gt;&amp;gt; AICore enforces an inference quota per app. Making too many GenAI API requests in a short period will result in an &lt;code&gt;ErrorCode.BUSY&lt;/code&gt; response. When receiving such an error, consider using exponential backoff to retry the request. Also, &lt;code&gt;ErrorCode.PER_APP_BATTERY_USE_QUOTA_EXCEEDED&lt;/code&gt; can be returned if an app exceeds a long-duration quota (e.g. daily quota).&lt;/p&gt;

&lt;p&gt;The second one limits inference to the foreground, as the &lt;a href="https://developers.google.com/ml-kit/genai#background-usage" rel="noopener noreferrer"&gt;background usage section&lt;/a&gt; of the same page states:&lt;/p&gt;

&lt;p&gt;&amp;gt; GenAI API inference is permitted only when the app is the top foreground application. Using the API when the app is not in the foreground, including using a foreground service, will result in an &lt;code&gt;ErrorCode.BACKGROUND_USE_BLOCKED&lt;/code&gt; response.&lt;/p&gt;

&lt;p&gt;A Capacitor app runs its web code inside the app's own activity, so a generation that starts while the user looks at the screen meets the second rule. A retry timer, a queued request or a push handler that fires after the user switched apps does not.&lt;/p&gt;

&lt;p&gt;The Capacitor LLM plugin does not pass ML Kit's error code or its retry delay for quota errors to JavaScript. A quota or background rejection arrives as &lt;code&gt;GENERATION_FAILED&lt;/code&gt; with the platform's message, the same code the plugin uses for an exceeded context window and for content blocked by safety guardrails. Matching on the message text is fragile, so treat &lt;code&gt;GENERATION_FAILED&lt;/code&gt; as retryable with exponential backoff, cap the attempts, and stop retrying once the app is in the background. The helper below uses the official &lt;a href="https://capacitorjs.com/docs/apis/app" rel="noopener noreferrer"&gt;App plugin&lt;/a&gt; to check the app state:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;typescript&lt;br&gt;
import { App } from '@capacitor/app';&lt;br&gt;
import { Llm } from '@capawesome-team/capacitor-llm';&lt;/p&gt;

&lt;p&gt;const MAX_ATTEMPTS = 4;&lt;/p&gt;

&lt;p&gt;const delay = (milliseconds: number) =&amp;gt; new Promise(resolve =&amp;gt; setTimeout(resolve, milliseconds));&lt;/p&gt;

&lt;p&gt;const generateWithBackoff = async (chatId: string, prompt: string) =&amp;gt; {&lt;br&gt;
  for (let attempt = 1; ; attempt++) {&lt;br&gt;
    try {&lt;br&gt;
      const { text } = await Llm.generateText({ chatId, prompt });&lt;br&gt;
      return text;&lt;br&gt;
    } catch (error) {&lt;br&gt;
      const isRetryable = (error as { code?: string }).code === 'GENERATION_FAILED';&lt;br&gt;
      if (!isRetryable || attempt === MAX_ATTEMPTS) {&lt;br&gt;
        throw error;&lt;br&gt;
      }&lt;br&gt;
      await delay(1000 * 2 ** attempt);&lt;br&gt;
      const { isActive } = await App.getState();&lt;br&gt;
      if (!isActive) {&lt;br&gt;
        throw error;&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
};&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;The helper waits two, four and eight seconds between attempts and gives up after the fourth. Neither a daily quota nor a chat whose history outgrew the input limit recovers within that window, so show a "Try again" button when the helper throws and create a new chat for the next attempt. If your screen queues several drafts, pause the queue when the &lt;code&gt;appStateChange&lt;/code&gt; event reports &lt;code&gt;isActive: false&lt;/code&gt;. For streams, Google's &lt;a href="https://developers.google.com/android/reference/com/google/mlkit/genai/common/GenAiException.ErrorCode" rel="noopener noreferrer"&gt;error reference&lt;/a&gt; recommends removing an interrupted result from the UI, so clear the partial draft when &lt;code&gt;streamText(...)&lt;/code&gt; rejects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing on devices
&lt;/h2&gt;

&lt;p&gt;Testing Gemini Nano in a Capacitor app requires a phone from the Prompt API list, ideally one per Nano version you target. Frequent test runs in a short period can trigger the &lt;code&gt;BUSY&lt;/code&gt; quota error from the previous section. Google's &lt;a href="https://developers.google.com/ml-kit/genai/aicore-dev-preview" rel="noopener noreferrer"&gt;AICore Developer Preview&lt;/a&gt; program, which you join through the &lt;code&gt;aicore-experimental&lt;/code&gt; Google group and the AICore testing program on Google Play, adds a "Bypass quota limits" toggle to the AICore settings. The toggle exists only on Pixel phones and applies to every app on the device that uses AICore, so switch it off again before you test how your backoff behaves.&lt;/p&gt;

&lt;p&gt;The other thing to test is the SDK version. The plugin pulls in Google's &lt;code&gt;com.google.mlkit:genai-prompt&lt;/code&gt; dependency with a default of &lt;code&gt;1.0.0-beta2&lt;/code&gt;, while Google's latest release is &lt;code&gt;1.0.0-beta4&lt;/code&gt; from July 21, 2026. Google's &lt;a href="https://developers.google.com/ml-kit/release-notes" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; list compatibility fixes for Gemini Nano v4 in that release, so if the Pixel 11 or the Galaxy Z Flip8 and Z Fold8 are in your target group, test generation and streaming on one of them before you ship. The &lt;code&gt;$mlkitGenaiPromptVersion&lt;/code&gt; variable in &lt;code&gt;android/variables.gradle&lt;/code&gt; overrides the version. Treat that change like any other dependency upgrade and run your tests against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  ML Kit GenAI plugins
&lt;/h2&gt;

&lt;p&gt;The Capacitor ML Kit project maintains six free, open-source GenAI plugins that call Google's ML Kit GenAI APIs directly on Android, one per API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-summarization/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Summarization plugin&lt;/a&gt; for summaries of one to three bullet points&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-proofreading/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Proofreading plugin&lt;/a&gt; for grammar and spelling fixes&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-rewriting/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Rewriting plugin&lt;/a&gt; for six predefined styles such as Shorten, Friendly and Professional&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-image-description/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Image Description plugin&lt;/a&gt; for short image descriptions in English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-prompt/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Prompt plugin&lt;/a&gt; for single free-form prompts&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-speech-recognition/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Speech Recognition plugin&lt;/a&gt; for transcription, still in alpha&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Like the rest of Capacitor ML Kit, these plugins are unofficial and not affiliated with Google. Their lifecycle mirrors the one above under different names. &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-summarization/#checkfeaturestatus" rel="noopener noreferrer"&gt;&lt;code&gt;checkFeatureStatus(...)&lt;/code&gt;&lt;/a&gt; returns &lt;code&gt;AVAILABLE&lt;/code&gt;, &lt;code&gt;DOWNLOADABLE&lt;/code&gt;, &lt;code&gt;DOWNLOADING&lt;/code&gt; or &lt;code&gt;UNAVAILABLE&lt;/code&gt;, but unlike &lt;code&gt;getAvailability()&lt;/code&gt; it rejects on iOS and the web, so check the platform first. &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-summarization/#downloadfeature" rel="noopener noreferrer"&gt;&lt;code&gt;downloadFeature(...)&lt;/code&gt;&lt;/a&gt; fetches the feature, and its &lt;code&gt;downloadProgress&lt;/code&gt; event carries only &lt;code&gt;totalBytesDownloaded&lt;/code&gt;. Without the total size, no percentage is possible, so show an indeterminate bar with a byte counter. For the four feature-specific APIs the explicit download is optional, since Google states that the first inference request triggers it as well. Calling it first is what gives you progress events. These APIs also have their own &lt;a href="https://developers.google.com/ml-kit/genai#feature-device" rel="noopener noreferrer"&gt;device list&lt;/a&gt;, which includes phones that are missing from the Prompt list.&lt;/p&gt;

&lt;p&gt;Google's rule of thumb is to "use Prompt API when you need more customization and flexibility, and use the feature-specific APIs for standard tasks that don't require complex logic." The Capacitor LLM plugin adds chats, &lt;code&gt;textChunk&lt;/code&gt; streaming, cancellation and iOS support on top of the Prompt API. &lt;a href="https://capawesome.io/blog/capacitor-mlkit-8-2-0-release/" rel="noopener noreferrer"&gt;Capacitor ML Kit 8.2.0&lt;/a&gt; shows the check, download and summarize flow of the free plugins with code.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need to download Gemini Nano before using it?
&lt;/h3&gt;

&lt;p&gt;Yes, the model has to be on the device, but it may already be there. AICore stores one shared copy for all apps, so if another app downloaded it, &lt;code&gt;getAvailability()&lt;/code&gt; reports &lt;code&gt;available&lt;/code&gt;. Otherwise the status is &lt;code&gt;downloadable&lt;/code&gt;, and &lt;code&gt;downloadModel()&lt;/code&gt; fetches the model while the &lt;code&gt;downloadProgress&lt;/code&gt; event reports a value between 0 and 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Gemini Nano work offline?
&lt;/h3&gt;

&lt;p&gt;Yes, once the model is on the device. Google states that input, inference and output data are processed locally and that functionality "remains the same without reliable internet connection". The model download and AICore's configuration after a device setup need a connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which devices support Gemini Nano in a Capacitor app?
&lt;/h3&gt;

&lt;p&gt;Gemini Nano runs on the devices from Google's &lt;a href="https://developers.google.com/ml-kit/genai#prompt-device" rel="noopener noreferrer"&gt;Prompt API device list&lt;/a&gt;, such as the Pixel 9, Pixel 10 Pro, Pixel 11, Galaxy S26 Ultra, OnePlus 15 and Xiaomi 15. Each one needs Android API level 26 or higher and a locked bootloader. Check &lt;code&gt;getAvailability()&lt;/code&gt; at runtime instead of maintaining your own list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a generation reject with &lt;code&gt;GENERATION_FAILED&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;The Capacitor LLM plugin uses &lt;code&gt;GENERATION_FAILED&lt;/code&gt; for an exceeded context window, for content blocked by safety guardrails, and for AICore's quota and foreground rules. The error message carries the platform's reason. Retry with exponential backoff while the app is in the foreground, and create a new chat if the retries keep failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Gemini Nano in the background?
&lt;/h3&gt;

&lt;p&gt;No. Google permits GenAI inference only while the app is the top foreground application, and a foreground service does not change that. Start generations from user actions on screen, and move work that arrives in the background, such as a push notification, to the next time the user opens the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the Capacitor LLM plugin free?
&lt;/h3&gt;

&lt;p&gt;No. It is part of the &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt; subscription, which covers every Insiders plugin. Gemini Nano itself costs nothing per request, and the six Capacitor ML Kit GenAI plugins are free and open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/announcing-the-capacitor-llm-plugin/" rel="noopener noreferrer"&gt;Announcing the Capacitor LLM Plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/how-to-use-apple-intelligence-in-a-capacitor-app/" rel="noopener noreferrer"&gt;How to Use Apple Intelligence in a Capacitor App&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/capacitor-mlkit-8-2-0-release/" rel="noopener noreferrer"&gt;Capacitor ML Kit 8.2.0: 14 New Plugins&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stay in the loop
&lt;/h2&gt;

&lt;p&gt;Google changed the ML Kit GenAI APIs several times in 2026, from the Prompt API beta in January to the Gemini Nano v4 fixes in July. Plugin releases that follow those changes, and guides like this one, go out in the Capawesome newsletter first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Subscribe to the Capawesome Newsletter&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Pick the plugin by the shape of your feature. If a summary, a proofread or a rewrite on Android covers it, start with the free ML Kit GenAI plugin for that task. If you need free-form prompts, multi-turn chats or the same code on iOS, build on the Capacitor LLM plugin, and show the feature on every Android device where &lt;code&gt;getAvailability()&lt;/code&gt; does not report &lt;code&gt;unavailable&lt;/code&gt;, with a fallback everywhere else. The chat and streaming code from the review example runs unchanged on iPhones with Apple Intelligence, as &lt;a href="https://capawesome.io/blog/how-to-use-apple-intelligence-in-a-capacitor-app/" rel="noopener noreferrer"&gt;How to Use Apple Intelligence in a Capacitor App&lt;/a&gt; shows.&lt;/p&gt;

&lt;p&gt;If you have any questions, join us on the &lt;a href="https://discord.gg/VCXxSVjefW" rel="noopener noreferrer"&gt;Capawesome Discord server&lt;/a&gt;. To stay updated on the latest news, subscribe to the &lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Capawesome newsletter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>android</category>
      <category>ai</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Publish a Capacitor App on Huawei AppGallery</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:33:35 +0000</pubDate>
      <link>https://dev.to/capawesome/publish-a-capacitor-app-on-huawei-appgallery-2hpb</link>
      <guid>https://dev.to/capawesome/publish-a-capacitor-app-on-huawei-appgallery-2hpb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://capawesome.io/blog/how-to-publish-a-capacitor-app-on-huawei-appgallery/" rel="noopener noreferrer"&gt;Capawesome blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Yes, you can publish a Capacitor app on Huawei AppGallery with the same APK or App Bundle you build for Google Play, and Capacitor itself has no Google Play services dependency. The work is in your plugins. Huawei phones launched since late 2019 ship without Google services, so push notifications through Firebase Cloud Messaging, Google Maps and Google Play Billing need a check or a fallback before the first upload. This guide covers the plugin audit, the one-time setup in AppGallery Connect and automatic submission from Capawesome Cloud. Huawei puts AppGallery at more than 580 million monthly active users in more than 170 countries and regions, according to its &lt;a href="https://www.prnewswire.com/news-releases/huawei-appgallery-wraps-up-record-breaking-gamescom-2025-with-exclusive-offers-for-player-community-302546813.html" rel="noopener noreferrer"&gt;September 2025 figures&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F50wpzv8k3u58p57rmy5d.png" alt="Try Capawesome Cloud free for 14 days — iOS builds without a Mac" width="800" height="111"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AppGallery accepts the APK or App Bundle you already build for Google Play, so a Capacitor app needs no separate build for Huawei.&lt;/li&gt;
&lt;li&gt;Capacitor core depends only on AndroidX and the Cordova framework library, so Google Play services problems on Huawei phones come from individual plugins.&lt;/li&gt;
&lt;li&gt;Without Google Play services, FCM, Google Maps, Google Sign-In, Play Billing and unbundled ML Kit modules fail, while Firestore, Crashlytics, Firebase email sign-in and bundled ML Kit models keep working.&lt;/li&gt;
&lt;li&gt;The Capacitor Google Play Services plugin's &lt;code&gt;getStatus()&lt;/code&gt; method resolves to &lt;code&gt;SERVICE_MISSING&lt;/code&gt; on Huawei devices, so the app can hide features that need Google Play services.&lt;/li&gt;
&lt;li&gt;A Capawesome Cloud destination uploads builds to AppGallery Connect and, with the release status Completed, submits them for review, which usually takes 1 to 3 working days.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Same build, different store
&lt;/h2&gt;

&lt;p&gt;AppGallery Connect accepts Android apps as APK or App Bundle (AAB), according to Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-release-overview-0000001272395372" rel="noopener noreferrer"&gt;release overview&lt;/a&gt;, so publishing a Capacitor app on Huawei AppGallery starts from the package you already build for Google Play. The Capacitor Android library declares only AndroidX libraries and the Cordova framework as dependencies in its &lt;a href="https://github.com/ionic-team/capacitor/blob/main/android/capacitor/build.gradle" rel="noopener noreferrer"&gt;build file&lt;/a&gt;, and Capacitor's &lt;a href="https://capacitorjs.com/docs/android" rel="noopener noreferrer"&gt;Android requirements&lt;/a&gt; ask for API level 24 and a WebView with Chrome 60 or later, not for Google Play services. The app template applies the &lt;code&gt;google-services&lt;/code&gt; Gradle plugin when it finds a &lt;code&gt;google-services.json&lt;/code&gt; file, and Firebase states that this build-time plugin is &lt;a href="https://firebase.google.com/docs/android/android-play-services" rel="noopener noreferrer"&gt;"not related to Google Play services"&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you build with &lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;Capawesome Cloud&lt;/a&gt;, you have both formats after every build, because it &lt;a href="https://capawesome.io/docs/cloud/native-builds/android-app-bundles/" rel="noopener noreferrer"&gt;builds an AAB alongside the APK&lt;/a&gt; for each Android application module.&lt;/p&gt;

&lt;p&gt;The two formats follow different rules on AppGallery. Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releaseapkrpk-0000001106463276" rel="noopener noreferrer"&gt;APK release guide&lt;/a&gt; requires an APK smaller than 4 GB, a 64-bit package for phones and a single APK per version, and Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-appsigning-introduction-0000001051379577" rel="noopener noreferrer"&gt;App Signing service&lt;/a&gt; is optional for APKs. An AAB works only after you sign the App Signing agreement and configure a key, because AppGallery Connect compiles the bundle into APKs and re-signs them in 2 to 5 minutes, and it sends back any bundle whose download size exceeds 150 MB (&lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releasebundle-0000001100316672" rel="noopener noreferrer"&gt;AAB release guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;We recommend the APK, signed with your own keystore through the &lt;a href="https://capawesome.io/docs/cloud/native-builds/certificates/android/" rel="noopener noreferrer"&gt;Android signing certificate&lt;/a&gt; you upload to Capawesome Cloud. The key stays with you, there is no agreement to sign, and the 150 MB limit doesn't apply. Pick that key with care, since Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releasefaq-0000001110342644" rel="noopener noreferrer"&gt;release FAQ&lt;/a&gt; requires every update to keep the package name and the signature of the version already on AppGallery. The destination guide shows where you choose the format, under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#publishing-format" rel="noopener noreferrer"&gt;Publishing format&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which phones can install that APK depends on the operating system. Huawei phones sold outside China, such as the &lt;a href="https://consumer.huawei.com/en/phones/pura80/specs/" rel="noopener noreferrer"&gt;Pura 80&lt;/a&gt; and the &lt;a href="https://consumer.huawei.com/en/phones/mate80-pro/specs/" rel="noopener noreferrer"&gt;Mate 80 Pro&lt;/a&gt;, run Android-based EMUI and install your APK from AppGallery, while HarmonyOS 5 and 6 phones in China run only native HarmonyOS apps, according to Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/harmonyos-guides/app-data-migration-overview" rel="noopener noreferrer"&gt;data migration guide&lt;/a&gt; and &lt;a href="https://www.scmp.com/tech/tech-war/article/3288398/huawei-has-global-ambitions-pure-blood-os-china-focus-now" rel="noopener noreferrer"&gt;reporting by SCMP&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google Play services audit
&lt;/h2&gt;

&lt;p&gt;Google Play services is the dependency to check before your first AppGallery submission, because Huawei phones have shipped without it since the Mate 30 series. &lt;a href="https://www.cnbc.com/2019/09/19/huawei-mate-30-announced-specs-price-android.html" rel="noopener noreferrer"&gt;CNBC reported&lt;/a&gt; at the September 2019 launch that the phones came without the Play Store and Google's apps, four months after the US Commerce Department added Huawei to its &lt;a href="https://www.federalregister.gov/documents/2019/05/21/2019-10616/addition-of-entities-to-the-entity-list" rel="noopener noreferrer"&gt;Entity List&lt;/a&gt;. Google's &lt;a href="https://developers.google.com/android/guides/setup" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; states that devices without the Google Play Store don't have Google Play services and tells apps to check for it before they call Google APIs.&lt;/p&gt;

&lt;p&gt;A Google feature that fails on a Huawei phone is also a review risk. Huawei's guideline &lt;a href="https://developer.huawei.com/consumer/en/doc/app/50104-03" rel="noopener noreferrer"&gt;3.1&lt;/a&gt; expects an app to work properly on mainstream Huawei devices, and its &lt;a href="https://developer.huawei.com/consumer/en/doc/distribution/app/50106" rel="noopener noreferrer"&gt;common rejection reasons&lt;/a&gt; include crashes and launches with a blank screen.&lt;/p&gt;

&lt;p&gt;The table maps common Capacitor plugins to their Google dependency, based on each plugin's Android build file and documentation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Google services dependency&lt;/th&gt;
&lt;th&gt;On Huawei devices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@capacitor/push-notifications&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://firebase.google.com/docs/cloud-messaging/android/client" rel="noopener noreferrer"&gt;Firebase Cloud Messaging&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/firebase/cloud-messaging/" rel="noopener noreferrer"&gt;Capacitor Firebase Cloud Messaging plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Firebase Cloud Messaging&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@capacitor/google-maps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developers.google.com/maps/documentation/android-sdk/config" rel="noopener noreferrer"&gt;Maps SDK for Android&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/google-sign-in/" rel="noopener noreferrer"&gt;Capacitor Google Sign-In plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;play-services-auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/purchases/" rel="noopener noreferrer"&gt;Capacitor Purchases plugin&lt;/a&gt; (Insiders)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/google/play/billing" rel="noopener noreferrer"&gt;Google Play Billing&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/app-update/" rel="noopener noreferrer"&gt;Capacitor App Update plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/guide/playcore/in-app-updates" rel="noopener noreferrer"&gt;Play in-app updates&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/app-review/" rel="noopener noreferrer"&gt;Capacitor App Review plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/guide/playcore/in-app-review" rel="noopener noreferrer"&gt;Play in-app review&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/app-integrity/" rel="noopener noreferrer"&gt;Capacitor App Integrity plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/google/play/integrity/overview" rel="noopener noreferrer"&gt;Play Integrity&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/document-scanner/" rel="noopener noreferrer"&gt;Capacitor Document Scanner plugin&lt;/a&gt; (Insiders)&lt;/td&gt;
&lt;td&gt;Play services ML Kit module&lt;/td&gt;
&lt;td&gt;Breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/watch/" rel="noopener noreferrer"&gt;Capacitor Watch plugin&lt;/a&gt; (Insiders)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;play-services-wearable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Breaks, all methods reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/" rel="noopener noreferrer"&gt;Capacitor ML Kit Barcode Scanning plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Bundled model, Google code scanner&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;startScan(...)&lt;/code&gt; works, &lt;code&gt;scan(...)&lt;/code&gt; breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/barcode-scanner/" rel="noopener noreferrer"&gt;Capacitor Barcode Scanner plugin&lt;/a&gt; (Insiders)&lt;/td&gt;
&lt;td&gt;Bundled ML Kit model&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@capacitor/geolocation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;play-services-location&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works, falls back to &lt;code&gt;LocationManager&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://capawesome.io/docs/sdks/capacitor/background-geolocation/" rel="noopener noreferrer"&gt;Capacitor Background Geolocation plugin&lt;/a&gt; (Insiders)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;play-services-location&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works, falls back to the platform location manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/maplibre/" rel="noopener noreferrer"&gt;Capacitor MapLibre plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/live-update/" rel="noopener noreferrer"&gt;Capacitor Live Update plugin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Not affected&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plugins marked Insiders are part of the paid &lt;a href="https://capawesome.io/docs/insiders/" rel="noopener noreferrer"&gt;Capawesome Insiders&lt;/a&gt; subscription.&lt;/p&gt;

&lt;p&gt;The barcode rows split on where the ML Kit model lives. Google &lt;a href="https://developers.google.com/ml-kit/vision/barcode-scanning/android" rel="noopener noreferrer"&gt;documents&lt;/a&gt; that an unbundled model is downloaded through Google Play services, while a bundled model is linked into the app at build time and adds about 2.4 MB. In the Capacitor ML Kit Barcode Scanning plugin, &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#scan" rel="noopener noreferrer"&gt;&lt;code&gt;scan(...)&lt;/code&gt;&lt;/a&gt; hands the whole scan to the Google code scanner inside Google Play services, and &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/barcode-scanning/#startscan" rel="noopener noreferrer"&gt;&lt;code&gt;startScan(...)&lt;/code&gt;&lt;/a&gt; uses the bundled model.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@capacitor/geolocation&lt;/code&gt; keeps working because its &lt;code&gt;enableLocationFallback&lt;/code&gt; option defaults to &lt;code&gt;true&lt;/code&gt; and switches to Android's &lt;code&gt;LocationManager&lt;/code&gt; when the Google Play services location checks fail. Its &lt;a href="https://github.com/ionic-team/capacitor-geolocation/blob/main/README.md" rel="noopener noreferrer"&gt;README&lt;/a&gt; warns that &lt;code&gt;LocationManager&lt;/code&gt; "may not be as effective" as the Google Play services implementation. The &lt;a href="https://capawesome.io/docs/sdks/capacitor/geofences/" rel="noopener noreferrer"&gt;Capacitor Geofences plugin&lt;/a&gt; (Insiders) uses Google Play services' &lt;code&gt;GeofencingClient&lt;/code&gt; and documents no fallback. In-app updates through the Capacitor App Update plugin need the Play Store, and its &lt;a href="https://capawesome.io/docs/sdks/capacitor/app-update/#openappstore" rel="noopener noreferrer"&gt;&lt;code&gt;openAppStore(...)&lt;/code&gt;&lt;/a&gt; method opens the app's Play Store entry, the wrong store for an AppGallery install.&lt;/p&gt;

&lt;p&gt;Firebase publishes its own &lt;a href="https://firebase.google.com/docs/android/android-play-services" rel="noopener noreferrer"&gt;per-SDK list&lt;/a&gt;. Cloud Messaging has no functionality without Google Play services, and neither does the App Check Play Integrity provider, which the &lt;a href="https://capawesome.io/docs/sdks/capacitor/firebase/app-check/" rel="noopener noreferrer"&gt;Capacitor Firebase App Check plugin&lt;/a&gt; uses on Android. Analytics still sends events, but automatic insights such as demographics need Google Play services. Authentication, Cloud Firestore, Crashlytics, Remote Config, Cloud Storage and Realtime Database don't need it. The exception inside Authentication is the Google provider of the &lt;a href="https://capawesome.io/docs/sdks/capacitor/firebase/authentication/" rel="noopener noreferrer"&gt;Capacitor Firebase Authentication plugin&lt;/a&gt;, which adds &lt;code&gt;play-services-auth&lt;/code&gt; when you enable it, so email and password sign-in keeps working while Google sign-in doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Degrade gracefully
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/" rel="noopener noreferrer"&gt;Capacitor Google Play Services plugin&lt;/a&gt; tells your app at runtime whether Google Play services exists on the device, so you can hide the features from the table before a Huawei user or reviewer opens them. The plugin is free, depends only on &lt;code&gt;play-services-base&lt;/code&gt;, and its documentation states that the check works on any Android device, including devices without Google Play services. To install it, refer to the &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#installation" rel="noopener noreferrer"&gt;Installation&lt;/a&gt; section of the plugin documentation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#getstatus" rel="noopener noreferrer"&gt;&lt;code&gt;getStatus()&lt;/code&gt;&lt;/a&gt; resolves to &lt;code&gt;SERVICE_MISSING&lt;/code&gt; when Google Play services is not installed at all, which the plugin documentation names as the case for Huawei devices with Huawei Mobile Services. The following example skips push registration and turns off the map screen on those devices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Capacitor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PushNotifications&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capacitor/push-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GooglePlayServices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome/capacitor-google-play-services&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maps&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="na"&gt;pushNotifications&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isGooglePlayServicesMissing&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;Capacitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPlatform&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;GooglePlayServices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ServiceMissing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initializeFeatures&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isGooglePlayServicesMissing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushNotifications&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;missing&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="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushNotifications&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PushNotifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&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 platform check keeps iOS and web builds away from the plugin, which is Android-only and rejects every call on other platforms. Statuses such as &lt;code&gt;SERVICE_DISABLED&lt;/code&gt; and &lt;code&gt;SERVICE_VERSION_UPDATE_REQUIRED&lt;/code&gt; mean Google Play services is installed but disabled or outdated, which &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#makeavailable" rel="noopener noreferrer"&gt;&lt;code&gt;makeAvailable()&lt;/code&gt;&lt;/a&gt; can fix with the system dialog. On a Huawei phone, &lt;code&gt;makeAvailable()&lt;/code&gt; is rejected, because there is no Play Store to install Google Play services from.&lt;/p&gt;

&lt;p&gt;Hiding a feature is the minimum. When Huawei users need the feature itself, HMS Core has replacements. For push, that is &lt;a href="https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/service-introduction-0000001050040060" rel="noopener noreferrer"&gt;Push Kit&lt;/a&gt;, and since there is no official Capacitor HMS plugin, Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/HMS-Plugin-Guides-V1/push-integrating-cordova-sdk-0000001050135717-V1" rel="noopener noreferrer"&gt;documented Capacitor route&lt;/a&gt; is its Cordova plugin &lt;code&gt;@hmscore/cordova-plugin-hms-push&lt;/code&gt;, while devices with Google Play services keep the FCM setup from &lt;a href="https://capawesome.io/blog/capacitor-push-notifications-guide/" rel="noopener noreferrer"&gt;Capacitor Push Notifications: The Complete Guide&lt;/a&gt;. &lt;a href="https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/android-sdk-introduction-0000001061991291" rel="noopener noreferrer"&gt;Map Kit&lt;/a&gt;, &lt;a href="https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/introduction-0000001050048870" rel="noopener noreferrer"&gt;Account Kit&lt;/a&gt; and &lt;a href="https://developer.huawei.com/consumer/en/doc/HMSCore-Guides/introduction-0000001050033062" rel="noopener noreferrer"&gt;HUAWEI IAP&lt;/a&gt; cover maps, sign-in with a HUAWEI ID and in-app purchases, and AppGallery guideline &lt;a href="https://developer.huawei.com/consumer/en/doc/app/50104-06" rel="noopener noreferrer"&gt;6.4&lt;/a&gt; recommends Huawei IAP for apps with in-app purchases without requiring it. Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/development/Tools-Guides/overview-0000001050060881" rel="noopener noreferrer"&gt;HMS Toolkit&lt;/a&gt; is no shortcut here. Its Convertor rewrites Java and Kotlin projects, not a web layer, and Huawei is sunsetting the toolkit, with overseas services ending on December 30, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  AppGallery Connect setup
&lt;/h2&gt;

&lt;p&gt;AppGallery Connect requires a verified HUAWEI Developers account before you can create an app, and the account is &lt;a href="https://developer.huawei.com/consumer/en/doc/help/identityverfication-0000001053292680" rel="noopener noreferrer"&gt;free to register&lt;/a&gt; for individual and enterprise developers. Individuals verify with a bank document plus an ID card, passport or driver's license, and the bank document has to show the full name in English and stay valid for at least 6 months (&lt;a href="https://developer.huawei.com/consumer/en/doc/start/ibca-0000001062388135" rel="noopener noreferrer"&gt;individual verification&lt;/a&gt;). Enterprises submit a business license or a &lt;a href="https://developer.huawei.com/consumer/en/doc/start/atpopb-0000001062836624" rel="noopener noreferrer"&gt;DUNS number&lt;/a&gt; renewed within the last two years. Huawei reviews either within 1 to 2 working days and emails the result. Two choices are permanent. An individual account can't become an enterprise account later, and the country or region you select during verification can't be changed.&lt;/p&gt;

&lt;p&gt;With the account verified, &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-createapk-0000001912873100" rel="noopener noreferrer"&gt;create the Android app&lt;/a&gt; under &lt;strong&gt;Apps and atomic services&lt;/strong&gt; on the &lt;strong&gt;Android&lt;/strong&gt; tab, with APK as the package type. The device type "Mobile phone" includes tablets. The package name and the App or Game category are fixed once set, and the app name has to match the one in your package.&lt;/p&gt;

&lt;p&gt;The first version needs a store listing, which Huawei specifies in its &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releaseapkrpk-0000001106463276" rel="noopener noreferrer"&gt;APK release guide&lt;/a&gt; and its &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-app-material-requirement-0000001146534651" rel="noopener noreferrer"&gt;asset requirements&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Icon&lt;/strong&gt;: 216 × 216 or 512 × 512 pixels, PNG up to 2 MB, not animated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots&lt;/strong&gt;: 3 to 8 images at 800 × 450 or 450 × 800 pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introductions&lt;/strong&gt;: A brief introduction of up to 80 characters, with 25 or fewer advised, and a full introduction of up to 8,000 characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age rating&lt;/strong&gt;: A questionnaire that produces the rating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy policy&lt;/strong&gt;: A URL if the app collects user data, plus an easy-to-find link inside the app under guideline &lt;a href="https://developer.huawei.com/consumer/en/doc/app/50104-07" rel="noopener noreferrer"&gt;7.1&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language&lt;/strong&gt;: English (US) in the app package and on the App information page, if you distribute outside the Chinese mainland.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test account&lt;/strong&gt;: Sign-in details for Huawei's reviewers if the app requires sign-in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Guideline &lt;a href="https://developer.huawei.com/consumer/en/doc/app/50104-01" rel="noopener noreferrer"&gt;1.16&lt;/a&gt; bans names, icons and imagery of other device brands and app platforms from your listing, so screenshots with a Google Play badge have to go. If the same app is already on Google Play, AppGallery can reject it as a duplicate. Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releasefaq-0000001110342644" rel="noopener noreferrer"&gt;release FAQ&lt;/a&gt; asks the owner to email Huawei operations from the contact details used on Google Play, with the developer name and email address of both accounts. For assets you share with Apple and Google, see &lt;a href="https://capawesome.io/blog/how-to-prepare-your-app-store-listing/" rel="noopener noreferrer"&gt;How to Prepare Your App Store Listing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The last one-time step is the API client that Capawesome Cloud uses to reach AppGallery Connect, which the destination guide covers under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#obtaining-credentials" rel="noopener noreferrer"&gt;Obtaining credentials&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submit with Capawesome Cloud
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/" rel="noopener noreferrer"&gt;Huawei AppGallery destination&lt;/a&gt; lets Capawesome Cloud upload a finished Android build to AppGallery Connect, so nobody has to upload APKs in the browser. You create it once, in the Console with the fields listed under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#configuration" rel="noopener noreferrer"&gt;Configuration&lt;/a&gt; or with &lt;a href="https://capawesome.io/docs/cloud/cli/commands/#appsdestinationscreate" rel="noopener noreferrer"&gt;&lt;code&gt;apps:destinations:create&lt;/code&gt;&lt;/a&gt; from the Capawesome CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @capawesome/cli apps:destinations:create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app-id&lt;/span&gt; 00000000-0000-0000-0000-000000000000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"AppGallery Production"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; android &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; huawei-appgallery &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--huawei-app-id&lt;/span&gt;  &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--huawei-client-id&lt;/span&gt;  &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--huawei-client-secret&lt;/span&gt;  &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--android-build-artifact-type&lt;/span&gt; apk &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--android-release-status&lt;/span&gt; completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--app-id&lt;/code&gt; is the ID of your app in Capawesome Cloud, while the three &lt;code&gt;--huawei-*&lt;/code&gt; values come from AppGallery Connect as described under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#obtaining-credentials" rel="noopener noreferrer"&gt;Obtaining credentials&lt;/a&gt;. The artifact type follows the APK recommendation above, and the release status is explained below. Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agcapi-publish_api_overview-0000001111685332" rel="noopener noreferrer"&gt;Publishing API&lt;/a&gt; can neither create an app nor complete its content rating, which is why the listing from the previous section stays a one-time manual step.&lt;/p&gt;

&lt;p&gt;From then on, a submission is one command. &lt;a href="https://capawesome.io/docs/cloud/cli/commands/#appsdeploymentscreate" rel="noopener noreferrer"&gt;&lt;code&gt;apps:deployments:create&lt;/code&gt;&lt;/a&gt; takes the build number and the destination name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @capawesome/cli apps:deployments:create &lt;span class="nt"&gt;--build-number&lt;/span&gt; 42 &lt;span class="nt"&gt;--destination&lt;/span&gt; &lt;span class="s2"&gt;"AppGallery Production"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Console, the Deployments page does the same with &lt;strong&gt;Create deployment&lt;/strong&gt;. The destination's release status decides whether the upload stays a draft for you to submit in AppGallery Connect or goes to review automatically, as the guide explains under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#release-status" rel="noopener noreferrer"&gt;Release status&lt;/a&gt;. The same build number can go to a &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/google-play-store/" rel="noopener noreferrer"&gt;Google Play Store destination&lt;/a&gt; in a second deployment, so one &lt;a href="https://capawesome.io/docs/cloud/native-builds/" rel="noopener noreferrer"&gt;native build&lt;/a&gt; serves both stores.&lt;/p&gt;

&lt;p&gt;To take the command out of your release process, attach the destination and your signing certificate to an automation that triggers on &lt;code&gt;v*&lt;/code&gt; tags. Every matching tag then runs the pipeline from Git to build to AppGallery Connect, as described in &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/submit-automatically/" rel="noopener noreferrer"&gt;Submit Automatically After a Build&lt;/a&gt; and the &lt;a href="https://capawesome.io/docs/cloud/automations/auto-submit/" rel="noopener noreferrer"&gt;auto-submit walkthrough&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A successful deployment means Huawei has the build, not that users can install it (&lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/submit-a-build/#after-submitting" rel="noopener noreferrer"&gt;After submitting&lt;/a&gt;). Huawei's &lt;a href="https://developer.huawei.com/consumer/en/doc/app/agc-help-releaseapkrpk-0000001106463276" rel="noopener noreferrer"&gt;APK release guide&lt;/a&gt; says app review usually takes 1 to 3 working days and that rejections arrive by email. Reviewers also check each release country or region against local policies and remove the ones where the app doesn't comply. If a deployment fails because another version is still under review, the guide covers the fix under &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/huawei-appgallery/#submission-fails-because-a-version-is-under-review" rel="noopener noreferrer"&gt;Submission fails because a version is under review&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I publish my Capacitor app on Huawei AppGallery?
&lt;/h3&gt;

&lt;p&gt;Yes. AppGallery Connect accepts the same APK or AAB you build for Google Play, and Capacitor core has no Google Play services dependency. Check your plugins first, because Huawei phones ship without Google Play services, and features such as FCM push notifications, Google Maps and Google Play Billing don't work there. A Capawesome Cloud destination can then upload and submit every release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do Huawei phones still run Android apps?
&lt;/h3&gt;

&lt;p&gt;Huawei phones sold outside China do. Huawei's international models run Android-based EMUI, currently EMUI 15 and 16, and install APKs from AppGallery. Phones in China that run HarmonyOS 5 or 6 accept only native HarmonyOS apps, so a Capacitor APK doesn't reach them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Capacitor need Google Play services?
&lt;/h3&gt;

&lt;p&gt;No. The Capacitor Android library depends only on AndroidX and the Cordova framework. Google Play services comes in through plugins such as &lt;code&gt;@capacitor/push-notifications&lt;/code&gt;, which uses Firebase Cloud Messaging, and &lt;code&gt;@capacitor/google-maps&lt;/code&gt;, which uses the Maps SDK for Android. That is why the audit happens at the plugin level.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does AppGallery review take?
&lt;/h3&gt;

&lt;p&gt;Huawei's current release documentation says app review usually takes 1 to 3 working days, and a rejection arrives by email. If your developer account is new, add 1 to 2 working days for identity verification before your first submission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I upload the same APK to Google Play and AppGallery?
&lt;/h3&gt;

&lt;p&gt;Yes. AppGallery takes the APK you build for Google Play as it is. If Google Play signs your app through Play App Signing, installs from Google Play carry the app signing key that Google holds, while installs from AppGallery carry the key you signed the APK with, so the two usually report different certificate fingerprints.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Capawesome Cloud submit to AppGallery automatically?
&lt;/h3&gt;

&lt;p&gt;Yes. A Huawei AppGallery destination uploads the build to AppGallery Connect and, with the release status Completed, submits the version for review. Attach the destination to an automation that triggers on &lt;code&gt;v*&lt;/code&gt; tags, and every release tag goes from Git to AppGallery without a manual step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/11-steps-to-get-your-web-app-on-the-app-store/" rel="noopener noreferrer"&gt;Convert a Web App to a Mobile App in 11 Steps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/how-to-sign-and-build-your-capacitor-app-in-the-cloud/" rel="noopener noreferrer"&gt;How to Sign &amp;amp; Build Capacitor Apps in the Cloud&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://capawesome.io/blog/how-to-prepare-your-app-store-listing/" rel="noopener noreferrer"&gt;How to Prepare Your App Store Listing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Once your Android builds run in Capawesome Cloud, AppGallery is one destination away. Upload your keystore, add a Huawei AppGallery destination and push a &lt;code&gt;v*&lt;/code&gt; tag to watch the first version arrive in AppGallery Connect. You can run all of it during the 14-day free trial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://capawesome.io" rel="noopener noreferrer"&gt;Try Capawesome Cloud Free&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Run the audit before the first upload. If none of your plugins lands in a "Breaks" row, submit the APK you already build. If one does, gate it with &lt;a href="https://capawesome.io/docs/sdks/capacitor/google-play-services/#getstatus" rel="noopener noreferrer"&gt;&lt;code&gt;getStatus()&lt;/code&gt;&lt;/a&gt;, ship without that feature on Huawei phones, and add an HMS replacement only when Huawei users can't do without it. Sign the APK with your own keystore unless you want Huawei to hold the key, and hand the submissions to a tag automation once the first listing is live.&lt;/p&gt;

&lt;p&gt;Before that first submission, put the APK on a Huawei phone without a store, as described in &lt;a href="https://capawesome.io/blog/how-to-distribute-ios-and-android-apps-to-testers/" rel="noopener noreferrer"&gt;How to Distribute iOS and Android Apps to Testers&lt;/a&gt;. Questions about a specific plugin on Huawei devices are welcome on the &lt;a href="https://discord.gg/VCXxSVjefW" rel="noopener noreferrer"&gt;Capawesome Discord server&lt;/a&gt;, and the &lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;Capawesome newsletter&lt;/a&gt; announces new plugins and Cloud features as they ship.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>android</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build and Deploy iOS Apps with Gitea Actions</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Sun, 27 Sep 2026 14:00:22 +0000</pubDate>
      <link>https://dev.to/capawesome/build-and-deploy-ios-apps-with-gitea-actions-10eh</link>
      <guid>https://dev.to/capawesome/build-and-deploy-ios-apps-with-gitea-actions-10eh</guid>
      <description>&lt;p&gt;An iOS build needs Xcode, Xcode needs macOS, and Gitea Actions doesn't come with a Mac. Your Node, Go, or Android jobs run fine on Linux runners, but the iOS build has nowhere to go. In this guide, we'll set up Gitea Actions iOS builds that start on the Linux runner you already have and hand the macOS work to Capawesome Cloud, which compiles and signs the app and uploads it to TestFlight. We'll also cover the self-hosted Mac alternative, a Gitea server behind a firewall, and a setup that needs no workflow file at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;gitea.com registers runners only for its &lt;code&gt;gitea&lt;/code&gt; organization, and Gitea Cloud names no OS for its on-demand runners, so iOS builds need your own Mac or a build service.&lt;/li&gt;
&lt;li&gt;Gitea Runner can run jobs directly on a Mac through a &lt;code&gt;host&lt;/code&gt; label, but those jobs run without isolation, and Xcode, signing, and uploads stay your responsibility.&lt;/li&gt;
&lt;li&gt;A Gitea Actions job on &lt;code&gt;ubuntu-latest&lt;/code&gt; can run &lt;code&gt;apps:builds:create&lt;/code&gt;, which builds, signs, and submits the app to TestFlight on Capawesome Cloud's Apple Silicon M4 machines.&lt;/li&gt;
&lt;li&gt;If Capawesome Cloud can't reach your Gitea server, the runner checks out the code and uploads it with &lt;code&gt;--path&lt;/code&gt;, so no inbound firewall rule is needed.&lt;/li&gt;
&lt;li&gt;Capawesome Cloud Automations build on Gitea tag pushes through a webhook, with no workflow file and no runner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  macOS runners on Gitea
&lt;/h2&gt;

&lt;p&gt;Gitea Actions only runs jobs on runners that someone has registered with the instance, so the runners available to you depend on where your Gitea lives. On gitea.com, the &lt;a href="https://docs.gitea.com/usage/actions/faq/" rel="noopener noreferrer"&gt;Gitea Actions FAQ&lt;/a&gt; states that runners are registered only for the &lt;code&gt;gitea&lt;/code&gt; organization's repositories, so every other user brings their own. &lt;a href="https://about.gitea.com/products/cloud/" rel="noopener noreferrer"&gt;Gitea Cloud&lt;/a&gt;, the managed offering from CommitGo, starts runners on demand, but neither its product page nor its &lt;a href="https://about.gitea.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; names an operating system, and the Enterprise plan lists Kubernetes autoscaling runners. A self-hosted Gitea instance has exactly the runners your team registers.&lt;/p&gt;

&lt;p&gt;An iOS build can't avoid macOS for its final steps. Xcode, &lt;code&gt;xcodebuild&lt;/code&gt;, and &lt;code&gt;codesign&lt;/code&gt; only run on Apple hardware, whether the project is native Swift, &lt;a href="https://capawesome.io/integrations/capacitor/" rel="noopener noreferrer"&gt;Capacitor&lt;/a&gt;, or Cordova. That leaves two options. You can register a Mac as a runner, or keep the job on Linux and send the build to a service that runs Macs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosting a Mac runner
&lt;/h2&gt;

&lt;p&gt;Gitea Runner (formerly act_runner) ships &lt;a href="https://docs.gitea.com/runner/installation/binary/" rel="noopener noreferrer"&gt;official macOS binaries&lt;/a&gt;, so a Mac mini can pick up Gitea Actions jobs like any other runner. To run jobs directly on the Mac instead of in a container, register the runner with a &lt;code&gt;host&lt;/code&gt; label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitea-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://gitea.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt;  &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; mac-mini-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--labels&lt;/span&gt; macos:host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A workflow then targets the Mac with &lt;code&gt;runs-on: macos&lt;/code&gt;, and every step uses the Xcode installed on that 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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-ios&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;macos&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;xcodebuild -project MyApp.xcodeproj -scheme MyApp -archivePath build/MyApp.xcarchive archive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.gitea.com/runner/" rel="noopener noreferrer"&gt;Gitea Runner documentation&lt;/a&gt; lists the trade-offs of host mode. Jobs on a &lt;code&gt;host&lt;/code&gt; label run without isolation from each other, a &lt;code&gt;docker://&lt;/code&gt; action or a service container still needs a Docker daemon on the Mac, and the &lt;a href="https://docs.gitea.com/runner/labels/" rel="noopener noreferrer"&gt;labels guide&lt;/a&gt; recommends distinct label names so a workflow written for &lt;code&gt;ubuntu-latest&lt;/code&gt; never runs unsandboxed on your machine. The binary installation guide includes a launchd plist that keeps the runner alive across reboots.&lt;/p&gt;

&lt;p&gt;The archive step above is the easy part. Everything after it is yours to build and maintain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Xcode upgrades.&lt;/strong&gt; When Apple raises the minimum SDK for App Store submissions, every Mac runner needs the new Xcode installed and tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signing.&lt;/strong&gt; The distribution certificate lives in a keychain that has to be unlocked for non-interactive jobs, and provisioning profiles have to be installed and renewed on each machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export and upload.&lt;/strong&gt; &lt;code&gt;xcodebuild -exportArchive&lt;/code&gt; needs an &lt;code&gt;ExportOptions.plist&lt;/code&gt;, and the TestFlight upload needs App Store Connect credentials and a tool such as fastlane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The machine itself.&lt;/strong&gt; macOS updates, disk space filled by DerivedData and old archives, and builds queueing behind each other on a single Mac.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team already runs Macs and someone owns them, this setup works. Otherwise, the rest of this guide keeps the job on Linux.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offloading to Capawesome Cloud
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://capawesome.io/" rel="noopener noreferrer"&gt;Capawesome Cloud&lt;/a&gt; runs iOS builds on Apple Silicon M4 machines and exposes them through the &lt;a href="https://capawesome.io/docs/cloud/cli/" rel="noopener noreferrer"&gt;Capawesome CLI&lt;/a&gt;, so a Gitea Actions job only needs Node.js to start one. The job runs &lt;code&gt;apps:builds:create&lt;/code&gt; on your existing &lt;code&gt;ubuntu-latest&lt;/code&gt; runner. Capawesome Cloud then clones the commit from your Gitea server through a Git connection, runs the web build and native sync for Capacitor and Cordova apps, compiles with Xcode, signs the app with a certificate you uploaded once, and can submit the result to TestFlight.&lt;/p&gt;

&lt;p&gt;The default Gitea Runner image (&lt;code&gt;docker.gitea.com/runner-images:ubuntu-latest&lt;/code&gt;) already includes Node.js, so the job needs no &lt;code&gt;actions/setup-node&lt;/code&gt; step. Because Capawesome Cloud clones the repository itself, it needs no &lt;code&gt;actions/checkout&lt;/code&gt; either. Each build starts in a fresh macOS environment, and Xcode versions come from the selected &lt;a href="https://capawesome.io/docs/cloud/native-builds/build-stacks/" rel="noopener noreferrer"&gt;build stack&lt;/a&gt;, so moving to a new Xcode means changing one flag instead of reinstalling a Mac.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time setup
&lt;/h2&gt;

&lt;p&gt;The Capawesome Cloud side of the setup takes four steps. The Gitea connection belongs to your Capawesome Cloud organization and is reused by every app, and everything else is set up once per app. Create the app first, either in the &lt;a href="https://console.cloud.capawesome.io" rel="noopener noreferrer"&gt;Console&lt;/a&gt; or with &lt;code&gt;npx @capawesome/cli apps:create&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Connect Gitea.&lt;/strong&gt; Create a Gitea access token under &lt;strong&gt;Settings → Applications&lt;/strong&gt; with the &lt;code&gt;read:user&lt;/code&gt;, &lt;code&gt;read:organization&lt;/code&gt;, and &lt;code&gt;read:repository&lt;/code&gt; scopes (or &lt;code&gt;write:repository&lt;/code&gt; if you plan to use Automations, covered below). Add a Gitea connection in Capawesome Cloud with that token. For a self-hosted server, enable &lt;strong&gt;Self-hosted or enterprise instance&lt;/strong&gt; and enter its URL; on gitea.com, leave the option off. Then open the app's &lt;strong&gt;Git repository&lt;/strong&gt; page and connect the repository. The &lt;a href="https://capawesome.io/docs/cloud/integrations/gitea/" rel="noopener noreferrer"&gt;Gitea integration guide&lt;/a&gt; walks through each field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload signing assets.&lt;/strong&gt; Upload your distribution certificate (&lt;code&gt;.p12&lt;/code&gt;) and its provisioning profile under a name such as &lt;code&gt;Production iOS&lt;/code&gt;. The &lt;a href="https://capawesome.io/docs/cloud/native-builds/certificates/ios/" rel="noopener noreferrer"&gt;iOS certificates guide&lt;/a&gt; has the details, and the free &lt;a href="https://capawesome.io/tools/ios-certificate-generator/" rel="noopener noreferrer"&gt;iOS Certificate Generator&lt;/a&gt; creates the CSR and &lt;code&gt;.p12&lt;/code&gt; in your browser if there's no Mac at hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a destination.&lt;/strong&gt; Create an &lt;a href="https://capawesome.io/docs/cloud/app-store-publishing/destinations/apple-app-store/" rel="noopener noreferrer"&gt;Apple App Store destination&lt;/a&gt; named &lt;code&gt;TestFlight&lt;/code&gt; with an App Store Connect API key, so Capawesome Cloud can upload builds for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the token.&lt;/strong&gt; Create an &lt;a href="https://capawesome.io/docs/cloud/accounts/tokens/" rel="noopener noreferrer"&gt;API token&lt;/a&gt; and add it to your Gitea repository under &lt;strong&gt;Settings → Actions → Secrets&lt;/strong&gt; as &lt;code&gt;CAPAWESOME_TOKEN&lt;/code&gt;, which the CLI reads automatically. Add the app ID under &lt;strong&gt;Settings → Actions → Variables&lt;/strong&gt; as &lt;code&gt;CAPAWESOME_APP_ID&lt;/code&gt;. Gitea rejects secret and variable names that start with &lt;code&gt;GITEA_&lt;/code&gt; or &lt;code&gt;GITHUB_&lt;/code&gt;, so keep the &lt;code&gt;CAPAWESOME_&lt;/code&gt; prefix.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The release workflow
&lt;/h2&gt;

&lt;p&gt;The release workflow builds and ships the iOS app whenever someone pushes a version tag. Save it as &lt;code&gt;.gitea/workflows/ios-release.yaml&lt;/code&gt;:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iOS release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&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;v*"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release-ios&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;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;CAPAWESOME_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CAPAWESOME_TOKEN }}&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="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npx @capawesome/cli@4.22.0 apps:builds:create \&lt;/span&gt;
            &lt;span class="s"&gt;--app-id "${{ vars.CAPAWESOME_APP_ID }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--platform ios \&lt;/span&gt;
            &lt;span class="s"&gt;--type app-store \&lt;/span&gt;
            &lt;span class="s"&gt;--certificate "Production iOS" \&lt;/span&gt;
            &lt;span class="s"&gt;--destination "TestFlight" \&lt;/span&gt;
            &lt;span class="s"&gt;--git-ref "${{ gitea.sha }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://capawesome.io/docs/cloud/cli/commands/#appsbuildscreate" rel="noopener noreferrer"&gt;&lt;code&gt;apps:builds:create&lt;/code&gt;&lt;/a&gt; command builds the exact commit behind the tag. &lt;code&gt;${{ gitea.sha }}&lt;/code&gt; is Gitea's context for that commit, and &lt;code&gt;${{ github.sha }}&lt;/code&gt; works as an alias if you're porting a GitHub workflow. &lt;code&gt;--type app-store&lt;/code&gt; produces an IPA for TestFlight and the App Store; the other iOS &lt;a href="https://capawesome.io/docs/cloud/native-builds/build-types/" rel="noopener noreferrer"&gt;build types&lt;/a&gt; are &lt;code&gt;simulator&lt;/code&gt;, &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;ad-hoc&lt;/code&gt;, and &lt;code&gt;enterprise&lt;/code&gt;. &lt;code&gt;--destination&lt;/code&gt; submits the signed build once it succeeds.&lt;/p&gt;

&lt;p&gt;The command waits for the build and exits non-zero if it fails, so a failed build marks the Gitea Actions run as failed. Gitea sets &lt;code&gt;CI=true&lt;/code&gt; in every run, which keeps the CLI from prompting for input, and the &lt;a href="https://capawesome.io/docs/cloud/cli/scripting/" rel="noopener noreferrer"&gt;CLI scripting guide&lt;/a&gt; explains the rest of its non-interactive behavior. Pinning the CLI version keeps runs reproducible. To inject build-time variables and secrets, add &lt;code&gt;--environment&lt;/code&gt; with an &lt;a href="https://capawesome.io/docs/cloud/native-builds/environments/" rel="noopener noreferrer"&gt;environment&lt;/a&gt;, and to pick a specific Xcode, set &lt;code&gt;--stack&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A release now starts with a version tag pushed to Gitea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git tag v1.4.0
git push origin v1.4.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who can push a &lt;code&gt;v*&lt;/code&gt; tag can now ship to TestFlight. A &lt;a href="https://docs.gitea.com/usage/protected-tags" rel="noopener noreferrer"&gt;protected tag&lt;/a&gt; rule for &lt;code&gt;v*&lt;/code&gt; under &lt;strong&gt;Settings → Tags&lt;/strong&gt; limits that to the users and teams you choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual release approval
&lt;/h2&gt;

&lt;p&gt;Gitea Actions has no deployment approvals. The &lt;a href="https://docs.gitea.com/usage/actions/comparison/" rel="noopener noreferrer"&gt;comparison with GitHub Actions&lt;/a&gt; notes that &lt;code&gt;jobs..environment&lt;/code&gt; is ignored, so a required-reviewer environment won't pause a job. A pattern that works instead is to split building from shipping. Build on every push to &lt;code&gt;main&lt;/code&gt; without a destination, then promote a chosen build with a manually dispatched workflow.&lt;/p&gt;

&lt;p&gt;For the build half, reuse the release workflow with a branch trigger and drop the &lt;code&gt;--destination&lt;/code&gt; line:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The promote workflow takes a build number as input and calls &lt;a href="https://capawesome.io/docs/cloud/cli/commands/#appsdeploymentscreate" rel="noopener noreferrer"&gt;&lt;code&gt;apps:deployments:create&lt;/code&gt;&lt;/a&gt;. Save it as &lt;code&gt;.gitea/workflows/ios-promote.yaml&lt;/code&gt;:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;iOS promote&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;build_number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build number to submit to TestFlight&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;promote-ios&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;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;CAPAWESOME_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CAPAWESOME_TOKEN }}&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="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npx @capawesome/cli@4.22.0 apps:deployments:create \&lt;/span&gt;
            &lt;span class="s"&gt;--app-id "${{ vars.CAPAWESOME_APP_ID }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--build-number "${{ inputs.build_number }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--destination "TestFlight"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dispatching with inputs requires &lt;a href="https://blog.gitea.com/release-of-1.23.0" rel="noopener noreferrer"&gt;Gitea 1.23&lt;/a&gt; or later, which added manual runs with an input form in the Actions UI. Take the build number from the build list in the Capawesome Cloud Console. Gitea has no reviewer list to check against, so the gate is whoever starts the run.&lt;/p&gt;

&lt;p&gt;Builds for QA don't have to go through TestFlight at all. An &lt;code&gt;ad-hoc&lt;/code&gt; build with &lt;code&gt;--share&lt;/code&gt; gets a public install link for testers, as described in &lt;a href="https://capawesome.io/docs/cloud/native-builds/share-a-build/" rel="noopener noreferrer"&gt;Share a build&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Servers behind a firewall
&lt;/h2&gt;

&lt;p&gt;Capawesome Cloud has to reach your Gitea server to clone a repository by &lt;code&gt;--git-ref&lt;/code&gt;. When the server is only reachable from inside your network, the Gitea Actions runner is usually the one machine that already has access. Let it check out the code and upload the source with &lt;code&gt;--path&lt;/code&gt; instead:&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;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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;npx @capawesome/cli@4.22.0 apps:builds:create \&lt;/span&gt;
        &lt;span class="s"&gt;--app-id "${{ vars.CAPAWESOME_APP_ID }}" \&lt;/span&gt;
        &lt;span class="s"&gt;--platform ios \&lt;/span&gt;
        &lt;span class="s"&gt;--type app-store \&lt;/span&gt;
        &lt;span class="s"&gt;--certificate "Production iOS" \&lt;/span&gt;
        &lt;span class="s"&gt;--destination "TestFlight" \&lt;/span&gt;
        &lt;span class="s"&gt;--path . \&lt;/span&gt;
        &lt;span class="s"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job keeps the same &lt;code&gt;env&lt;/code&gt; block as the release workflow. The CLI packs the directory into an archive, respecting your &lt;code&gt;.gitignore&lt;/code&gt;, and uploads it, so no inbound connection to your network is needed. The &lt;a href="https://capawesome.io/docs/cloud/native-builds/build-without-git/" rel="noopener noreferrer"&gt;build without Git guide&lt;/a&gt; lists what you give up. These builds show no commit metadata in the Console, can't be started or re-run from the Console, and don't work with Automations. By default, &lt;code&gt;actions/checkout&lt;/code&gt; itself is downloaded from github.com, so the runner needs outbound access there.&lt;/p&gt;

&lt;p&gt;To keep the Git connection instead, the &lt;a href="https://capawesome.io/docs/cloud/native-builds/firewall-access/" rel="noopener noreferrer"&gt;firewall guide&lt;/a&gt; compares a reverse tunnel (the recommended option), repository mirroring, and IP allowlisting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Builds without a workflow
&lt;/h2&gt;

&lt;p&gt;Capawesome Cloud Automations start builds from Gitea pushes without any Gitea Actions workflow. An automation receives your repository's push events through a webhook and builds when a matching branch or tag arrives, so a tag automation can replace the release workflow entirely:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the Gitea access token the &lt;code&gt;write:repository&lt;/code&gt; scope, so Capawesome Cloud can register the webhook.&lt;/li&gt;
&lt;li&gt;On the app's &lt;strong&gt;Automations&lt;/strong&gt; page, create an automation with the trigger type &lt;strong&gt;Tag&lt;/strong&gt; and the pattern &lt;code&gt;v*&lt;/code&gt;. A second pattern, &lt;code&gt;!v*-*&lt;/code&gt;, skips pre-release tags such as &lt;code&gt;v1.4.0-rc.1&lt;/code&gt; (&lt;a href="https://capawesome.io/docs/cloud/automations/trigger-on-tags/" rel="noopener noreferrer"&gt;trigger on tags&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Choose iOS as the platform and attach the &lt;code&gt;Production iOS&lt;/code&gt; certificate and the &lt;code&gt;TestFlight&lt;/code&gt; destination in the &lt;a href="https://capawesome.io/docs/cloud/automations/attach-build-settings/" rel="noopener noreferrer"&gt;build settings&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Capawesome Cloud registers the webhook for you. On a self-hosted instance that registration can fail, for example because of missing permissions, and the &lt;a href="https://capawesome.io/docs/cloud/automations/webhooks/" rel="noopener noreferrer"&gt;webhook guide&lt;/a&gt; then lists the URL and secret to add in Gitea's repository settings by hand. Automations clone through the Git connection, so they need a Gitea server that Capawesome Cloud can reach. They also work on an instance with no registered runners, and a commit with &lt;code&gt;[skip ci]&lt;/code&gt; in its message skips them.&lt;/p&gt;

&lt;p&gt;The trade-off is control. An automation builds every matching tag, while a workflow can run your tests first and start the build only when they pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Gitea Actions have macOS runners?
&lt;/h3&gt;

&lt;p&gt;Gitea Actions doesn't come with a Mac. gitea.com registers runners only for the &lt;code&gt;gitea&lt;/code&gt; organization's repositories, and Gitea Cloud describes on-demand runners without naming an operating system. You can register your own Mac with Gitea Runner using a &lt;code&gt;host&lt;/code&gt; label, or run the job on Linux and hand the iOS build to a service such as Capawesome Cloud.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Gitea Runner build iOS apps on my own Mac?
&lt;/h3&gt;

&lt;p&gt;Yes. Register Gitea Runner on the Mac with a label such as &lt;code&gt;macos:host&lt;/code&gt; and target it with &lt;code&gt;runs-on: macos&lt;/code&gt;. The steps then run directly on the machine with its installed Xcode and without isolation between jobs, and you maintain Xcode, the signing keychain, and the App Store Connect upload yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Gitea Actions at all?
&lt;/h3&gt;

&lt;p&gt;No. Capawesome Cloud Automations trigger iOS builds on Gitea branch or tag pushes through a webhook and can submit the result to TestFlight, with no workflow file and no runner. Use Gitea Actions when tests or other jobs should run before the build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work with a Gitea server behind a firewall?
&lt;/h3&gt;

&lt;p&gt;Yes. A runner inside your network can run &lt;code&gt;actions/checkout&lt;/code&gt; and upload the source with &lt;code&gt;apps:builds:create --path .&lt;/code&gt;, so Capawesome Cloud never connects to your server. To keep the Git connection and Automations instead, expose the server through a reverse tunnel, a mirror, or an IP allowlist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which workflow directory does Gitea read?
&lt;/h3&gt;

&lt;p&gt;Gitea looks for &lt;code&gt;.gitea/workflows&lt;/code&gt; first and falls back to &lt;code&gt;.github/workflows&lt;/code&gt;, using the first directory that exists. Once a repository has a &lt;code&gt;.gitea/workflows&lt;/code&gt; directory, Gitea ignores the workflows in &lt;code&gt;.github/workflows&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this work for Capacitor and Cordova apps?
&lt;/h3&gt;

&lt;p&gt;Yes. Capawesome Cloud builds native Swift and Objective-C projects as well as Capacitor and Cordova apps, and it runs the web build and native sync for the hybrid ones. The Gitea workflow is the same for all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;If iOS is the one job your Gitea runners can't handle, connect your Gitea server, upload a certificate, and push a tag. Capawesome Cloud comes with a 14-day free trial, which covers your first builds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://capawesome.io" rel="noopener noreferrer"&gt;Try Capawesome Cloud Free&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Start with a tag automation if pushing a tag is your whole release process. Move to the Gitea Actions workflow once tests have to pass before a build starts, and switch to &lt;code&gt;--path&lt;/code&gt; when Capawesome Cloud can't reach your server. Register a Mac as a runner only if someone on your team already maintains Macs.&lt;/p&gt;

&lt;p&gt;For the same flow without a CI layer, read &lt;a href="https://capawesome.io/blog/how-to-build-and-deploy-ios-apps-without-a-mac/" rel="noopener noreferrer"&gt;How to Build and Deploy iOS Apps Without Owning a Mac&lt;/a&gt;. If you have questions, join the &lt;a href="https://discord.gg/VCXxSVjefW" rel="noopener noreferrer"&gt;Capawesome Discord server&lt;/a&gt;, and &lt;a href="https://capawesome.io/newsletter/" rel="noopener noreferrer"&gt;subscribe to the Capawesome newsletter&lt;/a&gt; to stay up to date.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>cicd</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Run an On-Device LLM in Your Capacitor App Without an API Key</title>
      <dc:creator>Robin</dc:creator>
      <pubDate>Sat, 12 Sep 2026 17:00:53 +0000</pubDate>
      <link>https://dev.to/capawesome/run-an-on-device-llm-in-your-capacitor-app-without-an-api-key-1gg4</link>
      <guid>https://dev.to/capawesome/run-an-on-device-llm-in-your-capacitor-app-without-an-api-key-1gg4</guid>
      <description>&lt;p&gt;Adding an AI feature to a mobile app usually means sending the user's text to a server. You pick a provider, find somewhere for the API key that isn't the app bundle (in practice, a proxy you now run), and accept a bill that grows with every token.&lt;/p&gt;

&lt;p&gt;Modern phones offer another route. iOS 26 ships Apple Intelligence behind the Foundation Models framework, and recent Android devices run Gemini Nano through ML Kit and AICore. Both models belong to the operating system, so the prompt, the conversation history and the answer stay on the device. There is no key to protect and no per-request cost.&lt;/p&gt;

&lt;p&gt;We built the Capacitor LLM plugin to put one TypeScript API over both. To be upfront, it is our plugin and it ships as part of Capawesome Insiders, a paid subscription. This post condenses the &lt;a href="https://capawesome.io/blog/announcing-the-capacitor-llm-plugin/" rel="noopener noreferrer"&gt;full announcement on our blog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What On-Device Inference Buys You
&lt;/h2&gt;

&lt;p&gt;Privacy is the headline, but three other things follow from keeping the model on the phone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No per-token cost.&lt;/strong&gt; Inference runs on hardware the user already paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline generation.&lt;/strong&gt; Responses come back in airplane mode or with no reception at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No app size increase.&lt;/strong&gt; The model is part of the OS, so the feature adds nothing to your download.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A system model is small next to a frontier model behind an HTTP endpoint, the usable context is roughly 4,000 tokens on both platforms, and the hardware requirements exclude most devices in use today. That fits summarizing a note or rewriting a paragraph, not reasoning across a long document.&lt;/p&gt;

&lt;h2&gt;
  
  
  One API Over Two System Models
&lt;/h2&gt;

&lt;p&gt;The plugin ships neither a model nor a runtime. It calls each vendor's on-device API and normalizes the result:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Underlying API&lt;/th&gt;
&lt;th&gt;Requirements&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Gemini Nano&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://developers.google.com/ml-kit/genai/prompt/android" rel="noopener noreferrer"&gt;ML Kit GenAI Prompt&lt;/a&gt; via AICore&lt;/td&gt;
&lt;td&gt;API level 26+, Gemini Nano-capable device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Apple Intelligence&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developer.apple.com/documentation/foundationmodels" rel="noopener noreferrer"&gt;Foundation Models&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;iOS 26+, iPhone 15 Pro or later, Xcode 26 to build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getAvailability()&lt;/code&gt; resolves &lt;code&gt;unavailable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details shape a release plan. Google's ML Kit GenAI Prompt SDK is still in beta, so the plugin pins &lt;code&gt;com.google.mlkit:genai-prompt&lt;/code&gt; at &lt;code&gt;1.0.0-beta2&lt;/code&gt; and lets you override it with the &lt;code&gt;$mlkitGenaiPromptVersion&lt;/code&gt; Gradle variable. And Gemini Nano runs on few devices today, roughly the Pixel 9 and Galaxy S25 series, which makes the availability check mandatory.&lt;/p&gt;

&lt;p&gt;If you only ship Android, the free &lt;a href="https://capawesome.io/docs/sdks/capacitor/mlkit/genai-prompt/" rel="noopener noreferrer"&gt;Capacitor ML Kit GenAI Prompt plugin&lt;/a&gt; wraps the same Google API directly. For the iOS side, our guide on &lt;a href="https://capawesome.io/blog/how-to-use-apple-intelligence-in-a-capacitor-app/" rel="noopener noreferrer"&gt;using Apple Intelligence in a Capacitor app&lt;/a&gt; walks through the Foundation Models path on a real device, so I won't repeat it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Availability Is the First Call You Make
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getAvailability()&lt;/code&gt; never rejects. On a platform or OS version with no system model it resolves with &lt;code&gt;unavailable&lt;/code&gt;, so you can call it at startup and branch on it. Each of the seven statuses maps to a different UI action:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Reported on&lt;/th&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;available&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android, iOS&lt;/td&gt;
&lt;td&gt;Start generating.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device-not-eligible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Fall back to a cloud model.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloadable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Call &lt;code&gt;downloadModel()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;downloading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;Show progress and wait.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Point the user to Settings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-ready&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;Try again later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unavailable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Android, iOS, Web&lt;/td&gt;
&lt;td&gt;Hide the feature.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The status changes while your app runs. A user can switch on Apple Intelligence in Settings, or an Android download can finish, and the &lt;code&gt;availabilityChange&lt;/code&gt; event covers both cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;watchAvailability&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAvailability&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;updateUi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;availabilityChange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;updateUi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Switch over all seven statuses, not the four Android reports or the five iOS reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Downloading Gemini Nano on Android
&lt;/h2&gt;

&lt;p&gt;When the status is &lt;code&gt;downloadable&lt;/code&gt;, the model has to reach the device first, and Android is the only platform where your app starts it. &lt;code&gt;downloadModel()&lt;/code&gt; resolves on completion, and &lt;code&gt;downloadProgress&lt;/code&gt; reports a value between &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;. Show that progress, because the download runs long enough that a silent button reads as broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;downloadModel&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloadProgress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;setProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadModel&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;
  
  
  Chats Hold the Conversation Context
&lt;/h2&gt;

&lt;p&gt;A chat is the unit of context. &lt;code&gt;createChat(...)&lt;/code&gt; returns an identifier you pass to every generation, and optionally takes &lt;code&gt;instructions&lt;/code&gt;, the system prompt that shapes the model's role. Pass your own &lt;code&gt;id&lt;/code&gt; to map chats onto your data model, or leave it out and get a UUID.&lt;/p&gt;

&lt;p&gt;The implementation differs underneath. On iOS each chat is backed by a native language model session. On Android the system API has no multi-turn concept, so the plugin keeps the history in memory and includes it in each prompt. Either way, history does not survive an app restart, and every chat holds native resources until you release it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startChat&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="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful assistant that answers briefly.&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endChat&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="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chatId&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;deleteChat(...)&lt;/code&gt; also cancels an in-flight generation for that chat, so tearing down a chat screen mid-response is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating and Streaming Text
&lt;/h2&gt;

&lt;p&gt;There are two ways to get text out. &lt;code&gt;generateText(...)&lt;/code&gt; resolves with the complete response and suits short outputs where a spinner is fine. &lt;code&gt;streamText(...)&lt;/code&gt; emits chunks through the &lt;code&gt;textChunk&lt;/code&gt; event and resolves with the full text at the end. Every event carries the &lt;code&gt;chatId&lt;/code&gt; it belongs to, so one listener can serve several chats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streamAnswer&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="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;textChunk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;appendToUi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Summarize this note in three bullet points.&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;text&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;Only one generation runs per chat at a time. A second prompt sent into a busy chat rejects with &lt;code&gt;GENERATION_IN_PROGRESS&lt;/code&gt; instead of queueing, so disable the send button while a response is in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canceling a Generation
&lt;/h2&gt;

&lt;p&gt;A small model can spend several seconds on an answer nobody wants any more. &lt;code&gt;cancelGeneration(...)&lt;/code&gt; stops the current generation, and the pending promise rejects with &lt;code&gt;GENERATION_CANCELED&lt;/code&gt;, a normal outcome rather than an error to report. On iOS it stops immediately. On Android cancellation is best-effort, so a few more &lt;code&gt;textChunk&lt;/code&gt; events may arrive afterwards. Track a canceled flag per chat and drop those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@capawesome-team/capacitor-llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canceledChatIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stopGeneration&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="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;canceledChatIds&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="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelGeneration&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chatId&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;
  
  
  Where the Platform Limits Bite
&lt;/h2&gt;

&lt;p&gt;Two generation parameters are exposed. &lt;code&gt;maxOutputTokens&lt;/code&gt; caps the length of a response and &lt;code&gt;temperature&lt;/code&gt; controls how deterministic it is. Both work as chat defaults in &lt;code&gt;createChat(...)&lt;/code&gt; and as per-request overrides. The ranges come from the platforms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Android (Gemini Nano)&lt;/th&gt;
&lt;th&gt;iOS (Apple Intelligence)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;maxOutputTokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maximum of &lt;code&gt;4096&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;No documented limit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;temperature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Between &lt;code&gt;0.0&lt;/code&gt; and &lt;code&gt;1.0&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Values above &lt;code&gt;1.0&lt;/code&gt; allowed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context size&lt;/td&gt;
&lt;td&gt;Input under ~4,000 tokens.&lt;/td&gt;
&lt;td&gt;~4,096 tokens per session.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stay inside the Android ranges if you want one code path across both platforms. The context limit is the one to design around: a long conversation eventually overruns it, the generation rejects with &lt;code&gt;GENERATION_FAILED&lt;/code&gt;, and the fix is a new chat seeded with a summary generated while there was still room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;getAvailability()&lt;/code&gt; and a fallback path, because on most devices today the answer is still &lt;code&gt;unavailable&lt;/code&gt;. Once that gate is in place, wire up &lt;code&gt;generateText(...)&lt;/code&gt; first, then &lt;code&gt;streamText(...)&lt;/code&gt; and &lt;code&gt;cancelGeneration(...)&lt;/code&gt; when the UI calls for them.&lt;/p&gt;

&lt;p&gt;Every method and error code lives in the &lt;a href="https://capawesome.io/docs/sdks/capacitor/llm/" rel="noopener noreferrer"&gt;plugin documentation&lt;/a&gt;, and the longer write-up is in the &lt;a href="https://capawesome.io/blog/announcing-the-capacitor-llm-plugin/" rel="noopener noreferrer"&gt;announcement post&lt;/a&gt;. If you have shipped an on-device feature already, tell me in the comments which platform limit you hit first.&lt;/p&gt;

</description>
      <category>ionic</category>
      <category>ai</category>
      <category>android</category>
      <category>ios</category>
    </item>
  </channel>
</rss>
