react-native-quick-settings-tile
v0.1.0
Published
A Quick Settings tile (Android) and Control Center control (iOS 18+) that deep-link into your React Native (Expo) app.
Maintainers
Readme
react-native-quick-settings-tile
A Quick Settings tile (Android) and a Control Center control (iOS 18+) that open your app at a deep link of your choosing — so users can trigger an action from anywhere, without hunting for your app.
Both surfaces are configured from one place in your app config, and the tile can reflect an active/inactive state that survives your app being killed.
// app.json
{
"expo": {
"scheme": "myapp",
"plugins": [
["react-native-quick-settings-tile", {
"label": "Share location",
"url": "myapp://quick-share"
}]
]
}
}Requirements
| | |
| --- | --- |
| Expo SDK | 54+ (developed against SDK 57), or a bare RN app with Expo modules |
| Android tile | Android 7 (API 24)+ |
| requestAddTile() prompt | Android 13 (API 33)+ — resolves 'unsupported' below |
| iOS control | iOS 18+ — absent below, app unaffected |
Requires a development build — this module contains native code, so it does not run in Expo Go.
Bare React Native: despite the name, this package is built on Expo Modules and configured by an Expo config plugin — there is no manual-linking path. In a bare app, adopt Expo modules and prebuild first (npx install-expo-modules).
Install
npx expo install react-native-quick-settings-tile
npx expo prebuild --cleanOptions
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| label | string | — | Required. Tile / control title. Keep it short: Quick Settings truncates past ~18 characters. |
| url | string | — | Required. Deep link opened on tap. Must match your app's scheme. |
| android.icon | string | bundled pin | Path to a vector drawable XML, relative to your project root. Quick Settings tints tile icons itself, so it must be a flat single-colour vector on a transparent background — a bitmap will not render correctly. |
| android.requireUnlock | boolean | true | Require the device to be unlocked before the tile opens your app. Turning this off launches your app over the keyguard, exposing whatever it shows to whoever holds the locked phone. |
| android.appendTimestamp | boolean | true | Append ?ts=<millis> so each tap is a distinct URL. Leave this on if you route with Linking.useURL(), which otherwise deduplicates repeat taps. |
| ios.systemImage | string | "location.fill" | SF Symbol name. Controls only accept SF Symbols; a custom image renders as ?. |
| ios.deploymentTarget | string | "18.0" | Deployment target of the generated widget extension. |
| ios.appGroup | string | — | App group id (must start with group.), claimed by both your app and the generated extension so they share a container — e.g. UserDefaults(suiteName:) for state the control reads. Omit it unless you need one: an app group requires an explicit App ID, so adding it stops a wildcard provisioning profile from being able to sign the app, and a free personal team cannot provision it at all. |
| ios.delegateToAppleTargets | boolean | true | Set false if your app already runs @bacons/apple-targets with a match that covers targets/quick-settings-tile, to avoid processing the target twice. |
API
import * as QuickTile from 'react-native-quick-settings-tile';
// Highlight the tile until a moment in time. Pass 0 for "inactive now".
QuickTile.setTileActive(Date.now() + 15 * 60 * 1000);
// Send your app to the background, returning the user where they came from.
QuickTile.moveTaskToBack();
// Prompt to add the tile (Android 13+). Call it from the foreground — e.g. a
// button press — or the system refuses and it resolves 'error'.
const result = await QuickTile.requestAddTile();
// 'added' | 'already_added' | 'declined' | 'error' | 'unsupported'
QuickTile.isAvailable(); // false wherever there is no native tilesetTileActive takes when the active state ends, not a boolean, so the tile can show itself as inactive after that moment even if your app is no longer running. The comparison uses the device's wall clock, so a user who moves their clock backwards re-lights the tile — don't treat its state as anything more than a display hint.
Every function is a safe no-op off Android, so callers never need to branch on platform.
Make moveTaskToBack() a user setting, not an always-on step. Returning the user to where they came from is the whole appeal of a tile, but an app that closes itself right after they tapped a button is indistinguishable from a crash — users report it as one. Gate the call behind a preference (default on is fine) so anyone who'd rather stay in your app can turn it off.
Platform notes
Android. The tile works on Android 7 (API 24) and up. requestAddTile() needs Android 13 (API 33) and resolves 'unsupported' below it — fall back to telling users to add the tile by hand (pull down the shade → edit → drag your tile in). It resolves 'error' when the system refuses the request without asking the user, most commonly because your app wasn't in the foreground when you called it. The tile's label and icon come from app resources the plugin writes, which override the library's defaults.
iOS. The control needs iOS 18; below that it simply doesn't appear and your app is unaffected. Users add it themselves through Control Center customisation — Apple exposes no add-prompt API, which is why requestAddTile() resolves 'unsupported' here. The control is static in this version: setTileActive does not change it. ios.appGroup gives the app and the control a shared container to build that on, but the control does not yet read state from it. Use a custom scheme, not a universal link: universal links from controls were broken in iOS 18.0.
Why the iOS control is built the way it is
Every guide you'll find says to return an OpenURLIntent from a custom intent's perform(). That is the widget pattern; in a control it compiles, registers, renders — and then does nothing at all when tapped. Four separate requirements have to line up, and missing any one fails silently (or with an error you'll only see in log stream --predicate 'subsystem == "com.apple.chrono"'):
| Requirement | Provided by | If missing |
| --- | --- | --- |
| Control counts as a launcher | static var openAppWhenRun = true | descriptor shows isLauncher: NO; iOS stops offering the control |
| Destination known at registration | URLRepresentableIntent + static var urlRepresentation | tap fails: CHSErrorDomain 1107, "Encountered action intent without linkAction" |
| The app can perform the intent | the intent compiled into the app target too, not just the extension | action is accepted, then the launch is silently abandoned |
| The app learns where to go | the app-target perform() opening the URL itself | app opens on its default screen — the launch carries no URL |
Two things that look like fixes but aren't:
ControlWidgetButton(action: OpenURLIntent(url))—OpenURLIntentdoesn't conform toOpenIntent, so this binds to the genericAppIntentoverload and carries no link either.OpenIntent— itstargetmust be anAppValue; a plainURLdoesn't qualify, so it can't express "just open this link".
Hence the two generated copies of the intent: the extension's (UIKit-free, since UIApplication is unavailable to app extensions) exists for the control's static metadata, and the app's is the one that actually runs and opens your deep link.
The iOS half generates an Xcode widget-extension target under targets/quick-settings-tile/ in your app at prebuild, then delegates the Xcode wiring to @bacons/apple-targets, which also registers the extension with EAS so it is signed automatically. Add targets/ to your .gitignore — it is regenerated on every prebuild.
Gotcha: your app isn't focused the instant it launches
A tile tap brings your app up and routes the deep link before the window has focus. Anything that requires focus will fail silently in that window — most notably the clipboard: Android ignores writes from an unfocused app, logs Denying clipboard access to <pkg>, and the write still reports success. So the only trustworthy check is reading it back:
async function copyWhenFocused(text: string): Promise<boolean> {
for (let attempt = 0; attempt < 12; attempt++) {
if (AppState.currentState === 'active') {
await Clipboard.setStringAsync(text);
if ((await Clipboard.getStringAsync()) === text) return true;
}
await new Promise((r) => setTimeout(r, 150));
}
return false;
}Do this before calling moveTaskToBack() — otherwise you hand the user back to their chat with an empty clipboard and a toast that says otherwise.
How it fits together
tile / control tap
→ myapp://quick-share?ts=… (package-scoped ACTION_VIEW / OpenURLIntent)
→ your app (cold: initial URL, warm: onNewIntent → url event)
→ Linking.useURL() → your routingSecurity: the deep link is a public entry point
The URL's arrival is not proof that the user tapped your tile. On Android, any installed app can fire the same URL at your app with an ACTION_VIEW intent; on iOS, any app or web page can open your custom scheme — and custom schemes are first-come-first-served, so another app could even claim yours. The ts= parameter is trivially forgeable, and android.requireUnlock gates only the tile itself, never the URL.
So treat the deep link as navigation, not authorization:
- Don't perform sensitive, destructive, or irreversible actions purely because the URL arrived. Confirm in-app, or require the action to have been armed by the user beforehand.
- Don't trust query parameters (including
ts) as authentication or freshness proof. - Assume the URL can arrive at any time, from any caller, on both platforms — design the screen it opens accordingly.
License
MIT
