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

expo-saf-scan

v1.1.1

Published

Fast listing, video thumbnails and deleting in folders picked with Android's Storage Access Framework

Readme

expo-saf-scan

npm version npm downloads license platform: Android Expo modules

Fast listing, thumbnails and deleting in folders picked with Android's Storage Access Framework (SAF), as an Expo module.

Android lets apps read another app's shared folder, like WhatsApp's media in Android/media/com.whatsapp/WhatsApp, only after the user picks it in the system folder picker. Listing such a folder with a document-file wrapper asks the system once per file for every property (type, size, date), which takes close to a minute for a few thousand files. expo-saf-scan asks once per folder.

| Listing 3,074 files (WhatsApp media folder) | Time | | --- | --- | | expo-file-system (Directory.list(), size, modification time) | 54.8 s | | expo-saf-scan (listAsync(…, { recursive: true })) | 2.6 s |

Measured with the example app in a debug build on an Android 16 emulator (Pixel 9a). Both get faster in a release build; the gap stays.

Built for Pikuro, where it powers the WhatsApp cleanup.

Features

  • Folder picker that opens at a given folder and keeps read/write access across restarts
  • Fast listing: one query per folder with name, size, MIME type and date, optionally recursive (subfolders in parallel)
  • Thumbnails of photos and videos, cached as small JPEGs: Android's own thumbnail when there is one, otherwise decoded at a fraction of the size (a video's first frame). Never reads the whole file into memory (image components load the full file for a video URI). Works for media library URIs too.
  • Batch delete, several files at once
  • Install time of other apps, with a config plugin for the <queries> entry Android 11+ needs

Android only: iOS has no equivalent of picking another app's folder. On iOS and web, isAvailable is false.

Installation

npx expo install expo-saf-scan

It contains native code, so it needs a development build (not Expo Go). In a bare React Native app, install Expo modules first.

To read the install time of other apps, declare them with the config plugin in app.json:

{
  "expo": {
    "plugins": [["expo-saf-scan", { "packages": ["com.whatsapp"] }]]
  }
}

Usage

import {
  externalStorageDocumentUri,
  hasAccess,
  listAsync,
  pickFolderAsync,
  thumbnailAsync,
} from 'expo-saf-scan';

// Opens the system picker right in WhatsApp's folder; the user taps "Use this folder" and "Allow".
const treeUri = await pickFolderAsync({
  initialUri: externalStorageDocumentUri('Android/media/com.whatsapp/WhatsApp'),
});
if (!treeUri) return; // Cancelled.
// Store treeUri: access stays until the app is uninstalled. Check it later with hasAccess(treeUri).

const entries = await listAsync(treeUri, { recursive: true });
const media = entries.filter((entry) => /^(image|video)\//.test(entry.mimeType ?? ''));
const thumbnail = await thumbnailAsync(media[0].uri, { maxSize: 256 }); // file:// URI of a JPEG

API

pickFolderAsync(options?): Promise<string | null>

Opens the system folder picker. Resolves with the picked folder's tree URI, or null if the user cancelled. Read and write access is kept across restarts.

  • initialUri: document URI where the picker opens, see externalStorageDocumentUri.

Android doesn't allow picking the storage root, Download/, Android/data/ or Android/obb/. Android/media/<package> works.

externalStorageDocumentUri(path): string

Document URI of a path on the phone's shared storage, for initialUri: externalStorageDocumentUri('DCIM/Camera').

hasAccess(treeUri): boolean

Whether the app still holds read and write access to a picked folder.

listAsync(treeUri, options?): Promise<SafEntry[]>

Lists a picked folder with one query per folder.

  • recursive: also list all subfolders (default false)
  • includeHidden: include names starting with a dot, like .nomedia (default false)
  • documentId: list a subfolder from an earlier listing instead of the picked folder

Each SafEntry has uri, documentId, name, path (relative to the listed folder), isDirectory, mimeType, size (bytes) and lastModified (ms).

thumbnailAsync(uri, { maxSize? }): Promise<string | null>

File URI of a cached JPEG of a photo or video, at most maxSize px (default 512) on its longer side; null if the file can't be read. Takes a document URI from listAsync or a media library content:// URI.

It asks Android for the thumbnail it usually keeps already (the gallery made it) and only decodes the file when there is none: a photo at a fraction of its size, a video's first frame. Loading a document URI into an image component instead decodes the whole photo, and for a video reads the whole file into memory, which crashes with large videos.

videoThumbnailAsync(uri, { maxSize? }): Promise<string | null>

Deprecated, use thumbnailAsync. Removed in 2.0.0. File URI of a cached JPEG of a video's first frame.

deleteAsync(uris): Promise<string[]>

Deletes files or folders for good (there is no trash) and resolves with the URIs that are gone afterwards, including those that were gone already. Up to 8 deletes run at once. Android shows no confirmation for SAF deletes, so ask the user first.

getInstallTime(packageName): number | null

When another app was first installed on this phone, in ms; null if it isn't installed or isn't declared with the config plugin. Useful to tell files that came along with a backup from an older phone.

isAvailable: boolean

true on Android.

Example app

cd example
pnpm install
npx expo run:android

Pick a folder, then compare expo-saf-scan with expo-file-system on it.

License

MIT