<?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: NuvyntraLabs</title>
    <description>The latest articles on DEV Community by NuvyntraLabs (@nuvyantralabs_ccee).</description>
    <link>https://dev.to/nuvyantralabs_ccee</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%2F4138260%2Fe149499a-8204-4e2a-b191-6a06912983c9.png</url>
      <title>DEV Community: NuvyntraLabs</title>
      <link>https://dev.to/nuvyantralabs_ccee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nuvyantralabs_ccee"/>
    <language>en</language>
    <item>
      <title>From Xamarin to .NET MAUI: An Enterprise Migration Playbook</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 14:50:19 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/from-xamarin-to-net-maui-an-enterprise-migration-playbook-5g23</link>
      <guid>https://dev.to/nuvyantralabs_ccee/from-xamarin-to-net-maui-an-enterprise-migration-playbook-5g23</guid>
      <description>&lt;p&gt;Migrating a small Xamarin.Forms application to .NET MAUI can be relatively straightforward.&lt;/p&gt;

&lt;p&gt;Migrating a &lt;strong&gt;large enterprise mobile application&lt;/strong&gt; is a completely different problem.&lt;/p&gt;

&lt;p&gt;Enterprise applications often contain years of accumulated business logic, hundreds of screens, custom renderers, native Android and iOS integrations, third-party SDKs, background services, push notifications, offline synchronization, authentication systems, CI/CD pipelines, and production dependencies.&lt;/p&gt;

&lt;p&gt;Simply converting the &lt;code&gt;.csproj&lt;/code&gt; file and fixing compilation errors is not an enterprise migration strategy.&lt;/p&gt;

&lt;p&gt;A successful migration should be treated as a &lt;strong&gt;controlled transformation program&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The objective isn't to convert Xamarin code to MAUI. The objective is to move a production application to a modern platform without disrupting the business.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This guide presents a practical approach for migrating large Xamarin/Xamarin.Forms enterprise applications to .NET MAUI.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Why Enterprise Migration Is Different
&lt;/h2&gt;

&lt;p&gt;A typical Xamarin application may look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mobile Application
 ├── Pages
 ├── ViewModels
 ├── Services
 ├── API
 └── Local Storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A large enterprise application can look more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Enterprise Mobile Platform
│
├── Presentation
│   ├── 100+ Pages
│   ├── Custom Controls
│   ├── Renderers
│   └── Behaviors
│
├── Business Layer
│   ├── Domain Logic
│   ├── Workflows
│   └── Validation
│
├── Infrastructure
│   ├── REST APIs
│   ├── Database
│   ├── Caching
│   └── Synchronization
│
├── Platform Services
│   ├── Push Notifications
│   ├── Location
│   ├── Bluetooth
│   ├── Background Services
│   └── File System
│
├── Native Integrations
│   ├── Android SDKs
│   ├── iOS SDKs
│   └── Third-party SDKs
│
└── DevOps
    ├── CI/CD
    ├── Signing
    ├── Distribution
    └── Monitoring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these areas can introduce migration risk.&lt;/p&gt;

&lt;p&gt;Therefore, the migration needs to be planned before the first code change.&lt;/p&gt;




&lt;h1&gt;
  
  
  2. The Migration Mindset
&lt;/h1&gt;

&lt;p&gt;The traditional approach is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin
   ↓
Upgrade Project
   ↓
Fix Compilation Errors
   ↓
Fix Runtime Errors
   ↓
Release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an enterprise application, a better approach is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ASSESS
   ↓
BASELINE
   ↓
DECOUPLE
   ↓
BUILD MAUI FOUNDATION
   ↓
PILOT
   ↓
MIGRATE IN WAVES
   ↓
VALIDATE
   ↓
CANARY RELEASE
   ↓
FULL ROLLOUT
   ↓
RETIRE XAMARIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates checkpoints throughout the migration rather than discovering major problems near the end.&lt;/p&gt;




&lt;h1&gt;
  
  
  3. Phase 1 — Assess the Existing Application
&lt;/h1&gt;

&lt;p&gt;Before migrating anything, create a &lt;strong&gt;Migration Inventory&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Xamarin.Forms version&lt;/li&gt;
&lt;li&gt;.NET/Xamarin versions&lt;/li&gt;
&lt;li&gt;Target Android/iOS versions&lt;/li&gt;
&lt;li&gt;NuGet dependencies&lt;/li&gt;
&lt;li&gt;Custom renderers&lt;/li&gt;
&lt;li&gt;Effects&lt;/li&gt;
&lt;li&gt;Behaviors&lt;/li&gt;
&lt;li&gt;Custom controls&lt;/li&gt;
&lt;li&gt;Native Android code&lt;/li&gt;
&lt;li&gt;Native iOS code&lt;/li&gt;
&lt;li&gt;Background services&lt;/li&gt;
&lt;li&gt;Push notification implementation&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Local database&lt;/li&gt;
&lt;li&gt;Offline synchronization&lt;/li&gt;
&lt;li&gt;File handling&lt;/li&gt;
&lt;li&gt;Deep links&lt;/li&gt;
&lt;li&gt;Analytics&lt;/li&gt;
&lt;li&gt;Crash reporting&lt;/li&gt;
&lt;li&gt;CI/CD&lt;/li&gt;
&lt;li&gt;Signing and provisioning&lt;/li&gt;
&lt;li&gt;Automated tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is to answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What parts of this application are actually dependent on Xamarin?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  4. Build a Dependency Risk Matrix
&lt;/h1&gt;

&lt;p&gt;Not every dependency deserves the same migration strategy.&lt;/p&gt;

&lt;p&gt;Classify dependencies into three categories.&lt;/p&gt;

&lt;h3&gt;
  
  
  Green
&lt;/h3&gt;

&lt;p&gt;Already compatible with the target .NET/MAUI environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.NET libraries
Modern NuGet packages
Pure business logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Yellow
&lt;/h3&gt;

&lt;p&gt;Requires configuration or code changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Older NuGet packages
Custom controls
Navigation libraries
Storage libraries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Red
&lt;/h3&gt;

&lt;p&gt;Requires replacement or significant redesign.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin-only packages
Unsupported renderers
Old native bindings
Unmaintained SDKs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the team a migration backlog before implementation begins.&lt;/p&gt;




&lt;h1&gt;
  
  
  5. Phase 2 — Establish a Production Baseline
&lt;/h1&gt;

&lt;p&gt;One of the most overlooked parts of migration is establishing measurable baseline data.&lt;/p&gt;

&lt;p&gt;Before migration, measure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Xamarin Baseline&lt;/th&gt;
&lt;th&gt;MAUI Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold startup&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warm startup&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory usage&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application size&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login time&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen load time&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API failure rate&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash rate&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ANR rate&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database performance&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also document critical business workflows.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login
Search Customer
Open Customer
Create Order
Submit Order
Offline
Synchronize
Receive Notification
Logout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These become migration acceptance criteria.&lt;/p&gt;

&lt;p&gt;Without a baseline, "migration completed" can mean nothing more than "the application builds."&lt;/p&gt;




&lt;h1&gt;
  
  
  6. Phase 3 — Stabilize Xamarin Before Migration
&lt;/h1&gt;

&lt;p&gt;If the existing application is on an older Xamarin.Forms version, stabilize it before starting the MAUI migration.&lt;/p&gt;

&lt;p&gt;A useful sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Legacy Xamarin.Forms
        ↓
Latest stable Xamarin.Forms version
        ↓
Update dependencies
        ↓
Fix existing issues
        ↓
Production baseline
        ↓
MAUI migration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not combine an unstable legacy application with a platform migration.&lt;/p&gt;

&lt;p&gt;Otherwise, you won't know whether a problem was introduced by the migration or already existed.&lt;/p&gt;




&lt;h1&gt;
  
  
  7. Don't Automatically Convert Everything
&lt;/h1&gt;

&lt;p&gt;A common mistake is assuming that every piece of existing code should survive.&lt;/p&gt;

&lt;p&gt;Ask three questions:&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this code contain business logic?
&lt;/h3&gt;

&lt;p&gt;Keep and modernize it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this code exist only because Xamarin required it?
&lt;/h3&gt;

&lt;p&gt;Consider removing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this code solving a problem that MAUI already solves?
&lt;/h3&gt;

&lt;p&gt;Delete it.&lt;/p&gt;

&lt;p&gt;Migration is an opportunity to reduce technical debt, but &lt;strong&gt;not an excuse for a complete rewrite&lt;/strong&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  8. Decouple the Business Layer
&lt;/h1&gt;

&lt;p&gt;Before migrating large amounts of UI, separate the business logic from the Xamarin-specific code.&lt;/p&gt;

&lt;p&gt;A target architecture could look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Presentation
      ↓
ViewModels
      ↓
Application Services
      ↓
Domain
      ↓
Infrastructure
      ↓
Platform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyCompany.Mobile
MyCompany.Mobile.Core
MyCompany.Mobile.Domain
MyCompany.Mobile.Application
MyCompany.Mobile.Infrastructure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The objective is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The less Xamarin-dependent your business layer is, the easier the migration becomes.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  9. PCL Migration: Don't Create Unnecessary Intermediate Work
&lt;/h1&gt;

&lt;p&gt;Many older applications use Portable Class Libraries.&lt;/p&gt;

&lt;p&gt;A common migration path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PCL
 ↓
.NET Standard
 ↓
.NET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can be useful, but it should not automatically become a mandatory intermediate step.&lt;/p&gt;

&lt;p&gt;Instead, inspect what is actually inside the PCL.&lt;/p&gt;

&lt;p&gt;If it contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Models
Business Rules
Validation
API Clients
Utilities
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;move these into modern reusable libraries.&lt;/p&gt;

&lt;p&gt;If it contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin.Forms
Xamarin.Essentials
Platform-specific APIs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;separate those dependencies first.&lt;/p&gt;

&lt;p&gt;The goal is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Convert the PCL."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The goal is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Extract reusable application logic from the PCL."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  10. Build the MAUI Foundation First
&lt;/h1&gt;

&lt;p&gt;Before migrating hundreds of screens, establish the new MAUI foundation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MyCompany.Mobile
│
├── Configuration
├── DependencyInjection
├── Navigation
├── Resources
│
├── Core
├── Domain
├── Application
├── Infrastructure
│
└── Platforms
    ├── Android
    ├── iOS
    ├── Windows
    └── MacCatalyst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dependency Injection&lt;/li&gt;
&lt;li&gt;Configuration&lt;/li&gt;
&lt;li&gt;Logging&lt;/li&gt;
&lt;li&gt;Navigation&lt;/li&gt;
&lt;li&gt;API clients&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Telemetry&lt;/li&gt;
&lt;li&gt;Error handling&lt;/li&gt;
&lt;li&gt;Environment management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates the platform on which the migrated features can run.&lt;/p&gt;




&lt;h1&gt;
  
  
  11. Migrate One Vertical Slice
&lt;/h1&gt;

&lt;p&gt;This is one of the most important changes to the traditional migration approach.&lt;/p&gt;

&lt;p&gt;Do not migrate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Page 1
Page 2
Page 3
...
Page 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, migrate a complete business capability.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login
   ↓
Dashboard
   ↓
Customer Search
   ↓
Customer Details
   ↓
Create Order
   ↓
Submit Order
   ↓
Offline Sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single workflow exercises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UI&lt;/li&gt;
&lt;li&gt;ViewModels&lt;/li&gt;
&lt;li&gt;Navigation&lt;/li&gt;
&lt;li&gt;Dependency Injection&lt;/li&gt;
&lt;li&gt;API&lt;/li&gt;
&lt;li&gt;Authentication&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Offline support&lt;/li&gt;
&lt;li&gt;Platform services&lt;/li&gt;
&lt;li&gt;Telemetry&lt;/li&gt;
&lt;li&gt;Error handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the vertical slice works, your migration architecture has been validated.&lt;/p&gt;




&lt;h1&gt;
  
  
  12. Renderers → Handlers
&lt;/h1&gt;

&lt;p&gt;One of the major Xamarin.Forms migration areas is the transition from renderers to MAUI handlers.&lt;/p&gt;

&lt;p&gt;But don't blindly convert every renderer.&lt;/p&gt;

&lt;p&gt;Classify each renderer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Custom Renderer
      │
      ├── Styling
      │      ↓
      │    Styles
      │
      ├── Behavior
      │      ↓
      │    Behavior
      │
      ├── Property mapping
      │      ↓
      │    Handler Mapper
      │
      └── Native control
             ↓
         Custom Handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You may discover that some old renderers are no longer necessary.&lt;/p&gt;




&lt;h1&gt;
  
  
  13. Create Platform Abstractions
&lt;/h1&gt;

&lt;p&gt;Avoid spreading platform-specific code throughout ViewModels and business logic.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if ANDROID
&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;everywhere, create interfaces.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IPushNotificationService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetTokenAsync&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;Then implement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IPushNotificationService
        │
        ├── Android
        └── iOS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern can be applied to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IBiometricsService
ILocationService
IFileService
IAudioService
IDeviceService
IBackgroundService
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps platform-specific code isolated.&lt;/p&gt;




&lt;h1&gt;
  
  
  14. Treat Native Functionality as a Separate Migration Track
&lt;/h1&gt;

&lt;p&gt;Native functionality deserves its own checklist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Android
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MainActivity
Services
Broadcast Receivers
Firebase Messaging
Permissions
Intents
Background Processing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  iOS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AppDelegate
Push Notifications
Background Tasks
Extensions
Permissions
Native SDKs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't consider the migration complete until these areas have been individually validated.&lt;/p&gt;




&lt;h1&gt;
  
  
  15. Firebase and Push Notifications
&lt;/h1&gt;

&lt;p&gt;Push notification migration can be more complicated than expected.&lt;/p&gt;

&lt;p&gt;Validate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App running
App backgrounded
App terminated
App restored
Token refresh
Notification tap
Deep link
Data payload
Notification payload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android
iOS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And test different OS versions and device manufacturers.&lt;/p&gt;

&lt;p&gt;Push functionality should be considered a &lt;strong&gt;production-critical capability&lt;/strong&gt;, not just another NuGet package migration.&lt;/p&gt;




&lt;h1&gt;
  
  
  16. Authentication Must Be Tested End-to-End
&lt;/h1&gt;

&lt;p&gt;Enterprise applications frequently use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth&lt;/li&gt;
&lt;li&gt;OpenID Connect&lt;/li&gt;
&lt;li&gt;Microsoft Entra ID&lt;/li&gt;
&lt;li&gt;SSO&lt;/li&gt;
&lt;li&gt;biometric authentication&lt;/li&gt;
&lt;li&gt;refresh tokens&lt;/li&gt;
&lt;li&gt;secure storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Login
 ↓
Token
 ↓
API
 ↓
Token Refresh
 ↓
Logout
 ↓
Re-login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;expired tokens&lt;/li&gt;
&lt;li&gt;revoked sessions&lt;/li&gt;
&lt;li&gt;offline behavior&lt;/li&gt;
&lt;li&gt;device restart&lt;/li&gt;
&lt;li&gt;application restart&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  17. Keep Xamarin and MAUI Side-by-Side
&lt;/h1&gt;

&lt;p&gt;For a very large application, a side-by-side migration can significantly reduce risk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 Shared Core
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
       Xamarin                MAUI
          │                     │
      Legacy UI             New UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrate functionality progressively.&lt;/p&gt;

&lt;p&gt;Eventually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin
   ↓
Remaining modules
   ↓
Zero modules
   ↓
Retire Xamarin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach allows the existing application to continue receiving releases while migration continues.&lt;/p&gt;




&lt;h1&gt;
  
  
  18. Use Feature Flags
&lt;/h1&gt;

&lt;p&gt;Feature flags allow old and new implementations to coexist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CustomerDetails.Maui
        │
        ├── false → Xamarin
        │
        └── true  → MAUI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A feature can then be enabled for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internal testers
      ↓
Pilot users
      ↓
Small production group
      ↓
Larger production group
      ↓
Everyone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a problem occurs, disable the MAUI implementation without necessarily rolling back the entire application.&lt;/p&gt;




&lt;h1&gt;
  
  
  19. CI/CD Should Be Migrated Early
&lt;/h1&gt;

&lt;p&gt;Do not wait until the application is fully converted.&lt;/p&gt;

&lt;p&gt;Build the pipeline early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pull Request
      ↓
