npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

react-native-quick-settings-tile

npm CI license: MIT

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 --clean

Options

| 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 tile

setTileActive 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))OpenURLIntent doesn't conform to OpenIntent, so this binds to the generic AppIntent overload and carries no link either.
  • OpenIntent — its target must be an AppValue; a plain URL doesn'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 routing

Security: 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