@webority/mobile-core
The platform layer for Webority React Native apps: HTTP clients, auth and token storage, config,
logging, formatters, validators, network status, permissions, storage, image compression and
version checks. No UI — components live in
@webority/mobile-ui.
npm install @webority/mobile-core
Runtime dependencies: axios and libphonenumber-js. The required peers are expo,
expo-application, expo-network, expo-secure-store (the HTTP client reads the bearer token from
it) and expo-sqlite. Every other native peer is optional and reached only through its own import
path below, so the package installs and runs without any of them. react-native-keychain is no
longer used; token storage moved to expo-secure-store.
Full documentation lives in the repository's docs/ directory.
Import paths
Anything needing a native package lives behind its own path, so an app only bundles what it imports:
import { Permissions } from '@webority/mobile-core/permissions';
import { Geolocation } from '@webority/mobile-core/geolocation';
import { openDatabase } from '@webority/mobile-core/sqlite';
import { downloadFile } from '@webority/mobile-core/download';
import { shareFile } from '@webority/mobile-core/share';
import { fetchAttachment, fetchPreviewAttachment, previewKind } from '@webority/mobile-core/attachments';
import { getOrCreateInstallationId, clearTokensOnFirstLaunch } from '@webority/mobile-core/installation';
import { requestReviewAfterSuccess } from '@webority/mobile-core/review';
import { initPush, enablePush, usePush } from '@webority/mobile-core/push';
import { definePushActionTask } from '@webority/mobile-core/push/task';
import { compressImage } from '@webority/mobile-core/compressor';
import { initOtaUpdates, useOtaUpdates } from '@webority/mobile-core/updates';
import { createApiBaseQuery, createAppApi, setupApiListeners } from '@webority/mobile-core/query';
Metro resolves requires statically, so anything reachable from the main barrel is bundled for every consumer. Keeping these off it is what makes their peers genuinely optional.
/query is the RTK Query layer; it needs @reduxjs/toolkit (2.12 or later) and react-redux,
both optional peers. One createApi per app, built with createAppApi and the base query over
the library's clients, so refresh, retry, the offline check and the extractApiError failure
shape stay in one place. Never a fetchBaseQuery.
// services/api/apiSlice.ts
import { publicClient, userClient } from '@webority/mobile-core';
import { createApiBaseQuery, createAppApi, buildListQuery, providesPagedList,
type PagedDataObject } from '@webority/mobile-core/query';
export const apiSlice = createAppApi({
reducerPath: 'api',
baseQuery: createApiBaseQuery({ client: userClient, publicClient }),
tagTypes: ['Project'],
endpoints: (build) => ({
getProjects: build.query<PagedDataObject<Project>, { page: number }>({
query: ({ page }) => ({ url: '/api/projects', params: buildListQuery({ page, pageSize: 20 }) }),
providesTags: providesPagedList('Project')
})
})
});
// store.ts: add apiSlice.reducer and apiSlice.middleware, then setupApiListeners(store.dispatch)
setupApiListeners maps the app returning to the foreground (AppState) to RTK Query's focus,
and this library's network status to online and offline; RTK's own defaults listen to window
events that never fire on a device. A call marked silent: true, or an endpoint with
extraOptions: { silent: true }, carries silent on action.meta.baseQueryMeta, which the
error toast middleware skips.
/push registers the phone for push notifications. It needs expo-notifications, an optional
peer, and reports unavailable without it or in Expo Go. Call initPush once at startup with
getInstallationId, register, unregister and onOpen; enablePush asks permission, sends
the native token (Fcm on Android, Apns on iOS) and the bundle id to register, and
re-registers when the token changes; disablePush calls unregister and turns push off only once
it succeeds (a failure rejects and leaves push on). resetPush turns push off on the phone with no
server call and drops the native token, for a sign-out with no session left. onOpen receives the
tapped notification's data as strings, including the tap that launched the app. usePush gives a
settings switch its status and enabled. The library does not remember the switch: an app that
keeps push on calls enablePush at each start. setBadgeCount(count) sets the app icon badge. dismissAllNotifications() clears every notification from the notification tray; it never throws, and logs a failure at warn.
enablePush({ prompt: false })never shows the permission prompt: a phone already granted registers silently, and any other resolves with its status (undeterminedwhen the person has not been asked yet,deniedwhen refused and the prompt can still show,blockedwhen only Settings can turn it on). Call it at sign-in, and callenablePush()when the person reaches a moment where asking makes sense.- Every registered device carries
timeZone, the phone's IANA zone (Asia/Kolkata), or null when the platform reports none, so the server can send at the person's local time. - Every registered device carries
apnsEnvironment:DevelopmentorProductionon iOS, read from the build'saps-environment(expo-application), so the server sends each phone to the APNs gateway its signing accepts. It is null on Android, and on iOS when it cannot be read (the simulator); the server then uses its own setting. onReceive(handler)callshandler({ notificationId, title, body, data })for each notification that arrives while the app is open, shown or not, so the app can refresh a list or an unread count. It returns a function that removes the handler. A notification arriving in the background does not reach it; its tap still goes toonOpen.
Offline copy
createPersistedQueries keeps the answers of chosen endpoints on the phone, so a screen opened
with no connection shows the last data with the time it was loaded. Listed endpoints are saved to
Storage when they load; restore puts them back once at start.
- Pass
extractRehydrationInfotocreateAppApi. - Add
middlewareafter the api's middleware. - Call
restore(store.dispatch)once after the store is created. - Call
clear()on sign-out, so the next person on the phone never sees the last person's data.
// services/api/offlineQueries.ts
export const offlineQueries = createPersistedQueries({
storageKey: 'offline.queries',
endpoints: ['getProjects']
});
// services/api/apiSlice.ts: add extractRehydrationInfo: offlineQueries.extractRehydrationInfo
// store.ts: reducer: { [apiSlice.reducerPath]: apiSlice.reducer }
// middleware: (getDefault) => getDefault().concat(apiSlice.middleware, offlineQueries.middleware)
offlineQueries.restore(store.dispatch); // once, after the store exists
// sign-out
await offlineQueries.clear();
A restored answer shows at once without a fetch, so set refetchOnMountOrArgChange: true on each
kept endpoint: a screen that mounts then asks the server again and refreshes the restored data.
Notification buttons
A push names a category; the category's buttons are registered on the phone. Register them at every start, with the same ids on both platforms:
import { setCategories, notifyLocal } from '@webority/mobile-core/push';
await setCategories([
{
id: 'approval',
actions: [
{ id: 'approve', title: 'Approve', opensApp: false },
{ id: 'reject', title: 'Reject', destructive: true }
]
},
{
id: 'reply',
actions: [
{
id: 'reply',
title: 'Reply',
opensApp: false,
textInput: { submitTitle: 'Send', placeholder: 'Write a reply' }
}
]
}
]);
opensApp defaults to true; destructive and authenticationRequired (unlock first) are iOS
only. A button tap arrives as { actionId, userText, data, notificationId }: userText is the
reply text, data the push data as strings, notificationId the notification's identifier on
the phone. A plain tap still goes to onOpen; a button tap with no handler opens like a plain tap.
To receive button taps in every app state, the killed app included, call definePushActionTask
once at module scope in the entry file, before the root component registers. It needs
expo-task-manager, an optional peer (npx expo install expo-task-manager), so it lives on its
own path:
// index.js
import { definePushActionTask } from '@webority/mobile-core/push/task';
import { notifyLocal } from '@webority/mobile-core/push';
definePushActionTask(async ({ actionId, userText, data }) => {
try {
await settle(actionId, userText, data);
} catch {
await notifyLocal({ title: 'Could not approve', body: 'Open the app to try again.' });
}
});
The handler's promise is awaited, so its request can finish before the background work ends.
onAction(handler) from /push sets the same handler for taps while JavaScript is running and
returns a function that removes it. A response reported twice within ten seconds (the launch read,
the listener and the Android task can each report it) runs once.
- Android: the background notification task runs the handler for a button tap on a
backgrounded or killed app. Buttons appear only on notifications expo-notifications draws (for
example
notifyLocal). An app that draws its own notifications natively from data-only pushes must build each button's intent with expo'sNotificationsService.createNotificationResponseIntent, so the tap reaches this handler. - iOS: a button with
opensApp: falselaunches the app in the background; the response reaches the listenerdefinePushActionTasksets up. The task is not registered on iOS.
Storage and network status stay on the main barrel but load no package on their own. Wire them at
startup with setStorageImplementation and setNetworkStatusImplementation if you want them.
Build number header and forced updates
Both HTTP clients send App-Build: <integer> on every request: the app's whole build number
(VERSION_INTERNAL, Android versionCode, iOS CFBundleVersion). The API compares it with its minimum
and answers an older build with 426 client_update_required. The number comes from
initWebority({ appBuild }) (setConfig({ appBuild })), else from expo-application's
nativeBuildVersion. Without a whole number the header is left off, and the server never refuses a
request without it. Client-Build (<appVersion>+<commit>) is unchanged.
Both HTTP clients also send Client-App: <appId> when the config has an app id (initWebority({ appId })).
It is the app's store identifier, so the API can tell apps of the same client kind apart.
The version floor comes only from the Min-App-Version response header. When a reply carries it,
useVersionCheck turns isUpdateRequired on for a build below it. A 426 client_update_required
does not move the floor, so a 426 without that header leaves the floor where it is. A 426 is never
retried.
An app moves to this contract by running webority upgrade 0.93.0. It renames the old X- header
strings to the plain names, ApiError.detail to diagnostic and detail.signOutReason to
detail.code, and lists each removed option (correlationIdHeader, signOutReasonHeader) and
constant for a person to delete.
Quiet requests and log redaction
Both HTTP clients log a failed response through Logger.error. For a call whose failure the app
handles itself, pass skipErrorLog: true in the axios config and the clients stay silent:
await userClient.get('/status', { skipErrorLog: true });
Logger masks secrets in every message and argument before any sink sees them: Bearer tokens,
JWT-shaped strings, and the values of keys named token, accessToken, refreshToken,
password and authorization (any case) in objects and JSON strings.
configureLogger({ redactKeys }) adds app keys to the built-in masked list and never removes one. Logger.getBreadcrumbs() returns the last 50 breadcrumbs, oldest first, already redacted; configureLogger({ breadcrumbLimit }) changes the cap. initWebority takes a required remoteLogger: the sink, or null for a build without crash reporting. It also takes a required appId, the store id (APP_ID in appsettings.json, with no environment suffix), and a required environment. It adds that environment's suffix (.development, .staging, none for Production) and refuses to start when the result differs from the installed app's id.
App lock
@webority/mobile-core/appLock locks the app when a signed-in person returns after the lock delay (five minutes by default). useAppLock returns what a settings screen and the lock screen need:
import { useAppLock } from '@webority/mobile-core/appLock';
const { locked, enabled, biometricAvailable, availabilityKnown, setEnabled, unlock } = useAppLock({ active: isSignedIn });
availabilityKnownis false until the first biometric check settles. Until thenbiometricAvailableis not an answer yet, so a screen should wait before it shows the lock as unavailable or disables its switch.setEnabled(next)andunlock(reason)return anAppLockResult:{ ok: true }, or{ ok: false, reason }with the reasoncancelled,lockout,not-enrolled,not-availableorfailed.policyoverrides the delay and the two storage keys; the defaults are used for any key left out.
Metro bridge
@webority/mobile-core/metro builds the development bridge that a Metro dev tool reads as
globalThis.__METRO_BRIDGE__. The app assigns it under __DEV__; the builder adds no check of its own.
import { createMetroBridge } from '@webority/mobile-core/metro';
if (__DEV__) {
globalThis.__METRO_BRIDGE__ = createMetroBridge({ store, navigationRef, signIn, signOut, isSignedIn });
}
store: the Redux store, read throughgetStateand written throughdispatch.navigationRef: the navigation container's ref (isReady,getRootState,canGoBack,goBack,navigate).signIn(request)resolves{ codeRequired };signOut()resolves once the session is cleared;isSignedIn()returns a boolean.commands(optional): extra named commands. A name that issignIn,signOutornavigatethrows when the bridge is created.
The bridge exposes commands (signIn, signOut, navigate, and the extras), navigation
(getState, which is null until the navigator is ready, and goBack, which returns { wentBack }),
redux, auth (isSignedIn) and push (notifyLocal and deviceToken, the device's push token).