Build
      ↓
Unit Tests
      ↓
Static Analysis
      ↓
Android Build
      ↓
iOS Build
      ↓
Integration Tests
      ↓
Artifact
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Development
     ↓
QA
     ↓
UAT
     ↓
Internal
     ↓
Canary
     ↓
Production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Android signing&lt;/li&gt;
&lt;li&gt;iOS signing&lt;/li&gt;
&lt;li&gt;provisioning&lt;/li&gt;
&lt;li&gt;certificates&lt;/li&gt;
&lt;li&gt;app identifiers&lt;/li&gt;
&lt;li&gt;push configuration&lt;/li&gt;
&lt;li&gt;environment configuration&lt;/li&gt;
&lt;li&gt;crash reporting&lt;/li&gt;
&lt;li&gt;symbol upload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A migration that works only on a developer's machine is not complete.&lt;/p&gt;




&lt;h1&gt;
  
  
  20. Automated Testing Is a Migration Requirement
&lt;/h1&gt;

&lt;p&gt;Create three levels of validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unit tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Business rules
Validation
Domain logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Integration tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API
Authentication
Database
Synchronization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Device tests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android
iOS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critical workflows should be automated before migration begins.&lt;/p&gt;

&lt;p&gt;Otherwise, the team will spend enormous amounts of time manually comparing old and new applications.&lt;/p&gt;




&lt;h1&gt;
  
  
  21. Performance Testing
&lt;/h1&gt;

&lt;p&gt;Don't assume that moving to MAUI automatically improves performance.&lt;/p&gt;

&lt;p&gt;Compare the old and new applications.&lt;/p&gt;

&lt;p&gt;Measure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Startup
Memory
Navigation
Scrolling
API operations
Database operations
Synchronization
Battery usage
Application size
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;low-end Android devices&lt;/li&gt;
&lt;li&gt;popular Android manufacturers&lt;/li&gt;
&lt;li&gt;supported iPhones&lt;/li&gt;
&lt;li&gt;slow networks&lt;/li&gt;
&lt;li&gt;offline mode&lt;/li&gt;
&lt;li&gt;application restart&lt;/li&gt;
&lt;li&gt;background/foreground transitions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is &lt;strong&gt;measurable equivalence or improvement&lt;/strong&gt;, not simply successful compilation.&lt;/p&gt;




&lt;h1&gt;
  
  
  22. Observability During Migration
&lt;/h1&gt;

&lt;p&gt;Add migration-specific telemetry.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MigrationFeature = CustomerDetails
MigrationVersion = 5.2
Platform = Android
Implementation = MAUI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Track:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Crash rate
API failures
Navigation failures
Startup time
Feature failures
User workflow completion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin production
       VS
MAUI production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;using actual data.&lt;/p&gt;




&lt;h1&gt;
  
  
  23. Don't Combine Migration With a Major Rewrite
&lt;/h1&gt;

&lt;p&gt;This is probably the most important practical recommendation.&lt;/p&gt;

&lt;p&gt;Avoid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin → MAUI
       +
New UI
       +
New architecture
       +
New API
       +
New database
       +
New authentication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead:&lt;/p&gt;

&lt;h3&gt;
  
  
  Release 1 — Migration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Xamarin
   ↓
MAUI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Focus on functional equivalence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release 2 — Stabilization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAUI
 ↓
Performance
 ↓
Reliability
 ↓
Operational improvements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Release 3 — Modernization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAUI
 ↓
New UI
 ↓
Architecture improvements
 ↓
New features
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes failures easier to isolate.&lt;/p&gt;




&lt;h1&gt;
  
  
  24. Track Migration Debt
&lt;/h1&gt;

&lt;p&gt;Not everything needs to be modernized immediately.&lt;/p&gt;

&lt;p&gt;Create migration tasks such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAUI-MIG-001
Replace legacy renderer

MAUI-MIG-002
Remove Xamarin compatibility dependency

MAUI-MIG-003
Modernize navigation

MAUI-MIG-004
Replace legacy native binding

MAUI-MIG-005
Remove platform-specific conditional logic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents temporary migration compromises from becoming permanent architecture.&lt;/p&gt;




&lt;h1&gt;
  
  
  25. Enterprise Migration Scorecard
&lt;/h1&gt;

&lt;p&gt;Track every module.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Core&lt;/th&gt;
&lt;th&gt;UI&lt;/th&gt;
&lt;th&gt;Native&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;Performance&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🟡&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🟡&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;In Progress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orders&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;🔴&lt;/td&gt;
&lt;td&gt;🟡&lt;/td&gt;
&lt;td&gt;🟡&lt;/td&gt;
&lt;td&gt;Blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reports&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;td&gt;Complete&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This creates a common language between developers, QA, architects, DevOps and management.&lt;/p&gt;




&lt;h1&gt;
  
  
  26. Recommended Migration Roadmap
&lt;/h1&gt;

&lt;p&gt;A practical enterprise migration can be organized into these phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase 0
Assessment
       ↓
Phase 1
Baseline &amp;amp; Stabilization
       ↓
Phase 2
Architecture Decoupling
       ↓
Phase 3
MAUI Foundation
       ↓
Phase 4
Pilot Vertical Slice
       ↓
Phase 5
Platform &amp;amp; Native Migration
       ↓
Phase 6
Feature Migration Waves
       ↓
Phase 7
Regression &amp;amp; Performance
       ↓
Phase 8
Canary Production
       ↓
Phase 9
Full Production Rollout
       ↓
Phase 10
Xamarin Retirement
       ↓
Phase 11
Application Modernization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  27. The Golden Rules
&lt;/h1&gt;

&lt;p&gt;If I had to reduce the entire migration strategy to ten rules:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Don't start with the &lt;code&gt;.csproj&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Start with an assessment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Establish a baseline
&lt;/h3&gt;

&lt;p&gt;Measure the existing production application.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Don't migrate everything at once
&lt;/h3&gt;

&lt;p&gt;Use incremental migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Decouple business logic
&lt;/h3&gt;

&lt;p&gt;Reduce platform dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Migrate vertical slices
&lt;/h3&gt;

&lt;p&gt;Move complete business workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Isolate native code
&lt;/h3&gt;

&lt;p&gt;Keep Android/iOS implementation behind abstractions.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Don't blindly convert renderers
&lt;/h3&gt;

&lt;p&gt;Determine whether they are still necessary.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Use feature flags
&lt;/h3&gt;

&lt;p&gt;Keep rollback paths available.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Automate validation
&lt;/h3&gt;

&lt;p&gt;Build tests before the migration becomes large.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Separate migration from modernization
&lt;/h3&gt;

&lt;p&gt;First move to MAUI. Then improve the application.&lt;/p&gt;




&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;A Xamarin-to-.NET MAUI migration for a large enterprise application should not be considered a simple framework upgrade.&lt;/p&gt;

&lt;p&gt;It is an &lt;strong&gt;application transformation project&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The most effective strategy is to create a stable baseline, understand the application's dependencies, separate business logic from platform-specific code, establish the MAUI foundation, and then migrate complete business capabilities incrementally.&lt;/p&gt;

&lt;p&gt;The migration should look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        ASSESS
           ↓
        BASELINE
           ↓
        DECOUPLE
           ↓
      MAUI FOUNDATION
           ↓
       PILOT SLICE
           ↓
    FEATURE MIGRATION
           ↓
      VALIDATE
           ↓
     CANARY RELEASE
           ↓
    FULL PRODUCTION
           ↓
    RETIRE XAMARIN
           ↓
      MODERNIZE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most important mindset shift is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't ask, "How do we convert our Xamarin application to .NET MAUI?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"How do we move our enterprise application to .NET MAUI while keeping the business running?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That question leads to a very different—and much safer—migration strategy.&lt;/p&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>xamarin</category>
      <category>maui</category>
      <category>migration</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>VideoPipeline 1.1.0: Cap the Clip, Transcode on the OS, Then Encrypt</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:11:31 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/videopipeline-110-cap-the-clip-transcode-on-the-os-then-encrypt-11d9</link>
      <guid>https://dev.to/nuvyantralabs_ccee/videopipeline-110-cap-the-clip-transcode-on-the-os-then-encrypt-11d9</guid>
      <description>&lt;p&gt;A 40-second 4K clip should not leave the device at full size. The app needs a duration cap, a smaller frame, a thumbnail, and a file it can encrypt before upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.VideoPipeline&lt;/strong&gt; is that path. Version &lt;strong&gt;1.1.0&lt;/strong&gt; is on nuget.org. &lt;code&gt;FromCamera&lt;/code&gt; and &lt;code&gt;FromGallery&lt;/code&gt; pick the clip. &lt;code&gt;MaxDuration&lt;/code&gt;, &lt;code&gt;MaxResolution&lt;/code&gt;, and &lt;code&gt;MaxBytes&lt;/code&gt; are the gates. Over budget, the plugin asks the OS encoder to shrink it. If the device cannot encode, the result is &lt;code&gt;CannotTranscode&lt;/code&gt;. There is no FFmpeg binary in the package.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: how to register the defaults, run the builder, and what each platform actually transcodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What VideoPipeline is
&lt;/h2&gt;

&lt;p&gt;VideoPipeline is the video counterpart to MediaPipeline for &lt;strong&gt;.NET MAUI&lt;/strong&gt; on Android, iOS, Mac Catalyst, and Windows. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt; (API 21+), &lt;code&gt;net10.0-ios&lt;/code&gt;, &lt;code&gt;net10.0-maccatalyst&lt;/code&gt;, and &lt;code&gt;net10.0-windows10.0.17763.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It does one job: pick a video, enforce the budget, thumbnail it, and optionally encrypt it.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.VideoPipeline" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.VideoPipeline&lt;/code&gt;&lt;/a&gt; 1.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseVideoPipeline&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;VideoPipeline.FromCamera&lt;/code&gt;, &lt;code&gt;FromGallery&lt;/code&gt;, &lt;code&gt;FromFile&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypt&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Encrypt(key)&lt;/code&gt; writes AES-256-GCM &lt;code&gt;.vault&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.VideoPipeline" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.VideoPipeline&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-video-pipeline/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-video-pipeline/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Images stay on &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-media-pipeline/" rel="noopener noreferrer"&gt;Plugin.Maui.MediaPipeline&lt;/a&gt;. There is no &lt;code&gt;Current&lt;/code&gt; singleton. Tests inject a processor with &lt;code&gt;UseProcessor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and register
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.VideoPipeline &lt;span class="nt"&gt;--version&lt;/span&gt; 1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Plugin.Maui.VideoPipeline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseVideoPipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultMaxDuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&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;DefaultMaxDuration&lt;/code&gt; applies when the builder does not call &lt;code&gt;MaxDuration&lt;/code&gt;. The options default is already 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick, limit, thumbnail, encrypt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;VideoPipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromGallery&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxResolution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;720&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MaxBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThumbnailAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// result.Status: Cancelled, TooLarge, TooLong, CannotTranscode, Unsupported&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;FromCamera()&lt;/code&gt; records. &lt;code&gt;FromFile(path)&lt;/code&gt; and &lt;code&gt;FromSource&lt;/code&gt; skip the picker. &lt;code&gt;SaveAsync&lt;/code&gt; returns &lt;code&gt;VideoPipelineResult&lt;/code&gt;. &lt;code&gt;Succeeded&lt;/code&gt; is true only for &lt;code&gt;VideoPipelineStatus.Ok&lt;/code&gt;. The artifact then carries &lt;code&gt;ThumbnailPath&lt;/code&gt; when a frame could be decoded, and &lt;code&gt;Encrypted&lt;/code&gt; when &lt;code&gt;Encrypt&lt;/code&gt; ran.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Encrypt&lt;/code&gt; takes an AES key and writes a &lt;code&gt;.vault&lt;/code&gt; file. &lt;code&gt;DecryptFileAsync&lt;/code&gt; reverses that file. &lt;code&gt;UploadWith&lt;/code&gt; accepts an &lt;code&gt;IMediaUploader&lt;/code&gt;. &lt;code&gt;StoreIn&lt;/code&gt; accepts an &lt;code&gt;IMediaVault&lt;/code&gt;. Chunked upload that must resume after process death stays on SmartUpload once you have a path.&lt;/p&gt;

&lt;p&gt;1.1.0 is the release that thumbnails and asks the OS to transcode when the clip is over budget. Android transcode honors cancel and times out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each platform encodes
&lt;/h2&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;Probe and thumbnail&lt;/th&gt;
&lt;th&gt;Transcode&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;&lt;code&gt;MediaMetadataRetriever&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MediaCodec&lt;/code&gt; and &lt;code&gt;MediaMuxer&lt;/code&gt;. Many devices return &lt;code&gt;CannotTranscode&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS and Mac Catalyst&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AVAsset&lt;/code&gt; and &lt;code&gt;AVAssetImageGenerator&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AVAssetExportSession&lt;/code&gt; at 640×480 or 1280×720, with an optional duration trim.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CannotTranscode&lt;/code&gt;, plus a copy and thumbnail floor.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;net10.0&lt;/code&gt; tests&lt;/td&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CannotTranscode&lt;/code&gt; unless a test injects &lt;code&gt;UseProcessor&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;CannotTranscode&lt;/code&gt; is a typed result. The call does not throw, and the package does not fall back to a bundled encoder. If every Android device must shrink the file, an FFmpeg binding is the tool that makes that guarantee. VideoPipeline is the path when the OS encoder is enough and a failed encode must stay a status.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions the host still requests
&lt;/h2&gt;

&lt;p&gt;Android, in &lt;code&gt;Platforms/Android/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;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.CAMERA"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.RECORD_AUDIO"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.READ_MEDIA_VIDEO"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.READ_EXTERNAL_STORAGE"&lt;/span&gt; &lt;span class="na"&gt;android:maxSdkVersion=&lt;/span&gt;&lt;span class="s"&gt;"32"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Camera capture needs &lt;code&gt;CAMERA&lt;/code&gt; and usually &lt;code&gt;RECORD_AUDIO&lt;/code&gt;. Gallery pick on API 33+ uses &lt;code&gt;READ_MEDIA_VIDEO&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;iOS, in &lt;code&gt;Platforms/iOS/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;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSCameraUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;This app records video.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSMicrophoneUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;This app records audio with video.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSPhotoLibraryUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;This app reads videos from your library.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mac Catalyst and Windows use the camera and photo capabilities the MAUI host already declares.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cancelled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The picker was dismissed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TooLong&lt;/code&gt; or &lt;code&gt;TooLarge&lt;/code&gt; after transcode&lt;/td&gt;
&lt;td&gt;The OS encode still missed &lt;code&gt;MaxDuration&lt;/code&gt; or &lt;code&gt;MaxBytes&lt;/code&gt;. Lower the capture budget or reject the clip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CannotTranscode&lt;/code&gt; on Android&lt;/td&gt;
&lt;td&gt;That device's &lt;code&gt;MediaCodec&lt;/code&gt; path could not encode. Show the status. Do not expect FFmpeg behavior.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CannotTranscode&lt;/code&gt; on Windows&lt;/td&gt;
&lt;td&gt;Windows transcode is the typed result. Probe is file size.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;ThumbnailPath&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The platform could not decode a frame at &lt;code&gt;ThumbnailAt&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Encrypted&lt;/code&gt; is false&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Encrypt(key)&lt;/code&gt; was not on the builder.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The upload restarts after a kill&lt;/td&gt;
&lt;td&gt;VideoPipeline hands off a file. Resume belongs to SmartUpload.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where VideoPipeline sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Camera or gallery video, limits, thumbnail, OS transcode, encrypt&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;VideoPipeline&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.VideoPipeline&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera-to-upload images&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-media-pipeline/" rel="noopener noreferrer"&gt;Plugin.Maui.MediaPipeline&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resumable upload of the file you just wrote&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-smart-upload/" rel="noopener noreferrer"&gt;Plugin.Maui.SmartUpload&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A guaranteed encode on every Android device&lt;/td&gt;
&lt;td&gt;An FFmpeg binding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;MAUI &lt;code&gt;MediaPicker&lt;/code&gt; is enough when you only need the original file. VideoPipeline is the pipeline when the budget, the thumbnail, and the &lt;code&gt;.vault&lt;/code&gt; file are part of the same call.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.VideoPipeline &lt;span class="nt"&gt;--version&lt;/span&gt; 1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register &lt;code&gt;UseVideoPipeline&lt;/code&gt;, call &lt;code&gt;FromGallery&lt;/code&gt; with a 30-second cap and a 12 MB ceiling, and branch on &lt;code&gt;result.Status&lt;/code&gt;. &lt;code&gt;samples/Plugin.Maui.VideoPipeline.Sample&lt;/code&gt; covers camera, gallery, encrypt, thumbnail, and a &lt;code&gt;CannotTranscode&lt;/code&gt; result that does not crash.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.VideoPipeline" rel="noopener noreferrer"&gt;Plugin.Maui.VideoPipeline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.VideoPipeline" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.VideoPipeline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.VideoPipeline/tree/main/samples/Plugin.Maui.VideoPipeline.Sample" rel="noopener noreferrer"&gt;Plugin.Maui.VideoPipeline.Sample&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-video-pipeline/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-video-pipeline/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Admin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>video</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>OfflineSync 1.0.10: Write on the Device, Sync When the Network Is Real</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:09:31 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/offlinesync-1010-write-on-the-device-sync-when-the-network-is-real-3ddk</link>
      <guid>https://dev.to/nuvyantralabs_ccee/offlinesync-1010-write-on-the-device-sync-when-the-network-is-real-3ddk</guid>
      <description>&lt;p&gt;A field note written in a dead zone has to land on the device first. The server can catch up when the radio comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.OfflineSync&lt;/strong&gt; is that queue. Version &lt;strong&gt;1.0.10&lt;/strong&gt; is on nuget.org. &lt;code&gt;InsertAsync&lt;/code&gt;, &lt;code&gt;UpdateAsync&lt;/code&gt;, and &lt;code&gt;DeleteAsync&lt;/code&gt; finish locally. A change log records the mutation. &lt;code&gt;SyncAsync&lt;/code&gt; pushes the log and pulls remote updates. A failed automatic push is isolated so it does not take the process down.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: how to register the engine, write a &lt;code&gt;SyncableEntity&lt;/code&gt;, pick a conflict strategy, and what the HTTP remote expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OfflineSync is
