@webority/mobile-core
v0.15.0
Published
Platform layer for Webority React Native apps: HTTP clients, auth/token storage, config, logging, formatters, validators, network status, permissions, storage, version checks and over-the-air updates. No UI.
Maintainers
Readme
@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-coreRuntime 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 { 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.6 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
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.
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 X-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. X-Client-Build (<appVersion>+<commit>) is unchanged.
A 426 client_update_required opens the same force-update flow as the X-Min-App-Version response
header: useVersionCheck turns isUpdateRequired on. The floor is the header when the reply has
one, else the body's minVersion, else one above this build. A 426 is never retried.
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.
