Platform Features & Deployment
Here's the mental model that carries this whole phase. MAUI gives you one C# API that
reaches each platform's native features — the GPS chip, the network state, the battery,
the clipboard. You call one method; MAUI talks to Android's location services on Android and
Apple's on iOS. For the rare case the shared API doesn't cover, you drop into the
Platforms/ folders with native code guarded by #if. And once the app does what you want,
you package it per store — a different bundle and signing process for each target.
So three moves, in order: reach native features with shared code, escape to platform code
only when forced, then ship. Our notes app has lived on a single codebase since Phase 1, and
that's about to pay off — the same app, with the same logic, becomes an Android .aab, an iOS
build, and a Windows .msix.
📝 These device APIs used to be a separate package called Xamarin.Essentials. In modern MAUI they're built in, under namespaces like
Microsoft.Maui.DevicesandMicrosoft.Maui.ApplicationModel. If you find old tutorials importingXamarin.Essentials, that's the same feature set — the names just moved.
Device APIs — one call, all platforms
A phone is a pile of sensors and services: location, network, battery, contacts, the camera. Each platform exposes these through its own native SDK with its own types and ceremony. MAUI wraps the common ones so you write the call once.
Take connectivity. Before our notes app syncs to a server, it should know whether there's a
network at all — firing an HttpClient request into airplane mode just gives you a slow,
confusing failure. One property tells you:
using Microsoft.Maui.Networking;
async Task SyncNotesAsync()
{
if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
{
await Shell.Current.DisplayAlert(
"Offline",
"You're not connected. Your notes are saved locally and will sync later.",
"OK");
return;
}
// We have a connection — safe to call the API (Phase 6).
await _notesApi.PushAsync(_notes);
}
What just happened: Connectivity.Current.NetworkAccess returns an enum describing the
device's network state. We check for Internet before touching the network, so an offline
user gets a clear, friendly message instead of a timeout. The exact same code runs on Android,
iOS, and Windows — MAUI asks each platform's connectivity API under the hood.
Want to react when the connection changes mid-session? Subscribe to an event:
Connectivity.Current.ConnectivityChanged += (s, e) =>
{
bool online = e.NetworkAccess == NetworkAccess.Internet;
SyncBanner.IsVisible = !online; // show an "offline" banner when we drop
};
What just happened: ConnectivityChanged fires whenever the device gains or loses a
connection. We flip a banner's visibility so the user always knows the app's sync state — no
polling, no guesswork.
That single-call shape repeats across the whole family. A quick map of the ones you'll reach for most:
| API | What it gives you | Example call |
|---|---|---|
Geolocation |
Current GPS coordinates | await Geolocation.GetLocationAsync() |
Connectivity |
Network state + changes | Connectivity.Current.NetworkAccess |
Battery |
Charge level, charging state | Battery.Default.ChargeLevel |
DeviceInfo |
Model, OS version, platform | DeviceInfo.Current.Platform |
Clipboard |
Copy/paste text | await Clipboard.SetTextAsync(note) |
Email / Browser |
Open a composer / a URL | await Browser.OpenAsync(url) |
Preferences |
Small key-value storage (Phase 6) | Preferences.Set("key", value) |
DeviceInfo is handy for branching on platform without leaving C#:
using Microsoft.Maui.Devices;
if (DeviceInfo.Current.Platform == DevicePlatform.iOS)
{
// e.g. nudge layout for the iOS status bar
}
What just happened: DeviceInfo.Current.Platform tells you which OS you're running on at
runtime, so you can make small adjustments in shared code without dropping into a Platforms/
folder. Use it for tweaks; use real platform code (below) for genuinely native behavior.
Permissions — ask, and declare
Some features touch private user data — location, camera, contacts. Both Android and iOS guard these behind runtime permission prompts: the OS asks the user, at the moment of use, whether your app may have access. MAUI gives you a shared API to check and request:
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices.Sensors;
async Task<Location?> GetCurrentLocationAsync()
{
var status = await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
return null; // user said no — degrade gracefully, don't crash
return await Geolocation.Default.GetLocationAsync();
}
What just happened: we check the current permission status first (no need to nag a user
who already said yes), and only request if we don't have it. If the user declines, we
return null and the caller handles the no-location case — we never assume permission was
granted. For our notes app, this might tag a note with "where it was written," and skip the
tag gracefully if location is off.
⚠️ The C# call is only half the job. Each platform also requires you to declare the permission in its manifest — and the request will silently fail (or the store will reject your app) if you forget. The declaration is per-platform:
- Android → an entry in
Platforms/Android/AndroidManifest.xml:<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />- iOS → a usage-description string in
Platforms/iOS/Info.plist. Apple shows your text to the user in the prompt and rejects apps that omit it:<key>NSLocationWhenInUseUsageDescription</key> <string>We tag notes with where you wrote them.</string>
Two halves, every time: request in C#, declare in the manifest. When a permission "doesn't work," a missing manifest entry is the first thing to check.
Platform-specific code — only when you must
The shared APIs cover a lot, but not everything. Sometimes you need behavior that exists only on one platform — a native widget, a vendor SDK, an OS-specific tweak. MAUI gives you three escape hatches.
1. Conditional compilation with #if. The compiler defines a symbol per target
(ANDROID, IOS, MACCATALYST, WINDOWS), so you can fence off platform code inline:
public void Vibrate()
{
#if ANDROID
var vibrator = Android.Views.View.GetSystemService(/* ... Android API ... */);
// call into the native Android vibrator
#elif IOS
UIKit.UIImpactFeedbackGenerator
.Init(UIKit.UIImpactFeedbackStyle.Medium)
.ImpactOccurred();
#endif
}
What just happened: the code inside #if ANDROID only compiles into the Android build, and
the #elif IOS block only into the iOS build. Each branch can call that platform's native API
directly. Great for a one-off; messy if it sprawls.
2. The Platforms/ folders + partial classes. For anything bigger, MAUI's project layout
already separates native code into Platforms/Android/, Platforms/iOS/, and so on. You
declare a partial method in shared code and implement it once per platform folder — same
shape as #if, but each platform's code lives in its own clean file.
3. An interface with per-platform implementations. The cleanest pattern for real native features: define an interface in shared code, write one implementation per platform, and inject the right one via dependency injection.
// Shared code — the contract
public interface IDeviceTorch
{
Task ToggleAsync(bool on);
}
// In a ViewModel — depend on the abstraction, never the platform
public class NoteEditorViewModel(IDeviceTorch torch)
{
public Task FlashAsync() => torch.ToggleAsync(true);
}
What just happened: the ViewModel knows only IDeviceTorch — pure shared C#, fully testable.
The Android and iOS torch implementations live in their Platforms/ folders and get
registered with the DI container at startup. Your app logic never sees a platform type.
💡 Reach for these only when the cross-platform API doesn't cover you. Most of what an app needs already has a shared API — check the device-API table first. Platform code is a tool for the edges, not the default.
Deployment — package per store
Your notes app runs. Now you turn one codebase into store-ready bundles. Each target produces a
different artifact with its own signing, icons, and review process — the build command picks the
framework with -f:
Android → a signed .aab (Android App Bundle, what Google Play wants) or .apk:
What just happened: dotnet publish compiles a Release build for the Android target and
produces the app bundle. You'll sign it with a keystore (Google Play also offers managed
signing) and upload it to the Play Console. The .aab lets Google generate
device-optimized APKs for each user.
iOS / Mac Catalyst → an App Store build:
⚠️ Building and shipping iOS requires a Mac — Apple's toolchain (the signing and packaging step) only runs on macOS. You'll also need an Apple Developer account (paid) and provisioning profiles that tie your app ID and signing certificate together. There's no way around the Mac; even from a Windows dev box, the final iOS build runs on a connected or remote Mac. Plan for it early.
Windows → an .msix package for the Microsoft Store or sideloading:
What just happened: this produces an .msix, Windows' modern app-package format. You can
submit it to the Microsoft Store or distribute it directly (sideloading) with a trusted
certificate.
Each store then has its own gauntlet: signing keys to guard, icon and splash assets at the right sizes, metadata and screenshots, and a review queue. Apple's review is the strictest; budget days, not minutes. The packaging is the easy part — the store paperwork is where first-time shippers lose time.
📝 The mechanics of actually getting through a store review — assets, metadata, privacy labels, beta tracks, and the waiting — are their own discipline. The Ship Your Side Project guide walks the whole release path, and it applies directly here.
Recap
- One C# API reaches each platform's native features.
Geolocation,Connectivity,Battery,DeviceInfo,Clipboard, and friends are single calls that work everywhere — check connectivity before syncing, read the GPS, copy text, all from shared code. - Permissions are two halves: request in C#, declare in the manifest. Use
Permissions.CheckStatusAsync<T>()/RequestAsync<T>(), AND add the entry toAndroidManifest.xml/Info.plist. ⚠️ A missing manifest entry fails silently or gets the app rejected. - Drop into platform code only when forced —
#if ANDROID,Platforms/partial classes, or an interface with per-platform implementations behind DI. Check the shared APIs first. - Package per target: Android
.aabviadotnet publish -f net8.0-android, iOS (⚠️ needs a Mac + Apple Developer account + provisioning profiles), Windows.msix. Each store brings its own signing, assets, and review. - The whole point of MAUI pays off here: the same notes app, one codebase, becomes a native bundle on every platform.
Quick check
[
{
"q": "Your app calls Permissions.RequestAsync<Permissions.LocationWhenInUse>() and the GPS read still fails on a real Android device. What's the most likely cause?",
"choices": ["MAUI doesn't support location on Android", "You forgot to declare the permission in AndroidManifest.xml", "You must use #if ANDROID for all location code", "Connectivity is off"],
"answer": 1,
"explain": "The C# request is only half the job — Android also needs the matching <uses-permission> entry in AndroidManifest.xml, and iOS needs a usage-description string in Info.plist. Without the manifest declaration, the request silently fails."
},
{
"q": "Before syncing notes to a server, which API tells you whether the device has a network connection?",
"choices": ["DeviceInfo.Current.Platform", "Battery.Default.ChargeLevel", "Connectivity.Current.NetworkAccess", "Preferences.Get"],
"answer": 2,
"explain": "Connectivity.Current.NetworkAccess returns the device's network state. Checking for NetworkAccess.Internet before an HttpClient call lets you fail fast and show an offline message instead of waiting on a timeout."
},
{
"q": "Which deployment fact is true?",
"choices": ["iOS apps can be built and shipped entirely from Windows with no Mac", "Android publishes to a .msix package", "Building/shipping iOS requires a Mac, an Apple Developer account, and provisioning profiles", "Windows apps ship as a signed .aab"],
"answer": 2,
"explain": "Apple's toolchain only runs on macOS, so iOS needs a Mac plus a paid Apple Developer account and provisioning profiles. Android ships an .aab/.apk; Windows ships an .msix."
}
]
← Phase 6: Data & Calling APIs · Guide overview · Phase 8: Where to Go Next →
Check your understanding
1. Your app calls Permissions.RequestAsync<Permissions.LocationWhenInUse>() and the GPS read still fails on a real Android device. What's the most likely cause?
2. Before syncing notes to a server, which API tells you whether the device has a network connection?
3. Which deployment fact is true?