&lt;/h2&gt;

&lt;p&gt;OfflineSync is an offline-first sync engine for &lt;strong&gt;.NET MAUI&lt;/strong&gt; on Android and iOS. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt; (API 21+), and &lt;code&gt;net10.0-ios&lt;/code&gt; (15+). The local file is SQLite (&lt;code&gt;offlinesync.db3&lt;/code&gt; under app data) unless you pass &lt;code&gt;DatabasePath&lt;/code&gt; or &lt;code&gt;UseInMemoryStore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It does one job: keep the write on the device, then ship the change log when the network is real.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.OfflineSync" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.OfflineSync&lt;/code&gt;&lt;/a&gt; 1.0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseOfflineSync&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IOfflineSyncEngine&lt;/code&gt;, or &lt;code&gt;OfflineSync.Default&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store&lt;/td&gt;
&lt;td&gt;SQLite by default, or an in-memory store for tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.OfflineSync" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.OfflineSync&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-offline-sync/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-offline-sync/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Room-style insert, find, replace, and delete across SQLite, NuvexaDB, Realm, or LiteDB stays on &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-local-store/" rel="noopener noreferrer"&gt;Plugin.Maui.LocalStore&lt;/a&gt;. OfflineSync is the sync protocol, not a second CRUD API for every engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and register
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.OfflineSync &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Plugin.Maui.OfflineSync&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOfflineSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoteBaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.example.com/sync/"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConflictStrategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConflictStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastWriteWins&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AutoSync&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AutoSyncInterval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&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;AutoSync&lt;/code&gt; is on by default, on a five-minute timer. &lt;code&gt;SyncOnNetworkRestored&lt;/code&gt; and &lt;code&gt;SyncOnResume&lt;/code&gt; are also on. Timer and connectivity sync catch exceptions. Subscribe to &lt;code&gt;SyncCompleted&lt;/code&gt; and &lt;code&gt;StatusChanged&lt;/code&gt; when the UI has to show a failed push. 1.0.10 also finishes the Android job even when &lt;code&gt;JobFinished&lt;/code&gt; arrives after the service instance is gone.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LocalOnly&lt;/code&gt; skips remote calls. &lt;code&gt;EnableBackgroundSync&lt;/code&gt; registers Android &lt;code&gt;JobScheduler&lt;/code&gt; or iOS &lt;code&gt;BGTaskScheduler&lt;/code&gt;. That flag still needs the manifest entries below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write locally
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TodoItem&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SyncableEntity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&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="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsDone&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OfflineSync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetCollection&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TodoItem&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"todos"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TodoItem&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Buy milk"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;OfflineSync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SyncAsync&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;SyncableEntity&lt;/code&gt; carries &lt;code&gt;Id&lt;/code&gt;, &lt;code&gt;Version&lt;/code&gt;, &lt;code&gt;CreatedAtUtc&lt;/code&gt;, &lt;code&gt;UpdatedAtUtc&lt;/code&gt;, &lt;code&gt;IsDeleted&lt;/code&gt;, and a local &lt;code&gt;SyncState&lt;/code&gt;. An empty id is filled on insert. &lt;code&gt;SyncState&lt;/code&gt; stays on the device. An edit after an insert stays one insert in the change log.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InsertAsync&lt;/code&gt;, &lt;code&gt;UpdateAsync&lt;/code&gt;, and &lt;code&gt;DeleteAsync&lt;/code&gt; complete against SQLite before any HTTP call. &lt;code&gt;GetAsync&lt;/code&gt;, &lt;code&gt;GetAllAsync&lt;/code&gt;, and &lt;code&gt;QueryAsync&lt;/code&gt; read that local set. &lt;code&gt;GetPendingCountAsync&lt;/code&gt; tells you how many changes are still waiting. &lt;code&gt;RequeueFailedAsync&lt;/code&gt; puts a &lt;code&gt;Failed&lt;/code&gt; change back on the queue after &lt;code&gt;MaxRetryAttempts&lt;/code&gt; (8 by default).&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflicts
&lt;/h2&gt;

&lt;p&gt;The default strategy is &lt;code&gt;ConflictStrategy.LastWriteWins&lt;/code&gt;: the later &lt;code&gt;UpdatedAtUtc&lt;/code&gt; is kept. &lt;code&gt;ServerWins&lt;/code&gt; keeps the remote document. &lt;code&gt;ClientWins&lt;/code&gt; keeps the local one. &lt;code&gt;Custom&lt;/code&gt; requires &lt;code&gt;OfflineSyncOptions.CustomConflictResolver&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;OfflineSync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConflictDetected&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// args.Collection, args.EntityId, args.Winner: Local, Remote, or Merged&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;StatusChanged&lt;/code&gt; reports &lt;code&gt;Idle&lt;/code&gt;, &lt;code&gt;Syncing&lt;/code&gt;, &lt;code&gt;Offline&lt;/code&gt;, or &lt;code&gt;Failed&lt;/code&gt;. &lt;code&gt;SyncCompleted&lt;/code&gt; fires after a cycle finishes, including a skip or a failure. &lt;code&gt;CollectionChanged&lt;/code&gt; fires for a local mutation and for a remote merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the HTTP remote looks like
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpRemoteSyncClient&lt;/code&gt; is the default when you set &lt;code&gt;RemoteBaseAddress&lt;/code&gt;. Pull is &lt;code&gt;GET {base}/{collection}?cursor={cursor}&lt;/code&gt;. Push is &lt;code&gt;POST {base}/{collection}/changes&lt;/code&gt; with &lt;code&gt;operation&lt;/code&gt;, &lt;code&gt;baseVersion&lt;/code&gt;, &lt;code&gt;updatedAtUtc&lt;/code&gt;, and &lt;code&gt;payloadJson&lt;/code&gt;. The response lists &lt;code&gt;accepted&lt;/code&gt;, &lt;code&gt;conflicts&lt;/code&gt;, and &lt;code&gt;rejected&lt;/code&gt;. A conflict should include &lt;code&gt;serverVersion&lt;/code&gt;, &lt;code&gt;serverUpdatedAtUtc&lt;/code&gt;, and &lt;code&gt;serverPayloadJson&lt;/code&gt; so the resolver can run.&lt;/p&gt;

&lt;p&gt;Supply your own transport when the API is already shaped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IRemoteSyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MyApiClient&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RemoteAccessToken&lt;/code&gt; or &lt;code&gt;RemoteAccessTokenProvider&lt;/code&gt; attaches a bearer token to the built-in client. An in-memory remote exists for the sample and for tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background sync permissions
&lt;/h2&gt;

&lt;p&gt;Leave &lt;code&gt;EnableBackgroundSync&lt;/code&gt; false until the host declares the platform entries.&lt;/p&gt;

&lt;p&gt;Android:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.RECEIVE_BOOT_COMPLETED"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.INTERNET"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_NETWORK_STATE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iOS, with the identifier &lt;code&gt;plugin.maui.offlinesync.refresh&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;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;BGTaskSchedulerPermittedIdentifiers&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;plugin.maui.offlinesync.refresh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIBackgroundModes&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;fetch&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;processing&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OfflineSync has not been initialized&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Call &lt;code&gt;UseOfflineSync&lt;/code&gt; before &lt;code&gt;OfflineSync.Default&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writes succeed and nothing reaches the server&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LocalOnly&lt;/code&gt; is true, &lt;code&gt;AutoSync&lt;/code&gt; is false and nothing called &lt;code&gt;SyncAsync&lt;/code&gt;, or the device is still offline.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status stays &lt;code&gt;Failed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Read &lt;code&gt;SyncCompleted&lt;/code&gt;. After &lt;code&gt;MaxRetryAttempts&lt;/code&gt;, call &lt;code&gt;RequeueFailedAsync&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ConflictStrategy.Custom&lt;/code&gt; throws at startup&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;CustomConflictResolver&lt;/code&gt;. Custom without a resolver is rejected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A failed auto-sync kills the app&lt;/td&gt;
&lt;td&gt;That crash path is what 1.0.10 isolates. Surface the error from &lt;code&gt;SyncCompleted&lt;/code&gt; instead.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background sync never runs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EnableBackgroundSync&lt;/code&gt; is false, or the manifest / &lt;code&gt;Info.plist&lt;/code&gt; entries are missing.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where OfflineSync sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local writes, a change log, and a conflict on sync&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;OfflineSync&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.OfflineSync&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Room-style CRUD on the engine you pick&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-local-store/" rel="noopener noreferrer"&gt;Plugin.Maui.LocalStore&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry one HTTP call that already failed&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-retry-queue/" rel="noopener noreferrer"&gt;Plugin.Maui.RetryQueue&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable jobs with a dead-letter path&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-job-queue/" rel="noopener noreferrer"&gt;Plugin.Maui.JobQueue&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Realm and Azure Mobile Apps are the usual choice when the organization already runs that sync fabric. OfflineSync is the engine when the host wants SQLite, a small HTTP protocol, and an explicit winner for each conflict.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.OfflineSync &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register &lt;code&gt;UseOfflineSync&lt;/code&gt; with an https base address. Insert one &lt;code&gt;SyncableEntity&lt;/code&gt;, then call &lt;code&gt;SyncAsync&lt;/code&gt; or wait for the five-minute auto-sync. &lt;code&gt;samples/Plugin.Maui.OfflineSync.Sample&lt;/code&gt; is a todo app that writes to SQLite and syncs through an in-process remote.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.OfflineSync" rel="noopener noreferrer"&gt;Plugin.Maui.OfflineSync&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.OfflineSync" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.OfflineSync&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.OfflineSync/tree/main/samples/Plugin.Maui.OfflineSync.Sample" rel="noopener noreferrer"&gt;Plugin.Maui.OfflineSync.Sample&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-offline-sync/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-offline-sync/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Admin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>sync</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>TlsPin 1.0.2: Reject the Call When the SPKI Pin Does Not Match</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:06:35 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/tlspin-102-reject-the-call-when-the-spki-pin-does-not-match-1hob</link>
      <guid>https://dev.to/nuvyantralabs_ccee/tlspin-102-reject-the-call-when-the-spki-pin-does-not-match-1hob</guid>
      <description>&lt;p&gt;A payment client that accepts any certificate chain will complete the call on the wrong key. The pin has to fail that request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.TlsPin&lt;/strong&gt; is that check. Version &lt;strong&gt;1.0.2&lt;/strong&gt; is on nuget.org. &lt;code&gt;AddTlsPin&lt;/code&gt; attaches SPKI SHA-256 hashes to one named &lt;code&gt;HttpClient&lt;/code&gt;. An empty pin set throws when you register it. A mismatch fails the request. &lt;code&gt;ReportOnly&lt;/code&gt; is the staging switch, and it still raises &lt;code&gt;OnPinFailure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: where the pin attaches, why you ship a backup hash, and which HTTP jobs stay on the sibling plugins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TlsPin is
&lt;/h2&gt;

&lt;p&gt;TlsPin is the certificate-pin layer for &lt;strong&gt;.NET MAUI&lt;/strong&gt; &lt;code&gt;HttpClient&lt;/code&gt; on Android, iOS, Mac Catalyst, and Windows. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt;, &lt;code&gt;net10.0-ios&lt;/code&gt;, &lt;code&gt;net10.0-maccatalyst&lt;/code&gt;, and &lt;code&gt;net10.0-windows10.0.17763.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It does one job: compare the server public key to the hashes you configured.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.TlsPin" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.TlsPin&lt;/code&gt;&lt;/a&gt; 1.0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UseTlsPin&lt;/code&gt;, then &lt;code&gt;AddTlsPin&lt;/code&gt; on the client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pin&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TlsPinSet.SpkiSha256&lt;/code&gt;, base64 SHA-256 of the SPKI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.TlsPin" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.TlsPin&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-tls-pin/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-tls-pin/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;UseTlsPin&lt;/code&gt; is a no-op registrar. There is no &lt;code&gt;Current&lt;/code&gt; singleton. The pin lives on the &lt;code&gt;IHttpClientBuilder&lt;/code&gt; you already use for that API. Retry, typed REST, and token refresh stay on ApiResilience, HttpForge, and SecureSession.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and pin one host
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.TlsPin &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Plugin.Maui.TlsPin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseTlsPin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.example.com"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTlsPin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TlsPinSet&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;SpkiSha256&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backup&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;primary&lt;/code&gt; and &lt;code&gt;backup&lt;/code&gt; are base64 SHA-256 hashes of &lt;code&gt;certificate.PublicKey.ExportSubjectPublicKeyInfo()&lt;/code&gt;. &lt;code&gt;TlsPin.ComputeSpkiSha256&lt;/code&gt; produces that string from an &lt;code&gt;X509Certificate2&lt;/code&gt;. Ship at least one backup pin so a planned key rotation does not brick every install on the day the leaf changes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AddTlsPin&lt;/code&gt; throws if &lt;code&gt;Pins&lt;/code&gt; is empty, and it throws if any host set has an empty &lt;code&gt;SpkiSha256&lt;/code&gt; list. That failure is at registration, before the first request.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RequireHttps&lt;/code&gt; defaults to true. A host that does not appear in &lt;code&gt;Pins&lt;/code&gt; fails closed. &lt;code&gt;AllowUnpinnedHosts&lt;/code&gt; is the opt-in that lets an unlisted host through. Leave it false on the payments client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Report-only staging
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ReportOnly&lt;/code&gt; still calls &lt;code&gt;OnPinFailure&lt;/code&gt; and then allows the request. Use it while you confirm the hash in a build you can still talk to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddTlsPin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReportOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnPinFailure&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// reason is "pin mismatch" or "unpinned host"&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;TlsPinSet&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;SpkiSha256&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backup&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;Turn &lt;code&gt;ReportOnly&lt;/code&gt; off before the store build. A mismatch then fails the request. &lt;code&gt;OnPinFailure&lt;/code&gt; still runs, with &lt;code&gt;"pin mismatch"&lt;/code&gt; or &lt;code&gt;"unpinned host"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Android needs &lt;code&gt;INTERNET&lt;/code&gt; if the host manifest does not already declare it. There is no pinning permission. iOS needs no extra usage string. ATS stays on because &lt;code&gt;RequireHttps&lt;/code&gt; is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TlsPin fail-closed: configure at least one host pin set&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AddTlsPin&lt;/code&gt; ran with an empty &lt;code&gt;Pins&lt;/code&gt; dictionary.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TlsPin fail-closed: a host pin set is empty&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A host entry has no &lt;code&gt;SpkiSha256&lt;/code&gt; value. Add the primary hash and a backup.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every call fails after a certificate rotation&lt;/td&gt;
&lt;td&gt;The new SPKI is not in the set. The backup pin is what you rotate in before the leaf changes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls succeed in staging and fail in production&lt;/td&gt;
&lt;td&gt;Staging still has &lt;code&gt;ReportOnly = true&lt;/code&gt;. Production does not.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A second host on the same client fails&lt;/td&gt;
&lt;td&gt;That host is not in &lt;code&gt;Pins&lt;/code&gt;, and &lt;code&gt;AllowUnpinnedHosts&lt;/code&gt; is false. Pin it, or give it its own named client.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You wanted retries on 401&lt;/td&gt;
&lt;td&gt;That is ApiResilience or SecureSession. TlsPin only checks the key.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where TlsPin sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SPKI pin on one named &lt;code&gt;HttpClient&lt;/code&gt;, fail closed&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;TlsPin&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.TlsPin&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A source-generated REST interface&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-httpforge/" rel="noopener noreferrer"&gt;Plugin.Maui.HttpForge&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry, circuit breaker, offline queue&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-api-resilience/" rel="noopener noreferrer"&gt;Plugin.Maui.ApiResilience&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access tokens and 401 refresh&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-secure-session/" rel="noopener noreferrer"&gt;Plugin.Maui.SecureSession&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A custom &lt;code&gt;HttpClientHandler&lt;/code&gt;, or the platform &lt;code&gt;TrustManager&lt;/code&gt; / &lt;code&gt;NSURLSession&lt;/code&gt; pin, is the usual choice when the app already owns that callback. TlsPin is the check when the hash set, the empty-set throw, and &lt;code&gt;ReportOnly&lt;/code&gt; should sit on the same &lt;code&gt;IHttpClientBuilder&lt;/code&gt; as the rest of the MAUI HTTP stack.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.TlsPin &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;UseTlsPin&lt;/code&gt;, then &lt;code&gt;AddTlsPin&lt;/code&gt; on the payments client with two SPKI hashes. Confirm &lt;code&gt;OnPinFailure&lt;/code&gt; in a &lt;code&gt;ReportOnly&lt;/code&gt; build, then ship with &lt;code&gt;ReportOnly&lt;/code&gt; left false. &lt;code&gt;samples/Plugin.Maui.TlsPin.Sample&lt;/code&gt; covers the public API.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.TlsPin" rel="noopener noreferrer"&gt;Plugin.Maui.TlsPin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.TlsPin" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.TlsPin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.TlsPin/tree/main/samples/Plugin.Maui.TlsPin.Sample" rel="noopener noreferrer"&gt;Plugin.Maui.TlsPin.Sample&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-tls-pin/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-tls-pin/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Admin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>maui</category>
      <category>tls</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>Geofence 1.1.0: Enter, Exit, and Dwell Inside Twenty Circular Regions</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:04:52 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/geofence-110-enter-exit-and-dwell-inside-twenty-circular-regions-56ep</link>
      <guid>https://dev.to/nuvyantralabs_ccee/geofence-110-enter-exit-and-dwell-inside-twenty-circular-regions-56ep</guid>
      <description>&lt;p&gt;A depot app does not need a new GPS fix every few seconds. It needs to know when the device crosses a circle drawn around the yard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.Geofence&lt;/strong&gt; is that monitor. Version &lt;strong&gt;1.1.0&lt;/strong&gt; is on nuget.org. You register named circular regions. The operating system reports enter, exit, and dwell. The list is written to app-private storage and registered again after the process dies.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: how to register the plugin, add a region, read a transition, and which permissions the host still has to request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Geofence is
&lt;/h2&gt;

&lt;p&gt;Geofence is a circular-region monitor for &lt;strong&gt;.NET MAUI&lt;/strong&gt; on Android and iOS. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt; (API 21+), and &lt;code&gt;net10.0-ios&lt;/code&gt; (15+). Mac Catalyst and Windows are not primary targets.&lt;/p&gt;

&lt;p&gt;It does one job: answer when the user enters, exits, or dwells in up to 20 named regions.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.Geofence" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.Geofence&lt;/code&gt;&lt;/a&gt; 1.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseGeofence&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IGeofence&lt;/code&gt;, or &lt;code&gt;Geofence.Current&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;Android &lt;code&gt;GeofencingClient&lt;/code&gt;, iOS &lt;code&gt;CLCircularRegion&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.Geofence" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.Geofence&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-geofence/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-geofence/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A current position, a tracking session, and reverse geocoding stay on &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-geolocator/" rel="noopener noreferrer"&gt;Plugin.Maui.GeoLocator&lt;/a&gt;. Geofence does not draw a map.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and register
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.Geofence &lt;span class="nt"&gt;--version&lt;/span&gt; 1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Plugin.Maui.Geofence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseGeofence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxRegions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinimumRadiusMeters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&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;UseGeofence&lt;/code&gt; loads &lt;code&gt;plugin.maui.geofence.regions.tsv&lt;/code&gt; from app data and re-registers those regions with the OS. Resolve &lt;code&gt;IGeofence&lt;/code&gt; from dependency injection, or use &lt;code&gt;Geofence.Current&lt;/code&gt; after registration. Calling &lt;code&gt;Current&lt;/code&gt; before &lt;code&gt;UseGeofence&lt;/code&gt; throws.&lt;/p&gt;

&lt;p&gt;A radius of zero is replaced with &lt;code&gt;DefaultRadiusMeters&lt;/code&gt; (100). A radius below &lt;code&gt;MinimumRadiusMeters&lt;/code&gt; is rejected. Replacing a region that already has the same id does not consume another slot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a region
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Geofence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;GeofenceRegion&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"depot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Latitude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;12.97&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Longitude&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;77.59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RadiusMeters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NotifyOnEntry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NotifyOnExit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NotifyOnDwell&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Dwell&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// result.Status: Denied, LimitReached, InvalidRadius, InvalidId, NotSupported&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;AddAsync&lt;/code&gt; returns a typed result. A missing location permission is &lt;code&gt;Denied&lt;/code&gt;. Missing Play services is &lt;code&gt;NotSupported&lt;/code&gt;. The 21st distinct id is &lt;code&gt;LimitReached&lt;/code&gt;. An empty id is &lt;code&gt;InvalidId&lt;/code&gt;. The call does not throw because the user said no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read a transition
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Geofence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transition&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// args.RegionId, args.Kind: Enter, Exit, or Dwell&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OS events queue until something subscribes to &lt;code&gt;Transition&lt;/code&gt;. Attach the handler before you expect the first crossing, including crossings that happened while the process was down.&lt;/p&gt;

&lt;p&gt;Android dwell uses &lt;code&gt;GEOFENCE_TRANSITION_DWELL&lt;/code&gt; and a loitering delay of at least 60 seconds. iOS has no loitering delay. After enter, the plugin starts a timer and cancels it on exit.&lt;/p&gt;

&lt;p&gt;Samples and tests can fire the same event without moving the device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Geofence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GeofenceTransitionKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"depot"&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;GetMonitoredAsync&lt;/code&gt; lists the regions still stored. &lt;code&gt;RemoveAsync&lt;/code&gt; and &lt;code&gt;RemoveAllAsync&lt;/code&gt; drop them from the store and from the OS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions the host still requests
&lt;/h2&gt;

&lt;p&gt;The plugin does not prompt. Declare the usage strings, then request location yourself.&lt;/p&gt;

&lt;p&gt;Android, in &lt;code&gt;Platforms/Android/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;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_COARSE_LOCATION"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_FINE_LOCATION"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_BACKGROUND_LOCATION"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddAsync&lt;/code&gt; needs fine location while the app is in use. Background transitions on Android 10+ also need &lt;code&gt;ACCESS_BACKGROUND_LOCATION&lt;/code&gt; and the Play disclosure your listing already requires.&lt;/p&gt;

&lt;p&gt;iOS, in &lt;code&gt;Platforms/iOS/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;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSLocationWhenInUseUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;This app monitors geofences while you use it.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSLocationAlwaysAndWhenInUseUsageDescription&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;This app monitors geofences when the app is in the background.&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIBackgroundModes&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;location&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Background enter and exit on &lt;code&gt;CLCircularRegion&lt;/code&gt; need Always authorization. Denied or Restricted comes back as &lt;code&gt;Denied&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Id&lt;/code&gt; is blank. Every region needs a stable name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidRadius&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Radius is below &lt;code&gt;MinimumRadiusMeters&lt;/code&gt; (50 by default).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LimitReached&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Twenty distinct ids are already monitored. Replace an id or remove one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Denied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Location permission is missing, or iOS authorization is Denied or Restricted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NotSupported&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Play services are missing on Android.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No event after a crossing&lt;/td&gt;
&lt;td&gt;Nothing is subscribed to &lt;code&gt;Transition&lt;/code&gt; yet, or background location was never granted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regions vanish after a kill&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UseGeofence&lt;/code&gt; was not called on the next launch, so the TSV was never re-registered.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Geofence sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Enter, exit, and dwell for up to 20 circles&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Geofence&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.Geofence&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A current fix, a tracking session, reverse geocoding&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-geolocator/" rel="noopener noreferrer"&gt;Plugin.Maui.GeoLocator&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A measured offline trip against a vehicle meter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/research/gps-sensor-tracking-service/" rel="noopener noreferrer"&gt;GPSSensorTrackingService&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Play &lt;code&gt;GeofencingClient&lt;/code&gt;, &lt;code&gt;CLCircularRegion&lt;/code&gt;, and &lt;a href="https://github.com/shinyorg/shiny" rel="noopener noreferrer"&gt;Shiny&lt;/a&gt; are the usual native and community choices when the app already speaks one of those APIs. Geofence is the client when the same &lt;code&gt;IGeofence&lt;/code&gt; has to run on Android and iOS, cap the list at 20, and reload it after process death.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.Geofence &lt;span class="nt"&gt;--version&lt;/span&gt; 1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register &lt;code&gt;UseGeofence&lt;/code&gt;, add one region with a radius of at least 50 meters, and subscribe to &lt;code&gt;Transition&lt;/code&gt; before you leave the yard. &lt;code&gt;samples/Plugin.Maui.Geofence.Sample&lt;/code&gt; requests location, adds a region, lists the persisted set, and simulates enter, exit, and dwell with &lt;code&gt;Raise()&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.Geofence" rel="noopener noreferrer"&gt;Plugin.Maui.Geofence&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.Geofence" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.Geofence&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.Geofence/tree/main/samples/Plugin.Maui.Geofence.Sample" rel="noopener noreferrer"&gt;Plugin.Maui.Geofence.Sample&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-geofence/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-geofence/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Admin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>maui</category>
      <category>geofence</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>LeakAnalyser 1.0.1: See Which MAUI View Survives After You Pop It</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 11:02:43 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/leakanalyser-101-see-which-maui-view-survives-after-you-pop-it-5bd7</link>
      <guid>https://dev.to/nuvyantralabs_ccee/leakanalyser-101-see-which-maui-view-survives-after-you-pop-it-5bd7</guid>
      <description>&lt;p&gt;A page that leaves the navigation stack should become collectable. If a handler, a binding, or a static root still points at it, the next push pays for the last one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.LeakAnalyser&lt;/strong&gt; answers that one question. Version &lt;strong&gt;1.0.1&lt;/strong&gt; is on nuget.org. After a view looks finished, it holds a &lt;code&gt;WeakReference&lt;/code&gt;, forces GC, and reports whether the object is still alive. Detection stays in Debug. Teardown can stay in Release.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: detect-only in Debug, disconnect handlers when you are ready, and which pages you must suppress because they are cached on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LeakAnalyser is
&lt;/h2&gt;

&lt;p&gt;LeakAnalyser is a liveness test for &lt;strong&gt;.NET MAUI&lt;/strong&gt; pages and views on Android, iOS, Mac Catalyst, and Windows. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt; (API 21+), &lt;code&gt;net10.0-ios&lt;/code&gt; and &lt;code&gt;net10.0-maccatalyst&lt;/code&gt; (15+), and &lt;code&gt;net10.0-windows&lt;/code&gt; (10.0.17763+; the Windows TFM is included when the package is packed on Windows).&lt;/p&gt;

&lt;p&gt;It does one job: report that a finished view is still rooted. A profiler tells you why.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.LeakAnalyser" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.LeakAnalyser&lt;/code&gt;&lt;/a&gt; 1.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseLeakAnalyser&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LeakMonitor.Cascade&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teardown&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TearDown.Cascade&lt;/code&gt; with &lt;code&gt;DisconnectHandlers&lt;/code&gt; or &lt;code&gt;Compartmentalize&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.LeakAnalyser" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.LeakAnalyser&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guides&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/integration/" rel="noopener noreferrer"&gt;Get started&lt;/a&gt; · &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/docs/" rel="noopener noreferrer"&gt;How it works&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The API is &lt;code&gt;UseLeakAnalyser&lt;/code&gt;, &lt;code&gt;LeakMonitor&lt;/code&gt;, &lt;code&gt;TearDown&lt;/code&gt;, and &lt;code&gt;LeakGraph&lt;/code&gt;. It is inspired by AdamE.MemoryToolkit.Maui. It is not a drop-in fork of that toolkit. 1.0.1 does not change the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug: detect only
&lt;/h2&gt;

&lt;p&gt;Forced GC is a probe. Keep &lt;code&gt;UseLeakAnalyser&lt;/code&gt; and &lt;code&gt;LeakMonitor.Cascade&lt;/code&gt; inside &lt;code&gt;DEBUG&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Plugin.Maui.LeakAnalyser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cp"&gt;#if DEBUG
&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDebug&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseLeakAnalyser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnLeaked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* alert, using target.Name */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultTearDownStrategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TearDownStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DetectOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ContentPage&lt;/span&gt; &lt;span class="na"&gt;xmlns:la=&lt;/span&gt;&lt;span class="s"&gt;"clr-namespace:Plugin.Maui.LeakAnalyser;assembly=Plugin.Maui.LeakAnalyser"&lt;/span&gt;
             &lt;span class="na"&gt;la:LeakMonitor.Cascade=&lt;/span&gt;&lt;span class="s"&gt;"True"&lt;/span&gt;
             &lt;span class="na"&gt;la:LeakMonitor.Name=&lt;/span&gt;&lt;span class="s"&gt;"OrdersPage"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OnLeaked&lt;/code&gt; fires when the target is still alive after the collection passes. &lt;code&gt;OnCollected&lt;/code&gt; fires when it is collected. Collection is the healthy outcome. &lt;code&gt;CollectionTarget&lt;/code&gt; carries &lt;code&gt;Name&lt;/code&gt;, &lt;code&gt;ObjectType&lt;/code&gt;, and &lt;code&gt;Screen&lt;/code&gt;. Defaults are 10 collections, 200 milliseconds apart.&lt;/p&gt;

&lt;p&gt;The monitor waits until the view looks finished: a page popped from an active &lt;code&gt;NavigationPage&lt;/code&gt;, a detached template, or a short wait that skips anything still on the navigation stack, still hosted by Shell, or still under a tab. Cached pages and tabs stay alive on purpose. Suppress those, or they look like leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disconnect handlers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DisconnectHandlers&lt;/code&gt; is the default when you omit &lt;code&gt;Strategy&lt;/code&gt;. It walks children and calls &lt;code&gt;DisconnectHandler()&lt;/code&gt;. It leaves &lt;code&gt;BindingContext&lt;/code&gt;, &lt;code&gt;Content&lt;/code&gt;, and &lt;code&gt;Parent&lt;/code&gt; in place. It honors &lt;code&gt;HandlerDisconnectPolicy.Manual&lt;/code&gt; and &lt;code&gt;TearDown.Suppress&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Put &lt;code&gt;TearDown&lt;/code&gt; after &lt;code&gt;LeakMonitor&lt;/code&gt; so teardown does not run before the snapshot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;la:LeakMonitor.Cascade="True"
la:TearDown.Cascade="True"
la:TearDown.Strategy="DisconnectHandlers"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same attached properties exist in C#: &lt;code&gt;LeakMonitor.SetCascade&lt;/code&gt;, &lt;code&gt;TearDown.SetCascade&lt;/code&gt;, &lt;code&gt;page.Monitor()&lt;/code&gt;, and &lt;code&gt;page.TearDown(TearDownStrategy.DisconnectHandlers)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Release, teardown can stay on without detection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;la:TearDown.Cascade="True"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not call &lt;code&gt;UseLeakAnalyser&lt;/code&gt; or set &lt;code&gt;LeakMonitor.Cascade&lt;/code&gt; in Release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compartmentalize
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Compartmentalize&lt;/code&gt; clears common managed references, runs &lt;code&gt;TearDown.OnTearDown&lt;/code&gt; while the element still has a handler, then disconnects. Each clear is isolated. A throwing setter is logged and teardown continues.&lt;/p&gt;

&lt;p&gt;It clears &lt;code&gt;BindingContext&lt;/code&gt;, &lt;code&gt;Parent&lt;/code&gt;, logical children, behaviors, resources, gesture recognizers, formatted text, item sources, and &lt;code&gt;Content&lt;/code&gt; on content views, borders, pages, and scroll views. CommunityToolkit hooks stay, because they are attached properties.&lt;/p&gt;

&lt;p&gt;Opt into it per page. A Shell flyout or a &lt;code&gt;NavigationPage&lt;/code&gt; that still holds modals will blank if this runs too early.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;la:TearDown.Strategy="Compartmentalize"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A breadcrumb, not a crash
&lt;/h2&gt;

&lt;p&gt;LeakAnalyser does not reference Diagnostics or Observability. Forward &lt;code&gt;OnLeaked&lt;/code&gt; yourself when the host already has those plugins. Register Diagnostics first if you want its logger ready before the first callback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG
&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseMauiDiagnostics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseLeakAnalyser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnLeaked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;MauiDiagnostics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrackEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Leak:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;MauiDiagnostics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrackException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; survived forced GC."&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="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Track &lt;code&gt;OnLeaked&lt;/code&gt; only. A collected target is success. Forced GC counts are not an app-health signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every Shell tab reports a leak&lt;/td&gt;
&lt;td&gt;Cached pages stay alive on purpose. &lt;code&gt;LeakMonitor.SetSuppress&lt;/code&gt; and &lt;code&gt;TearDown.SetSuppress&lt;/code&gt; on that host.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A leak appears while the browser sheet is open&lt;/td&gt;
&lt;td&gt;Temporary unloads such as &lt;code&gt;Browser.OpenAsync&lt;/code&gt; should suppress the host &lt;code&gt;NavigationPage&lt;/code&gt;, then clear Suppress on &lt;code&gt;Loaded&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A template root leaks and the page does not&lt;/td&gt;
&lt;td&gt;Put &lt;code&gt;Cascade&lt;/code&gt; on each &lt;code&gt;ControlTemplate&lt;/code&gt; root, not only on the host control.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;OnLeaked&lt;/code&gt; names the page and you still do not know the root&lt;/td&gt;
&lt;td&gt;Open Instruments, dotMemory, or Visual Studio diagnostics. This package reports liveness.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release builds feel slower after navigation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LeakMonitor.Cascade&lt;/code&gt; or &lt;code&gt;UseLeakAnalyser&lt;/code&gt; is still compiled into Release. Detection forces GC.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where LeakAnalyser sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is this finished view still alive?&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;LeakAnalyser&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.LeakAnalyser&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The retain path&lt;/td&gt;
&lt;td&gt;Instruments, dotMemory, or Visual Studio diagnostics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash, ANR, and breadcrumbs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-diagnostics/" rel="noopener noreferrer"&gt;Plugin.Maui.Diagnostics&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup, page, and API timings&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-performance/" rel="noopener noreferrer"&gt;Plugin.Maui.Performance&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/adameste/AdamE.MemoryToolkit.Maui" rel="noopener noreferrer"&gt;AdamE.MemoryToolkit.Maui&lt;/a&gt; is the usual community toolkit for the same class of problem. LeakAnalyser is the package when you want &lt;code&gt;LeakMonitor&lt;/code&gt; plus an optional &lt;code&gt;DisconnectHandlers&lt;/code&gt; or &lt;code&gt;Compartmentalize&lt;/code&gt; pass, and you already accept that the retain path lives in a profiler.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.LeakAnalyser &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Debug, call &lt;code&gt;UseLeakAnalyser&lt;/code&gt; with &lt;code&gt;TearDownStrategy.DetectOnly&lt;/code&gt;, set &lt;code&gt;LeakMonitor.Cascade&lt;/code&gt; and &lt;code&gt;LeakMonitor.Name&lt;/code&gt; on one page, pop it, and read &lt;code&gt;OnLeaked&lt;/code&gt; or &lt;code&gt;OnCollected&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.LeakAnalyser" rel="noopener noreferrer"&gt;Plugin.Maui.LeakAnalyser&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.LeakAnalyser" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.LeakAnalyser&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How it works: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/docs/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-leak-analyser/docs/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Admin&lt;/a&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>memoryleak</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>SmartUpload 1.0.7: Resume a File Upload After the Process Dies</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:54:20 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/smartupload-107-resume-a-file-upload-after-the-process-dies-129g</link>
      <guid>https://dev.to/nuvyantralabs_ccee/smartupload-107-resume-a-file-upload-after-the-process-dies-129g</guid>
      <description>&lt;p&gt;A field photo that dies halfway through one POST has to start again from byte zero. The next launch has no record of what the server already accepted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin.Maui.SmartUpload&lt;/strong&gt; keeps that record. Version &lt;strong&gt;1.0.7&lt;/strong&gt; is on nuget.org. The file is sent in slices. Each acknowledged offset is written to disk. After a crash, pause, or retry, the next attempt continues from that offset.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: how to register the client, enqueue a file, which wire protocol to pick, and what still belongs to the host when the transfer has to keep running after the UI is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SmartUpload is
&lt;/h2&gt;

&lt;p&gt;SmartUpload is the chunked upload client for &lt;strong&gt;.NET MAUI&lt;/strong&gt; on Android, iOS, Mac Catalyst, and Windows. MIT licensed. It targets &lt;code&gt;net10.0&lt;/code&gt;, &lt;code&gt;net10.0-android&lt;/code&gt; (API 21+), &lt;code&gt;net10.0-ios&lt;/code&gt; and &lt;code&gt;net10.0-maccatalyst&lt;/code&gt; (15+), and &lt;code&gt;net10.0-windows&lt;/code&gt; (10.0.17763+). 1.0.7 adds the Mac Catalyst and Windows target frameworks for the shared implementation.&lt;/p&gt;

&lt;p&gt;It does one job: move a file in resumable chunks and remember how far the server got.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://www.nuget.org/packages/Plugin.Maui.SmartUpload" rel="noopener noreferrer"&gt;&lt;code&gt;Plugin.Maui.SmartUpload&lt;/code&gt;&lt;/a&gt; 1.0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UseSmartUpload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ISmartUploadClient&lt;/code&gt;, or &lt;code&gt;SmartUpload.Current&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocols&lt;/td&gt;
&lt;td&gt;Content-Range (default), tus 1.0, or &lt;code&gt;IUploadProtocol&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.SmartUpload" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.SmartUpload&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-smart-upload/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-smart-upload/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The acknowledged offset survives process death. The operating system does not keep sending bytes after the process is gone. A transfer that must continue with the UI closed still needs a host foreground service on Android, or a host &lt;code&gt;NSURLSession&lt;/code&gt; background configuration on iOS. SmartUpload is what those hosts resume from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and register
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.SmartUpload &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseMauiApp&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;App&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSmartUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableLogging&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultChunkSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxConcurrentUploads&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResumeInterruptedOnStart&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireHttps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultRetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;RetryPolicy&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;MaxRetries&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;InitialDelay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;MaxDelay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&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;Resolve &lt;code&gt;ISmartUploadClient&lt;/code&gt; from dependency injection, or call &lt;code&gt;SmartUpload.Current&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DefaultChunkSize&lt;/code&gt; falls back to 1 MB when you leave it alone. The default is clamped between 64 KB and 32 MB. An explicit &lt;code&gt;UploadRequest.ChunkSize&lt;/code&gt; must be positive and is capped at 32 MB. &lt;code&gt;MaxConcurrentUploads&lt;/code&gt; defaults to 2; extra sessions stay &lt;code&gt;Queued&lt;/code&gt; until a slot opens. &lt;code&gt;HttpTimeout&lt;/code&gt; is 100 seconds per chunk.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ResumeInterruptedOnStart&lt;/code&gt; defaults to false. Set it, or call &lt;code&gt;ResumeInterruptedAsync()&lt;/code&gt; yourself after launch. That call resumes sessions that were uploading when the process died, and any queued work that had &lt;code&gt;AutoStart&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enqueue a file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SmartUpload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EnqueueAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;UploadRequest&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;FilePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;photoPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://uploads.example.com/files/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UploadProtocolKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Headers&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="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"Bearer &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Metadata&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="s"&gt;"album"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"field"&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;FilePath&lt;/code&gt; is an absolute path. The file has to stay at that path, non-empty, until the session completes. &lt;code&gt;Endpoint&lt;/code&gt; is required. For tus it is the creation URL; the client follows &lt;code&gt;Location&lt;/code&gt;. For Content-Range it is the URL that receives each slice. &lt;code&gt;Method&lt;/code&gt; defaults to &lt;code&gt;PUT&lt;/code&gt; and applies only to Content-Range.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Protocol&lt;/code&gt; defaults to &lt;code&gt;UploadProtocolKind.ContentRange&lt;/code&gt;. Omit it when the server speaks &lt;code&gt;Content-Range&lt;/code&gt;. Set &lt;code&gt;Tus&lt;/code&gt; for a tus 1.0 endpoint. &lt;code&gt;FileName&lt;/code&gt; defaults to the file name. &lt;code&gt;ContentType&lt;/code&gt; is inferred from the extension when omitted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AutoStart&lt;/code&gt; defaults to true, so the session starts as soon as a concurrency slot is free. Set it false to persist the session and call &lt;code&gt;StartAsync&lt;/code&gt; later.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SessionId&lt;/code&gt; is optional. When you supply one, it may contain only letters, digits, &lt;code&gt;-&lt;/code&gt;, and &lt;code&gt;_&lt;/code&gt;, and it cannot exceed 128 characters. Otherwise the client generates a GUID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pause, resume, and the session states
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PauseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ResumeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RetryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CancelAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RemoveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetSessionsAsync&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;PauseAsync&lt;/code&gt; stops after the current chunk is cancelled and keeps the offset. &lt;code&gt;ResumeAsync&lt;/code&gt; continues a paused or interrupted session from the last acknowledged byte. &lt;code&gt;RetryAsync&lt;/code&gt; starts a failed or cancelled session again from that same offset. &lt;code&gt;CancelAsync&lt;/code&gt; leaves the record until &lt;code&gt;RemoveAsync&lt;/code&gt;, which deletes it. &lt;code&gt;GetSessionsAsync&lt;/code&gt; returns every persisted session, newest first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Queued&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Waiting for a concurrency slot, or for &lt;code&gt;StartAsync&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Uploading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A chunk is in flight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Paused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Caller stopped it; the offset is on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Completed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The server accepted every byte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A non-retryable error, or retries ran out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Cancelled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Caller cancelled; the record remains until &lt;code&gt;RemoveAsync&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Progress is an immutable &lt;code&gt;UploadSession&lt;/code&gt; plus &lt;code&gt;UploadProgress.Fraction&lt;/code&gt; (&lt;code&gt;BytesUploaded / TotalBytes&lt;/code&gt;, clamped to 0..1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProgressChanged&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Progress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fraction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;P0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionCompleted&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"Done &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SessionFailed&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SessionStateChanged&lt;/code&gt; fires as the session moves through the table above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two built-in protocols
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;What the client sends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ContentRange&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PUT&lt;/code&gt; or &lt;code&gt;POST&lt;/code&gt; each slice with &lt;code&gt;Content-Range: bytes start-end/total&lt;/code&gt;, &lt;code&gt;X-Upload-Id&lt;/code&gt;, &lt;code&gt;X-Chunk-Index&lt;/code&gt;, and &lt;code&gt;X-Chunk-Count&lt;/code&gt;. An optional &lt;code&gt;HEAD&lt;/code&gt; can return &lt;code&gt;Range&lt;/code&gt; or &lt;code&gt;X-Last-Byte&lt;/code&gt; so the client can catch up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tus 1.0: &lt;code&gt;POST&lt;/code&gt; to create, &lt;code&gt;HEAD&lt;/code&gt; for &lt;code&gt;Upload-Offset&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt; with &lt;code&gt;application/offset+octet-stream&lt;/code&gt;. &lt;code&gt;Metadata&lt;/code&gt; is sent as &lt;code&gt;Upload-Metadata&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Custom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UploadRequest.CustomProtocol&lt;/code&gt;, or &lt;code&gt;SmartUploadOptions.CustomProtocol&lt;/code&gt;, implementing &lt;code&gt;IUploadProtocol&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Content-Range slice looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT /upload HTTP/1.1
Content-Range: bytes 0-1048575/10485760
Content-Length: 1048576
X-Upload-Id: 2f1c9a0e...
X-Chunk-Index: 0
X-Chunk-Count: 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IUploadProtocol&lt;/code&gt; is the extension point: &lt;code&gt;InitializeAsync&lt;/code&gt;, &lt;code&gt;QueryRemoteProgressAsync&lt;/code&gt;, &lt;code&gt;UploadChunkAsync&lt;/code&gt;, &lt;code&gt;CompleteAsync&lt;/code&gt;, and &lt;code&gt;AbortAsync&lt;/code&gt;. &lt;code&gt;InitializeAsync&lt;/code&gt; has to be idempotent. The chunk stream length equals that slice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DeleteRemoteOnCancel&lt;/code&gt; defaults to false. When it is true, a tus cancel sends &lt;code&gt;DELETE&lt;/code&gt; if the server advertised the termination extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is written to disk
&lt;/h2&gt;

&lt;p&gt;Sessions are JSON files under &lt;code&gt;FileSystem.AppDataDirectory/Plugin.Maui.SmartUpload/&lt;/code&gt;. Set &lt;code&gt;StorageDirectory&lt;/code&gt; to move that folder, or set &lt;code&gt;Store&lt;/code&gt; to replace the file store with your own &lt;code&gt;IUploadStore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each record keeps the file path, size, last-write timestamp, endpoint, headers, protocol state (including the tus &lt;code&gt;Location&lt;/code&gt;), and the acknowledged byte offset. On resume, a missing file fails with &lt;code&gt;UploadError.FileNotFound&lt;/code&gt;. A different length or last-write time fails with &lt;code&gt;UploadError.FileChanged&lt;/code&gt;. The client will not continue a session against a file that is no longer the one it started.&lt;/p&gt;

&lt;p&gt;Headers are part of that JSON, including &lt;code&gt;Authorization&lt;/code&gt;. A bearer token stored there is what the next request sends. Refresh it before resume when the token can expire while the app is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS is the default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RequireHttps&lt;/code&gt; defaults to true. An &lt;code&gt;http://&lt;/code&gt; endpoint throws &lt;code&gt;SmartUploadException&lt;/code&gt; with &lt;code&gt;UploadError.InvalidRequest&lt;/code&gt; and the message &lt;code&gt;Endpoint must be an https URL. Set RequireHttps to false to allow http.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That default shipped with the hardened 1.0.6 release and is still the 1.0.7 behavior. Set &lt;code&gt;RequireHttps = false&lt;/code&gt; only for a local development server. On iOS, cleartext also needs an App Transport Security exception. The scheme has to be &lt;code&gt;http&lt;/code&gt; or &lt;code&gt;https&lt;/code&gt; either way.&lt;/p&gt;

&lt;p&gt;The package declares &lt;code&gt;INTERNET&lt;/code&gt; and &lt;code&gt;ACCESS_NETWORK_STATE&lt;/code&gt;. Keep both if the host merges Android manifests by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RetryPolicy.Default&lt;/code&gt; retries five times. The first wait is 1 second, the multiplier is 2, and the wait is capped at 30 seconds. Jitter lands between 80% and 120% of that delay. Status codes 408, 429, 500, 502, 503, and 504 are retryable, along with &lt;code&gt;HttpRequestException&lt;/code&gt;, &lt;code&gt;IOException&lt;/code&gt;, &lt;code&gt;TimeoutException&lt;/code&gt;, and &lt;code&gt;TaskCanceledException&lt;/code&gt;. &lt;code&gt;OperationCanceledException&lt;/code&gt; stops the loop. A &lt;code&gt;SmartUploadException&lt;/code&gt; retries only when it is marked retryable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RetryPolicy.None&lt;/code&gt; sets &lt;code&gt;MaxRetries&lt;/code&gt; to 0. A per-request &lt;code&gt;UploadRequest.Retry&lt;/code&gt; overrides &lt;code&gt;DefaultRetry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When retries are exhausted, the session lands in &lt;code&gt;Failed&lt;/code&gt; with the offset still on disk. &lt;code&gt;RetryAsync&lt;/code&gt; starts again from that offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  A client that does not touch &lt;code&gt;Current&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Tests can build an isolated client. &lt;code&gt;SmartUpload.Create&lt;/code&gt; does not replace &lt;code&gt;SmartUpload.Current&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SmartUpload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SmartUploadOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Store&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MyStore&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DefaultChunkSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DefaultRetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;None&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A caller-supplied &lt;code&gt;HttpClient&lt;/code&gt; is not disposed by the plugin. &lt;code&gt;samples/SmartUpload.Sample&lt;/code&gt; creates a 1 MB file, or picks one, and exercises tus or Content-Range with pause, resume, retry, and cancel.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;InvalidRequest&lt;/code&gt;: endpoint must be https&lt;/td&gt;
&lt;td&gt;The URL is &lt;code&gt;http://&lt;/code&gt; and &lt;code&gt;RequireHttps&lt;/code&gt; is still true. Use https, or set &lt;code&gt;RequireHttps = false&lt;/code&gt; for local development only.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FileNotFound&lt;/code&gt; at enqueue&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FilePath&lt;/code&gt; is missing, blank, or the file length is 0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FileChanged&lt;/code&gt; on resume&lt;/td&gt;
&lt;td&gt;The file was rewritten or replaced. Length and last-write time are part of the session. Start a new session for the new bytes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FileNotFound&lt;/code&gt; on resume&lt;/td&gt;
&lt;td&gt;The source file was deleted. The session cannot continue.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress stuck in &lt;code&gt;Queued&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MaxConcurrentUploads&lt;/code&gt; slots are full, or &lt;code&gt;AutoStart&lt;/code&gt; is false and nothing called &lt;code&gt;StartAsync&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload starts over after a kill&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ResumeInterruptedOnStart&lt;/code&gt; is false and nothing called &lt;code&gt;ResumeInterruptedAsync&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session stays &lt;code&gt;Failed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Retries are exhausted. Call &lt;code&gt;RetryAsync&lt;/code&gt;. The offset is still stored.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom protocol rejected&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Protocol&lt;/code&gt; is &lt;code&gt;Custom&lt;/code&gt; and both &lt;code&gt;UploadRequest.CustomProtocol&lt;/code&gt; and &lt;code&gt;SmartUploadOptions.CustomProtocol&lt;/code&gt; are null.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transfer dies when the user leaves the app&lt;/td&gt;
&lt;td&gt;The offset is on disk. Continuing while the process is suspended is a host foreground service (Android) or &lt;code&gt;NSURLSession&lt;/code&gt; background session (iOS).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where SmartUpload sits next to the other tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chunked file upload that resumes after process death&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;SmartUpload&lt;/strong&gt; — &lt;code&gt;Plugin.Maui.SmartUpload&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON around the file: create the asset, confirm completion&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-httpforge/" rel="noopener noreferrer"&gt;Plugin.Maui.HttpForge&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry, circuit breaker, and an offline queue for ordinary HTTP calls&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-api-resilience/" rel="noopener noreferrer"&gt;Plugin.Maui.ApiResilience&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable jobs with backoff and a dead-letter path&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-job-queue/" rel="noopener noreferrer"&gt;Plugin.Maui.JobQueue&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resize, strip EXIF, watermark, or encrypt before the file leaves the device&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-media-pipeline/" rel="noopener noreferrer"&gt;Plugin.Maui.MediaPipeline&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypted files at rest on the device&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-file-vault/" rel="noopener noreferrer"&gt;Plugin.Maui.FileVault&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HttpForge &lt;code&gt;[Multipart]&lt;/code&gt; is one POST. Use it for the JSON that books the upload and confirms it. Use SmartUpload for the bytes when a kill has to be recoverable. A small JSON call stays on &lt;code&gt;HttpClient&lt;/code&gt; or ApiResilience.&lt;/p&gt;

&lt;p&gt;MediaPipeline can finish a photo and leave a file path. That path is what &lt;code&gt;EnqueueAsync&lt;/code&gt; takes. FileVault keeps a file encrypted on the device; SmartUpload sends a file to an endpoint.&lt;/p&gt;

&lt;p&gt;A general-purpose tus client is the usual choice when the app is not a MAUI host, or when the organization already standardized on one. SmartUpload is the client when the same session API has to run on Android, iOS, Mac Catalyst, and Windows, and the offset has to be on disk after the process dies.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Plugin.Maui.SmartUpload &lt;span class="nt"&gt;--version&lt;/span&gt; 1.0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register &lt;code&gt;UseSmartUpload&lt;/code&gt; with &lt;code&gt;RequireHttps = true&lt;/code&gt; and &lt;code&gt;ResumeInterruptedOnStart = true&lt;/code&gt;. Enqueue one file with &lt;code&gt;UploadProtocolKind.Tus&lt;/code&gt; or the default Content-Range protocol, then read &lt;code&gt;ProgressChanged&lt;/code&gt; until &lt;code&gt;SessionCompleted&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NuGet: &lt;a href="https://www.nuget.org/packages/Plugin.Maui.SmartUpload" rel="noopener noreferrer"&gt;Plugin.Maui.SmartUpload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.SmartUpload" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/Plugin.Maui.SmartUpload&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sample: &lt;a href="https://github.com/nuvyntralabs/Plugin.Maui.SmartUpload/tree/main/samples/SmartUpload.Sample" rel="noopener noreferrer"&gt;SmartUpload.Sample&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Package page: &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-smart-upload/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/packages/plugin-maui-smart-upload/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>upload</category>
      <category>opensource</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>Offline GPS Tracking and Haversine Distance</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 17:26:59 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/offline-gps-tracking-and-haversine-distance-1ba9</link>
      <guid>https://dev.to/nuvyantralabs_ccee/offline-gps-tracking-and-haversine-distance-1ba9</guid>
      <description>&lt;p&gt;A field app that bills by the kilometre has to know how far the phone actually moved. A directions API answers a different question: the shortest road between two points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPSSensorTrackingService&lt;/strong&gt; is a Kotlin Android proof that stays with the first question. The fused location provider delivers GPS fixes. A filter drops the ones that are noise. The distance between accepted fixes is added with the Haversine formula and written to a local database. The map draws that trace. Distance is computed on the device from those fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the proof is
&lt;/h2&gt;

&lt;p&gt;The app is a field-survey tracker. A daily shift window starts and stops location capture. While the window is open, a foreground service records the route and a running total for that calendar day.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;Field Survey POC · &lt;code&gt;com.fieldsurvey.poc&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Android 8+ (API 26) · Kotlin · Jetpack Compose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;Fused Location Provider, high-accuracy GPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distance&lt;/td&gt;
&lt;td&gt;Haversine on accepted fixes, Earth radius 6,371 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Room, one day log and its points per &lt;code&gt;YYYY-MM-DD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map&lt;/td&gt;
&lt;td&gt;Google Maps polyline of the saved trace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/GPSSensorTrackingService" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/GPSSensorTrackingService&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/research/gps-sensor-tracking-service/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/research/gps-sensor-tracking-service/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why the sample interval decides the distance
&lt;/h2&gt;

&lt;p&gt;Two GPS points give the straight line between them. The road between them can be longer. A fix every ten minutes hides the bends:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Motion&lt;/th&gt;
&lt;th&gt;Distance covered in 10 minutes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Walking, 5 km/h&lt;/td&gt;
&lt;td&gt;833 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bicycle, 15 km/h&lt;/td&gt;
&lt;td&gt;2.5 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Car, 40 km/h&lt;/td&gt;
&lt;td&gt;6.7 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Highway, 80 km/h&lt;/td&gt;
&lt;td&gt;13.3 km&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A left turn, a U-turn, or a loop inside that gap becomes one chord. The total comes out short. Sampling every few seconds while the device is moving keeps those corners in the trace. Sampling slows down when the device is still, so a phone on a desk does not burn a GPS fix every few seconds for nothing.&lt;/p&gt;

&lt;p&gt;A directions call on each leg has the opposite failure. It returns a plausible road, including roads the surveyor never drove, and it needs a network at sample time. This proof keeps the polyline as the filtered GPS trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Haversine, added as you go
&lt;/h2&gt;

&lt;p&gt;Haversine is the great-circle distance on a sphere. For two latitudes and longitudes it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;Haversine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;EARTH_RADIUS_M&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;6_371_000.0&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;distanceMeters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dLat&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRadians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dLon&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRadians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon2&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dLat&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt;
                &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRadians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRadians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt;
                &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dLon&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;EARTH_RADIUS_M&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;asin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="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 accepted fix adds its metres to that day’s total in SQL, so a crash keeps the distance already counted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;day_logs&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;totalDistanceMeters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;totalDistanceMeters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;deltaMeters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;lastFixUtcMillis&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;timestampUtcMillis&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;dateKey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The running total is kept incrementally. It is stored on the day row as each fix is accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three filters, or a parked phone invents kilometres
&lt;/h2&gt;

&lt;p&gt;Raw GPS wanders. A phone left on a table drifts by metres. A bad fix can jump across town. Sum those segments and the day total grows while nobody moved.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GpsFilter&lt;/code&gt; accepts a fix only after three checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy.&lt;/strong&gt; Medium mode drops anything worse than 30 m. High mode uses 20 m. Low mode uses 55 m.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doppler speed.&lt;/strong&gt; Position drifts while the receiver is still. Reported speed does not. A trustworthy speed under 0.5 m/s is treated as stationary and contributes 0 m. That threshold sits above the stationary noise floor and below a slow walk, so walking still counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anchor.&lt;/strong&gt; When speed is missing, the filter holds the last still point. Fixes inside &lt;code&gt;max(20 m, accuracy × 1.5)&lt;/code&gt; are rejected. A jump past that radius has to repeat on a second fix before it counts, unless speed already says the device is moving. The counted distance starts from the anchor, so the first real metres are kept. A single spike cannot walk the anchor down the street.&lt;/p&gt;

&lt;p&gt;Implied speed above 60 m/s (about 216 km/h) is dropped as a teleport.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that straightened every corner
&lt;/h2&gt;

&lt;p&gt;The first road test under-counted badly: about 1.87 km on a ride of about 4.8 km, with sharp turns and a U-turn.&lt;/p&gt;

&lt;p&gt;The location callback kept &lt;code&gt;LocationResult.lastLocation&lt;/code&gt;. The request also allowed the OS to batch fixes (&lt;code&gt;setMaxUpdateDelayMillis&lt;/code&gt; at twice the interval). A batch arrived as one delivery, the callback kept the last point, and every turn inside that batch collapsed into a straight line.&lt;/p&gt;

&lt;p&gt;The fix is both halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Walk &lt;code&gt;result.locations&lt;/code&gt; in time order and run the filter on each fix.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;setMaxUpdateDelayMillis(0)&lt;/code&gt; so fixes are delivered as they arrive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Route vertices are saved on the first fix, on a heading change of 20° or more (the previous point is saved too, so the polyline bends at the corner), on stop and start, and at least every 60 seconds or 120 metres on a straight. After motion, fast sampling continues for 90 seconds, so a stop at a signal and the turn that follows are still in the trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accuracy modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Moving sample&lt;/th&gt;
&lt;th&gt;Stationary sample&lt;/th&gt;
&lt;th&gt;Max accuracy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;4 s&lt;/td&gt;
&lt;td&gt;20 s&lt;/td&gt;
&lt;td&gt;20 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (default)&lt;/td&gt;
&lt;td&gt;6 s&lt;/td&gt;
&lt;td&gt;30 s&lt;/td&gt;
&lt;td&gt;30 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;15 s&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;55 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Medium is the default for a vehicle survey. High spends more battery to hold tighter corners. On a 4,500 mAh phone, a 12-hour Medium shift plus the always-on process lands around 16–20% of the battery for the day. That figure is an engineering estimate, not a lab measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The road test
&lt;/h2&gt;

&lt;p&gt;After the batching fix, the app was compared with a vehicle trip meter on a route that included left turns, right turns, and a U-turn.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vehicle trip meter&lt;/td&gt;
&lt;td&gt;2.00 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;1.94 km&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Points saved&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agreement&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gap&lt;/td&gt;
&lt;td&gt;60 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sixty metres on two kilometres is the residue of GPS error, sample spacing, and road curvature. The app estimates the path from fixes. It does not read the odometer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays on the device
&lt;/h2&gt;

&lt;p&gt;Shift start and stop are daily alarms. A second foreground service stays up so those alarms still fire after Doze, reboot, and an app update. OEM battery screens (Xiaomi, Oppo, vivo, Samsung, and others) are linked from inside the app, because a foreground service alone does not survive every vendor killer.&lt;/p&gt;

&lt;p&gt;The published proof keeps data on the device, draws the raw filtered trace, and leaves polyline simplification for later. Those are product steps. The distance figure is already the filtered Haversine sum.&lt;/p&gt;

&lt;p&gt;The same problem on .NET MAUI — an on-demand fix, a tracking session, and reverse geocoding — is &lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-geolocator/" rel="noopener noreferrer"&gt;Plugin.Maui.GeoLocator&lt;/a&gt; (&lt;a href="https://www.nuget.org/packages/Plugin.Maui.GeoLocator" rel="noopener noreferrer"&gt;NuGet&lt;/a&gt;, &lt;a href="https://github.com/NiladriPadhy/Plugin.Maui.GeoLocator" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;). This Android proof is the measurement study behind that kind of API: what to sample, what to reject, and what accuracy to expect against a trip meter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/GPSSensorTrackingService" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/GPSSensorTrackingService&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Research page: &lt;a href="https://nuvyntralabs.github.io/research/gps-sensor-tracking-service/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/research/gps-sensor-tracking-service/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>gps</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>.NET 11 RC1 Is Here — What Developers Should Know</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:29:15 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/net-11-rc1-is-here-what-developers-should-know-5dfg</link>
      <guid>https://dev.to/nuvyantralabs_ccee/net-11-rc1-is-here-what-developers-should-know-5dfg</guid>
      <description>&lt;p&gt;.NET 11 has reached &lt;strong&gt;Release Candidate 1 (RC1)&lt;/strong&gt;, bringing the next generation of the .NET platform one step closer to its November 2026 release. Microsoft released .NET 11 RC1 on &lt;strong&gt;September 8, 2026&lt;/strong&gt;, with Go-Live support.&lt;/p&gt;

&lt;p&gt;This release includes updates across the &lt;strong&gt;runtime, SDK, libraries, MSBuild, NuGet, C#, F#, ASP.NET Core, .NET MAUI, and Windows Forms&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes .NET 11 Interesting?
&lt;/h2&gt;

&lt;p&gt;.NET 11 is not simply another framework version. Several areas are receiving deeper platform-level improvements.&lt;/p&gt;

&lt;h3&gt;
  
  
  🚀 Runtime &amp;amp; Performance
&lt;/h3&gt;

&lt;p&gt;.NET 11 continues work around runtime performance, NativeAOT, asynchronous execution, and CoreCLR.&lt;/p&gt;

&lt;p&gt;One particularly interesting direction is the continued expansion of &lt;strong&gt;CoreCLR&lt;/strong&gt;, including its role in cross-platform scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧩 C# 15
&lt;/h3&gt;

&lt;p&gt;.NET 11 comes with &lt;strong&gt;C# 15&lt;/strong&gt;, which introduces new language capabilities including work around &lt;strong&gt;union types&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For developers building large applications, language improvements like these could make certain domain models and API contracts more expressive.&lt;/p&gt;

&lt;h3&gt;
  
  
  🌐 ASP.NET Core &amp;amp; Blazor
&lt;/h3&gt;

&lt;p&gt;ASP.NET Core and Blazor also receive significant updates in the .NET 11 cycle.&lt;/p&gt;

&lt;p&gt;The ecosystem is moving toward richer server-side rendering, improved validation, smaller Blazor WebAssembly applications, and better integration with modern .NET development workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  📱 .NET MAUI
&lt;/h3&gt;

&lt;p&gt;.NET MAUI developers should pay particular attention to .NET 11.&lt;/p&gt;

&lt;p&gt;One of the notable areas Microsoft has highlighted is the move toward &lt;strong&gt;CoreCLR for .NET MAUI applications on Android, iOS, and Mac Catalyst&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This could be an important evolution for developers building production cross-platform applications with MAUI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should You Move to .NET 11 Now?
&lt;/h2&gt;

&lt;p&gt;.NET 11 RC1 has &lt;strong&gt;Go-Live support&lt;/strong&gt;, meaning Microsoft supports production use of the RC release. However, teams should still carefully test their applications, dependencies, workloads, and CI/CD pipelines before moving production systems.&lt;/p&gt;

&lt;p&gt;For teams currently on .NET 10, there is also no immediate need to rush. &lt;strong&gt;.NET 10 is an LTS release and is supported through November 14, 2028&lt;/strong&gt;, while .NET 11 is an STS release.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;.NET 11 looks less like a simple yearly upgrade and more like another step in the evolution of the entire .NET ecosystem — from runtime and language improvements to cloud development, web applications, AI tooling, and cross-platform applications.&lt;/p&gt;

&lt;p&gt;The official launch is expected in &lt;strong&gt;November 2026&lt;/strong&gt;, alongside .NET Conf 2026.&lt;/p&gt;

&lt;p&gt;For .NET developers, now is a good time to start testing .NET 11 in non-production projects and checking how your libraries and applications behave with the new runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;.NET 11 is almost here. The next chapter of .NET development is about to begin.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>dotnet11</category>
      <category>releasenotes</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>VoIPCall: A Native Android Dialer for PSTN Calls on Vobiz</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:03:10 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/voipcall-a-native-android-dialer-for-pstn-calls-on-vobiz-2f4c</link>
      <guid>https://dev.to/nuvyantralabs_ccee/voipcall-a-native-android-dialer-for-pstn-calls-on-vobiz-2f4c</guid>
      <description>&lt;p&gt;A field app that places a phone-network call should look like the phone people already use, and it should still ring after the process has been swiped away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vobiz VoIP&lt;/strong&gt; is that dialer. It is a native Kotlin app for Android 12 and later. Calls go through &lt;a href="https://vobiz.ai" rel="noopener noreferrer"&gt;Vobiz&lt;/a&gt;. Signaling is SIP over a secure WebSocket. Audio is WebRTC. A TypeScript backend answers Vobiz webhooks and wakes the handset with Firebase Cloud Messaging.&lt;/p&gt;

&lt;p&gt;This post is the walkthrough: what the dialer does, how an outbound call leaves the keypad, how an inbound call arrives when the app is not running, and where this proof sits next to Plugin.Maui.VoipCore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vobiz VoIP is
&lt;/h2&gt;

&lt;p&gt;Vobiz VoIP is a public Android proof. The application id is &lt;code&gt;com.Vobiz.vobizvoip&lt;/code&gt;. The UI is Jetpack Compose. One Vobiz SIP endpoint is registered per device. One Vobiz number, in E.164, is that device’s caller ID.&lt;/p&gt;

&lt;p&gt;The phone holds the SIP username and password. The Vobiz Auth ID and Auth Token stay in the backend. They are not compiled into the APK.&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;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App&lt;/td&gt;
&lt;td&gt;Vobiz VoIP · &lt;code&gt;com.Vobiz.vobizvoip&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Android 12+ (API 31) · Kotlin · Jetpack Compose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signaling&lt;/td&gt;
&lt;td&gt;SIP over &lt;code&gt;wss://registrar.vobiz.ai:5063/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Media&lt;/td&gt;
&lt;td&gt;WebRTC audio (DTLS-SRTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wake-up&lt;/td&gt;
&lt;td&gt;Firebase Cloud Messaging, including a terminated process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/VOBIZ-VOIP-Call&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/research/vobiz-voip-call/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/research/vobiz-voip-call/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call/blob/main/DOCS/SETUP_GUIDE.md" rel="noopener noreferrer"&gt;SETUP_GUIDE.md&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repository contains the Android app and a local Node.js backend. The backend exposes the Answer URL, call state, and the FCM send. A tunnel publishes that process at an HTTPS origin Vobiz can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the dialer is native
&lt;/h2&gt;

&lt;p&gt;Vobiz ships a Flutter Android sample and a Java REST SDK for the Voice API. The REST SDK does not open a WebRTC audio session, and the sample is not a Kotlin dialer. This proof is the native client: OkHttp for the SIP WebSocket, the Android WebRTC library for media, and a phone-shaped UI on top.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Android dialer
    |  SIP over WSS          WebRTC audio
    v
Vobiz registrar  +  media edge
    |
    v
Public phone network
    ^
    |  Answer and Hangup webhooks
Backend  --Firebase push--&amp;gt;  the same phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call phases on the device run &lt;code&gt;IDLE&lt;/code&gt; → &lt;code&gt;OUTGOING&lt;/code&gt; → &lt;code&gt;RINGING&lt;/code&gt; → &lt;code&gt;INCOMING&lt;/code&gt; → &lt;code&gt;CONNECTING&lt;/code&gt; → &lt;code&gt;ACTIVE&lt;/code&gt; → &lt;code&gt;ENDING&lt;/code&gt; → &lt;code&gt;FAILED&lt;/code&gt;. A finished call is stored as &lt;code&gt;COMPLETED&lt;/code&gt;, &lt;code&gt;MISSED&lt;/code&gt;, &lt;code&gt;DECLINED&lt;/code&gt;, &lt;code&gt;CANCELED&lt;/code&gt;, or &lt;code&gt;FAILED&lt;/code&gt;. That history is the app’s own log. It is not written to Android’s system &lt;code&gt;CallLog&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The phone UI
&lt;/h2&gt;

&lt;p&gt;The shell matches a handset dialer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Home&lt;/strong&gt; lists recents, grouped into Today, Yesterday, and Older. A matching device contact supplies the name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search&lt;/strong&gt; on Home filters those contacts. A match can be called from the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keypad&lt;/strong&gt; is the second bottom tab.&lt;/li&gt;
&lt;li&gt;The side menu holds &lt;strong&gt;Contacts&lt;/strong&gt;, &lt;strong&gt;Settings&lt;/strong&gt;, and &lt;strong&gt;Clear call history&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A leading &lt;code&gt;0&lt;/code&gt; is replaced with the country code. A bare national number gets the country code prepended. A number that already starts with &lt;code&gt;+&lt;/code&gt; is left unchanged. The country code comes from the SIM. With no SIM, Settings &lt;strong&gt;Default country&lt;/strong&gt; is used. The last fallback is India (&lt;code&gt;+91&lt;/code&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What was typed&lt;/th&gt;
&lt;th&gt;What is dialed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+9198…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0091…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;00&lt;/code&gt; becomes &lt;code&gt;+&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;098…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Leading &lt;code&gt;0&lt;/code&gt; becomes &lt;code&gt;+&lt;/code&gt; and the country code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;987…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Country code prepended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;*&lt;/code&gt; or &lt;code&gt;#&lt;/code&gt; feature codes&lt;/td&gt;
&lt;td&gt;Left as entered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Place a call
&lt;/h2&gt;

&lt;p&gt;Outbound starts on the keypad, from a contact, from a recent row, or from another app. The dialer normalizes the number, then asks the backend to start the call. Vobiz dials the PSTN number. When the other phone answers, the WebRTC session carries both directions of audio.&lt;/p&gt;

&lt;p&gt;On the call screen the user can mute, route audio to the speaker, send DTMF, and hang up. While a native cellular call is off-hook, the VoIP microphone mutes itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer when the app is closed
&lt;/h2&gt;

&lt;p&gt;Every inbound PSTN call is parked in a short-lived Vobiz conference, then delivered by push. The same path runs in the foreground, in the background, and after the app has been removed from Recents.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Someone dials this device’s Vobiz number.&lt;/li&gt;
&lt;li&gt;Vobiz POSTs the Answer webhook. The backend checks that a device registered that DID.&lt;/li&gt;
&lt;li&gt;The caller is parked in a conference. A high-priority FCM message wakes the phone.&lt;/li&gt;
&lt;li&gt;The incoming-call screen is shown. On a locked phone that needs notifications, full-screen incoming calls, and unrestricted battery. The app asks for those.&lt;/li&gt;
&lt;li&gt;Answering registers SIP if needed and joins the same conference. Two-way audio starts.&lt;/li&gt;
&lt;li&gt;If nobody answers within 30 seconds, the pending call expires and the caller is released. The log row is &lt;code&gt;MISSED&lt;/code&gt;. Declining writes &lt;code&gt;DECLINED&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The caller hears silence, or the conference wait sound, for the few seconds between the push and the app joining. A DID with no registered device is rejected. The backend logs &lt;code&gt;No device registered this DID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not test by calling the configured caller ID from the same device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you enter once
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Save and connect&lt;/strong&gt; stays disabled until the required fields are valid. SIP credentials are encrypted with an Android Keystore AES-GCM key.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Settings field&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SIP username and password&lt;/td&gt;
&lt;td&gt;This device’s Vobiz SIP endpoint. One endpoint per phone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registrar WSS URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wss://registrar.vobiz.ai:5063/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIP domain&lt;/td&gt;
&lt;td&gt;&lt;code&gt;registrar.vobiz.ai&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public backend HTTPS URL&lt;/td&gt;
&lt;td&gt;The tunnel in front of the local backend.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POC device token&lt;/td&gt;
&lt;td&gt;The same &lt;code&gt;DEVICE_TOKEN&lt;/code&gt; as &lt;code&gt;backend/.env&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vobiz caller ID&lt;/td&gt;
&lt;td&gt;This device’s DID, E.164, starting with &lt;code&gt;+&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default country&lt;/td&gt;
&lt;td&gt;Used only when the device has no SIM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Record calls&lt;/td&gt;
&lt;td&gt;On by default. Playback is available from Recents.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A healthy status row reads &lt;strong&gt;Registered and ready for calls&lt;/strong&gt; for SIP, and &lt;strong&gt;Online&lt;/strong&gt; with &lt;strong&gt;Firebase ready&lt;/strong&gt; for the backend. The Vobiz Auth ID, Auth Token, and webhook token are backend secrets. The Answer and Hangup URLs are set on the Vobiz Voice Application, not in the Android form.&lt;/p&gt;

&lt;p&gt;Grant microphone, notifications, contacts, phone state, and nearby-devices (Bluetooth) when prompted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let another app place the call
&lt;/h2&gt;

&lt;p&gt;The app does not register as the OS default dialer. Other apps open it with an intent. Numbers are normalized before dialing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Open the dialer, optionally pre-filled&lt;/span&gt;
&lt;span class="nf"&gt;startActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTION_DIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tel:+919876543210"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="c1"&gt;// Place the call&lt;/span&gt;
&lt;span class="nf"&gt;startActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.Vobiz.vobizvoip.action.CALL"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setPackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.Vobiz.vobizvoip"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;putExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"09876543210"&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 call history, including a recording path when recording is on, is a content provider. The caller declares &lt;code&gt;com.Vobiz.vobizvoip.permission.READ_CALL_LOG&lt;/code&gt; and queries &lt;code&gt;content://com.Vobiz.vobizvoip.provider.calllog/calls&lt;/code&gt;. Columns include &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;display_name&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;1&lt;/code&gt; incoming, &lt;code&gt;2&lt;/code&gt; outgoing, &lt;code&gt;3&lt;/code&gt; missed), &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;duration&lt;/code&gt;, and &lt;code&gt;recording_path&lt;/code&gt;. The provider streams the recording. The backend auth token stays inside Vobiz VoIP.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something looks wrong
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SIP stays disconnected&lt;/td&gt;
&lt;td&gt;Confirm the endpoint username, password, &lt;code&gt;wss://&lt;/code&gt; registrar, and domain. Use the SIP refresh action in Settings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend stays offline&lt;/td&gt;
&lt;td&gt;The tunnel URL must be the current &lt;code&gt;PUBLIC_URL&lt;/code&gt;, and the backend process must be running. &lt;code&gt;GET /health&lt;/code&gt; on that origin should succeed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbound never rings, backend is online&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET /health&lt;/code&gt; reports Firebase only when the service-account key loaded. The app needs &lt;code&gt;google-services.json&lt;/code&gt;, notifications, full-screen incoming calls, and unrestricted battery.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caller hears an error and drops&lt;/td&gt;
&lt;td&gt;No device has registered that DID. Open the app on the phone that owns the number so it can register.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The other device rings&lt;/td&gt;
&lt;td&gt;You called device 1’s caller ID. Each handset has its own DID and its own SIP username.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call ends with no answer&lt;/td&gt;
&lt;td&gt;The pending inbound call expires after 30 seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-way audio fails on some networks&lt;/td&gt;
&lt;td&gt;STUN alone does not cross every mobile or symmetric NAT. Production media needs TURN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A second open of the same SIP user fights the first&lt;/td&gt;
&lt;td&gt;One SIP endpoint per device. Do not share an endpoint across phones.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this dialer sits next to the other tools
&lt;/h2&gt;

&lt;p&gt;This repository is the Android proof for one vendor path. It is not a MAUI package, and it is not pulled in by &lt;code&gt;nuvyn init&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A native Android dialer for Vobiz PSTN, with recents, keypad, and push wake-up&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Vobiz VoIP&lt;/strong&gt; — &lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call" rel="noopener noreferrer"&gt;VOBIZ-VOIP-Call&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A MAUI session model: call state, hold, mute, pluggable signaling&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/packages/plugin-maui-voip-core/" rel="noopener noreferrer"&gt;Plugin.Maui.VoipCore&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The written map of this proof&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nuvyntralabs.github.io/research/vobiz-voip-call/" rel="noopener noreferrer"&gt;Research: VOBIZ VoIP Call&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use Vobiz VoIP when the product is an Android handset on Vobiz and the UI has to be the dialer. Use VoipCore when a .NET MAUI app needs a session model and the signaling stack stays replaceable.&lt;/p&gt;

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

&lt;p&gt;You need JDK 17, Android SDK 36, a physical Android 12+ device, Node.js 22+, a tunnel, one Vobiz number, one SIP endpoint for that device, and a Firebase project for &lt;code&gt;com.Vobiz.vobizvoip&lt;/code&gt;. A second phone on the public network is the other leg.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew testDebugUnitTest assembleDebug
adb &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; app/build/outputs/apk/debug/app-debug.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in order: status shows ready, an outbound call to the second phone has two-way audio, an inbound call works in the foreground, the same inbound works after the app is swiped away, and a second device rings only when its own caller ID is dialed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call" rel="noopener noreferrer"&gt;github.com/nuvyntralabs/VOBIZ-VOIP-Call&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Research page: &lt;a href="https://nuvyntralabs.github.io/research/vobiz-voip-call/" rel="noopener noreferrer"&gt;nuvyntralabs.github.io/research/vobiz-voip-call/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Setup: &lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call/blob/main/DOCS/SETUP_GUIDE.md" rel="noopener noreferrer"&gt;SETUP_GUIDE.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Call paths: &lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call/blob/main/DOCS/CALL_WORKFLOWS.md" rel="noopener noreferrer"&gt;CALL_WORKFLOWS.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Settings and intents: &lt;a href="https://github.com/nuvyntralabs/VOBIZ-VOIP-Call/blob/main/DOCS/CONFIGURATION_AND_INTEGRATION.md" rel="noopener noreferrer"&gt;CONFIGURATION_AND_INTEGRATION.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>voip</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>AI Tools Used in Modern Software Development</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:22:30 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/ai-tools-used-in-modern-software-development-339</link>
      <guid>https://dev.to/nuvyantralabs_ccee/ai-tools-used-in-modern-software-development-339</guid>
      <description>&lt;h1&gt;
  
  
  AI Tools Used in Modern Software Development
&lt;/h1&gt;

&lt;p&gt;AI is no longer just a tool for generating code. It is becoming part of the entire software development workflow — from understanding requirements to writing code, debugging, testing, documenting, and even reviewing pull requests.&lt;/p&gt;

&lt;p&gt;For developers, the question is no longer &lt;strong&gt;“Should I use AI?”&lt;/strong&gt; but rather &lt;strong&gt;“Where can AI actually improve my development workflow?”&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. AI Coding Assistants
&lt;/h2&gt;

&lt;p&gt;The most visible use of AI in development is coding assistance.&lt;/p&gt;

&lt;p&gt;Tools such as &lt;strong&gt;GitHub Copilot, Cursor, Claude Code, and Gemini Code Assist&lt;/strong&gt; can help developers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate code from natural-language requirements&lt;/li&gt;
&lt;li&gt;Explain existing code&lt;/li&gt;
&lt;li&gt;Refactor code&lt;/li&gt;
&lt;li&gt;Generate unit tests&lt;/li&gt;
&lt;li&gt;Find potential bugs&lt;/li&gt;
&lt;li&gt;Create documentation&lt;/li&gt;
&lt;li&gt;Suggest implementation approaches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important point is that AI should be treated as a &lt;strong&gt;coding partner&lt;/strong&gt;, not an autonomous developer. Generated code still needs to be reviewed, tested, and understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AI-Powered IDEs and Editors
&lt;/h2&gt;

&lt;p&gt;AI is increasingly integrated directly into development environments.&lt;/p&gt;

&lt;p&gt;Modern AI-enabled editors can understand multiple files and sometimes an entire repository. This allows developers to ask questions such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Where is authentication handled?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;or&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Find all places where this API is called and update the error handling.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is much more powerful than traditional autocomplete because the AI can work with the &lt;strong&gt;context of the project&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AI for Debugging
&lt;/h2&gt;

&lt;p&gt;Debugging is another area where AI can save significant time.&lt;/p&gt;

&lt;p&gt;Developers can provide an exception, stack trace, log output, or problematic code and ask AI to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explain the error&lt;/li&gt;
&lt;li&gt;Identify possible root causes&lt;/li&gt;
&lt;li&gt;Suggest fixes&lt;/li&gt;
&lt;li&gt;Analyze logs&lt;/li&gt;
&lt;li&gt;Generate debugging steps&lt;/li&gt;
&lt;li&gt;Explain platform-specific behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, AI explanations should be treated as hypotheses until verified against the actual application.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. AI for Testing
&lt;/h2&gt;

&lt;p&gt;AI can accelerate the testing process by generating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unit tests&lt;/li&gt;
&lt;li&gt;Integration tests&lt;/li&gt;
&lt;li&gt;Mock data&lt;/li&gt;
&lt;li&gt;Test cases&lt;/li&gt;
&lt;li&gt;Edge cases&lt;/li&gt;
&lt;li&gt;Regression scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, instead of manually creating dozens of test cases for a validation method, AI can generate an initial test suite that developers can review and improve.&lt;/p&gt;

&lt;p&gt;The biggest value is often not simply &lt;strong&gt;writing tests faster&lt;/strong&gt;, but helping developers think about cases they might have overlooked.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. AI for Code Review
&lt;/h2&gt;

&lt;p&gt;AI can also act as an additional code-review layer.&lt;/p&gt;

&lt;p&gt;It can identify potential:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Null-reference issues&lt;/li&gt;
&lt;li&gt;Security problems&lt;/li&gt;
&lt;li&gt;Performance bottlenecks&lt;/li&gt;
&lt;li&gt;Duplicate code&lt;/li&gt;
&lt;li&gt;Poor error handling&lt;/li&gt;
&lt;li&gt;Unnecessary complexity&lt;/li&gt;
&lt;li&gt;Missing tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI review should complement, not replace, human code reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. AI for Documentation
&lt;/h2&gt;

&lt;p&gt;Documentation is often one of the first things developers postpone.&lt;/p&gt;

&lt;p&gt;AI can help generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;README files&lt;/li&gt;
&lt;li&gt;API documentation&lt;/li&gt;
&lt;li&gt;XML documentation&lt;/li&gt;
&lt;li&gt;Architecture descriptions&lt;/li&gt;
&lt;li&gt;Migration guides&lt;/li&gt;
&lt;li&gt;Release notes&lt;/li&gt;
&lt;li&gt;Code comments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This becomes particularly useful for open-source projects where documentation is critical for adoption.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. AI for Git and DevOps
&lt;/h2&gt;

&lt;p&gt;AI can also assist with everyday development operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Commit messages&lt;/li&gt;
&lt;li&gt;Pull request descriptions&lt;/li&gt;
&lt;li&gt;GitHub issue summaries&lt;/li&gt;
&lt;li&gt;CI/CD troubleshooting&lt;/li&gt;
&lt;li&gt;YAML configuration&lt;/li&gt;
&lt;li&gt;Build error analysis&lt;/li&gt;
&lt;li&gt;Release notes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This reduces the amount of repetitive work surrounding the actual coding process.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. AI for Architecture
&lt;/h2&gt;

&lt;p&gt;This is where things become more interesting.&lt;/p&gt;

&lt;p&gt;AI can help developers explore architectural alternatives.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Design an offline-first MAUI application with local SQLite storage, background synchronization and conflict resolution.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI can propose architecture options, identify potential problems, and generate an initial implementation structure.&lt;/p&gt;

&lt;p&gt;But architecture decisions still require human judgment.&lt;/p&gt;

&lt;p&gt;AI does not know your organization's business constraints, operational requirements, team capabilities, security policies, or long-term maintenance strategy unless you provide that context.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. AI + Repository Knowledge
&lt;/h2&gt;

&lt;p&gt;One of the biggest changes in AI-assisted development is the move from &lt;strong&gt;prompt → code&lt;/strong&gt; to &lt;strong&gt;repository → context → implementation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Developers increasingly want AI tools that understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Project structure&lt;/li&gt;
&lt;li&gt;Coding conventions&lt;/li&gt;
&lt;li&gt;Existing services&lt;/li&gt;
&lt;li&gt;APIs&lt;/li&gt;
&lt;li&gt;Documentation&lt;/li&gt;
&lt;li&gt;Dependencies&lt;/li&gt;
&lt;li&gt;Architecture&lt;/li&gt;
&lt;li&gt;Previous implementation decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why repository-aware AI agents and RAG-based developer tools are becoming increasingly important.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. The Developer Still Matters
&lt;/h2&gt;

&lt;p&gt;AI can generate impressive amounts of code.&lt;/p&gt;

&lt;p&gt;But generating code is not the same as engineering software.&lt;/p&gt;

&lt;p&gt;Developers still need to understand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is this architecture being used?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the generated code secure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will it scale?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it work across platforms?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when the network is unavailable?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens six months from now when someone has to maintain it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI can accelerate implementation, but engineering judgment remains essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  My AI Development Workflow
&lt;/h2&gt;

&lt;p&gt;A practical AI-assisted workflow can look like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requirement → Architecture → AI-assisted implementation → Human review → AI-assisted testing → Build → Debug → Code review → Documentation → Release&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The goal is not to let AI replace the developer.&lt;/p&gt;

&lt;p&gt;The goal is to remove repetitive work so developers can spend more time on &lt;strong&gt;architecture, product decisions, problem solving, and quality&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;AI development tools are rapidly becoming another layer of the software development ecosystem.&lt;/p&gt;

&lt;p&gt;The developers who benefit the most may not necessarily be the ones who generate the most code with AI.&lt;/p&gt;

&lt;p&gt;They will be the developers who know &lt;strong&gt;when to use AI, what context to provide, how to verify the output, and when not to trust it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI is changing the way software is built.&lt;/p&gt;

&lt;p&gt;But good software engineering still starts with &lt;strong&gt;understanding the problem&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>development</category>
      <category>aitools</category>
      <category>nuvyntralabs</category>
    </item>
    <item>
      <title>MAUI vs React Native vs Flutter vs Ionic</title>
      <dc:creator>NuvyntraLabs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:09:36 +0000</pubDate>
      <link>https://dev.to/nuvyantralabs_ccee/maui-vs-react-native-vs-flutter-vs-ionic-m58</link>
      <guid>https://dev.to/nuvyantralabs_ccee/maui-vs-react-native-vs-flutter-vs-ionic-m58</guid>
      <description>&lt;h2&gt;
  
  
  An Honest Cross-Platform Comparison — No Sugar Coating
&lt;/h2&gt;

&lt;p&gt;Cross-platform development promises something attractive: &lt;strong&gt;build once and run everywhere&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reality is different.&lt;/p&gt;

&lt;p&gt;.NET MAUI, React Native, Flutter, and Ionic all solve cross-platform development differently. None is perfect, and none completely eliminates the need to understand Android and iOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Difference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Main Approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;.NET MAUI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;C# / .NET&lt;/td&gt;
&lt;td&gt;Native controls through handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;React Native&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TypeScript / JavaScript&lt;/td&gt;
&lt;td&gt;Native components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flutter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dart&lt;/td&gt;
&lt;td&gt;Own rendering engine/widgets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ionic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTML / CSS / JS&lt;/td&gt;
&lt;td&gt;WebView&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That architectural difference matters more than most benchmark comparisons.&lt;/p&gt;




&lt;h2&gt;
  
  
  .NET MAUI
&lt;/h2&gt;

&lt;p&gt;MAUI is particularly attractive for &lt;strong&gt;C#/.NET teams&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Excellent C# and .NET ecosystem&lt;/li&gt;
&lt;li&gt;Strong native platform integration&lt;/li&gt;
&lt;li&gt;Shared business logic and services&lt;/li&gt;
&lt;li&gt;Good fit for enterprise applications&lt;/li&gt;
&lt;li&gt;Natural choice for teams already using ASP.NET Core/Azure&lt;/li&gt;
&lt;li&gt;Windows support is an important advantage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reality check
&lt;/h3&gt;

&lt;p&gt;MAUI does &lt;strong&gt;not&lt;/strong&gt; mean writing XAML once and forgetting Android and iOS.&lt;/p&gt;

&lt;p&gt;You will still encounter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Platform-specific APIs&lt;/li&gt;
&lt;li&gt;Android/iOS lifecycle differences&lt;/li&gt;
&lt;li&gt;Background execution restrictions&lt;/li&gt;
&lt;li&gt;Permissions&lt;/li&gt;
&lt;li&gt;Notifications&lt;/li&gt;
&lt;li&gt;Device-specific issues&lt;/li&gt;
&lt;li&gt;Native code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MAUI ecosystem is also smaller than the JavaScript ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; .NET-centric teams and enterprise applications.&lt;/p&gt;




&lt;h2&gt;
  
  
  React Native
&lt;/h2&gt;

&lt;p&gt;React Native is a natural choice for teams already invested in &lt;strong&gt;React and TypeScript&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Huge JavaScript ecosystem&lt;/li&gt;
&lt;li&gt;Large developer community&lt;/li&gt;
&lt;li&gt;TypeScript support&lt;/li&gt;
&lt;li&gt;Excellent React knowledge reuse&lt;/li&gt;
&lt;li&gt;Strong native integration&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reality check
&lt;/h3&gt;

&lt;p&gt;The npm ecosystem is both a strength and a weakness.&lt;/p&gt;

&lt;p&gt;Large projects can accumulate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dependency conflicts&lt;/li&gt;
&lt;li&gt;Native module complexity&lt;/li&gt;
&lt;li&gt;Gradle/Xcode issues&lt;/li&gt;
&lt;li&gt;JavaScript/native integration problems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For serious applications, you should expect some Kotlin/Java and Swift work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; React/TypeScript teams and products heavily invested in the JavaScript ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flutter
&lt;/h2&gt;

&lt;p&gt;Flutter takes a different approach by controlling its own rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Excellent UI consistency&lt;/li&gt;
&lt;li&gt;Powerful custom UI&lt;/li&gt;
&lt;li&gt;Strong animations&lt;/li&gt;
&lt;li&gt;Good performance for many workloads&lt;/li&gt;
&lt;li&gt;Great for highly branded applications&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reality check
&lt;/h3&gt;

&lt;p&gt;Flutter's consistency comes from its own rendering system rather than simply using native controls.&lt;/p&gt;

&lt;p&gt;Dart is also another ecosystem to adopt, and deep platform integration still requires native Android/iOS knowledge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; applications where custom UI and consistent visual experience are major priorities.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ionic
&lt;/h2&gt;

&lt;p&gt;Ionic takes the &lt;strong&gt;web-first&lt;/strong&gt; approach.&lt;/p&gt;

&lt;p&gt;HTML + CSS + JavaScript/TypeScript run through a WebView, with Capacitor providing native integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Excellent for web developers&lt;/li&gt;
&lt;li&gt;Reuse web skills and code&lt;/li&gt;
&lt;li&gt;Fast development&lt;/li&gt;
&lt;li&gt;Great for forms, dashboards and business applications&lt;/li&gt;
&lt;li&gt;Easy web/mobile technology sharing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reality check
&lt;/h3&gt;

&lt;p&gt;A WebView-based architecture has different characteristics from native-rendered applications.&lt;/p&gt;

&lt;p&gt;It can be perfectly suitable for many business applications, but demanding graphics, animations or highly native experiences can expose its limitations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best fit:&lt;/strong&gt; web-first teams and business applications.&lt;/p&gt;




&lt;h1&gt;
  
  
  Quick Comparison
&lt;/h1&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;MAUI&lt;/th&gt;
&lt;th&gt;React Native&lt;/th&gt;
&lt;th&gt;Flutter&lt;/th&gt;
&lt;th&gt;Ionic&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;C#&lt;/td&gt;
&lt;td&gt;TypeScript/JS&lt;/td&gt;
&lt;td&gt;Dart&lt;/td&gt;
&lt;td&gt;TypeScript/JS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native UI&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Own rendering&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom UI&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⭐ Strong&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web reuse&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;⭐ Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET integration&lt;/td&gt;
&lt;td&gt;⭐ Excellent&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS ecosystem&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;⭐ Huge&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;⭐ Huge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native integration&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;Via plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;.NET teams&lt;/td&gt;
&lt;td&gt;React teams&lt;/td&gt;
&lt;td&gt;Custom UI&lt;/td&gt;
&lt;td&gt;Web teams&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h1&gt;
  
  
  The Most Important Reality
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Cross-platform does not mean platform-independent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Android is still Android.&lt;/p&gt;

&lt;p&gt;iOS is still iOS.&lt;/p&gt;

&lt;p&gt;If your application uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bluetooth&lt;/li&gt;
&lt;li&gt;Camera&lt;/li&gt;
&lt;li&gt;Location&lt;/li&gt;
&lt;li&gt;VoIP&lt;/li&gt;
&lt;li&gt;Audio&lt;/li&gt;
&lt;li&gt;Background services&lt;/li&gt;
&lt;li&gt;Push notifications&lt;/li&gt;
&lt;li&gt;File systems&lt;/li&gt;
&lt;li&gt;Deep links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;you will eventually need to understand the underlying platforms.&lt;/p&gt;

&lt;p&gt;The framework reduces duplicated work. It doesn't eliminate platform complexity.&lt;/p&gt;




&lt;h1&gt;
  
  
  So Which One Is Best?
&lt;/h1&gt;

&lt;p&gt;There isn't a universal winner.&lt;/p&gt;

&lt;p&gt;Choose based on your &lt;strong&gt;team, product and requirements&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose MAUI&lt;/strong&gt; if your organization is deeply invested in .NET and C#.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose React Native&lt;/strong&gt; if React/TypeScript is already your team's strength.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Flutter&lt;/strong&gt; if highly customized and consistent UI is a major requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Ionic&lt;/strong&gt; if you're primarily a web team and your application is well suited to web technologies.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Thought
&lt;/h1&gt;

&lt;p&gt;The best cross-platform framework isn't the one with the most GitHub stars or the loudest marketing.&lt;/p&gt;

&lt;p&gt;It's the one your team can:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build → Debug → Release → Scale → Maintain&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;for years.&lt;/p&gt;

&lt;p&gt;Cross-platform development isn't about eliminating platform differences.&lt;/p&gt;

&lt;p&gt;It's about &lt;strong&gt;managing them efficiently&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;By &lt;a href="https://www.linkedin.com/in/niladri-padhy-7ab41626/" rel="noopener noreferrer"&gt;Niladri&lt;/a&gt;&lt;/p&gt;

</description>
      <category>maui</category>
      <category>flutter</category>
      <category>reactnative</category>
      <category>ionic</category>
    </item>
  </channel>
</rss>
