Device

@ng-native/device is the set of services that answer questions about the device and the operating system: how big the screen is, whether the user is in dark mode, how much of the screen the system has claimed, what the user has turned on in Settings, and whether the app is even in front of them right now. Everything here is a signal, not an observable and not a hook, so reading one in a template or a computed() is the same as reading any other piece of state.

Every service is providedIn: 'root' and built around an @Service() factory, so injecting one is the whole setup:

import { Component, inject } from '@angular/core';
import { Text } from '@ng-native/components';
import { ColorScheme } from '@ng-native/device';

@Component({
  selector: 'app-header',
  imports: [Text],
  template: `<text>{{ scheme.current() }}</text>`,
})
export class Header {
  protected readonly scheme = inject(ColorScheme);
}

A service nobody injects is never constructed, so there is no cost to importing the package and using only what a screen actually needs. Reach for a service here rather than reading React Native's own module directly - Dimensions, Appearance, AppState and the rest - because the service is what turns a callback-based API into a signal a computed() or a template can read without a manual subscription to tear down, and because it is what still works when there is no device: in a unit test, in mount's own bootstrap, in anything that imports this code without a phone underneath it.

Off a device

Every service here falls back to doing nothing when there is no React Native underneath it: no nativeFabricUIManager, no listeners, and a value that just sits at its neutral default. That is what makes the package importable by a test suite, by mount, and by anything else that runs this code somewhere other than a phone. ColorScheme reports light, Screen reports a zero-sized window, AppState reports active, and so on - nothing throws, and nothing waits forever for a platform that is not going to answer.

Most services also expose a SOURCE injection token, which a test overrides to drive the service without a device underneath it (SafeArea is the exception - it has no native module behind it, only report(), which <safe-area-provider> calls):

import { ColorScheme } from '@ng-native/device';
import { injectService } from '@ng-native/testing';
import { expect, it } from 'vitest';

it('reads the scheme the source reports', () => {
  const scheme = injectService(ColorScheme, {
    providers: [
      {
        provide: ColorScheme.SOURCE,
        useValue: { current: () => 'dark', subscribe: () => () => {} },
      },
    ],
  });

  expect(scheme.current()).toBe('dark');
});

The same providers work in render(), for a component that injects the service. See Testing with services.

The OS and its version

OS is the operating system the app runs on: 'ios' or 'android', from Platform.OS, and 'web' in the web host. In a test it is the platform the test renders as, 'ios' unless it called registerPlatformComponents('android').

A stand-alone mount from @ng-native/web provides 'web' in its root injector. An island (mount(el, C, { injector }) or <ng-native-island>) provides it only in the island's own injector, so a root service of the host app that injects OS reads 'ios' there, the default off a device. A host app that uses islands and has such services provides it in its own bootstrapApplication providers: { provide: OS, useValue: 'web' }.

OS_VERSION is the major version of that operating system, for a default that follows what the platform draws in that version: 26 on iOS 26.5, and the API level on Android. It is null on the web and in a test.

A feature that needs a version of one OS checks both, since an Android API level is a number too. A service injects them rather than importing Platform from react-native, which a Vitest spec cannot load:

@Service()
export class Assistant {
  // Apple Foundation Models need iOS 26.
  readonly available = inject(OS) === 'ios' && (inject(OS_VERSION) ?? 0) >= 26;
}

A test provides the OS and version it wants, to check the gate both ways:

providers: [
  { provide: OS, useValue: 'android' },
  { provide: OS_VERSION, useValue: 36 },
];

The services

Screen

  • Screen - the window, the physical display, orientation, and the compact breakpoint.
  • Safe area - the insets SafeArea reports, fed by <safe-area-provider>.

Appearance

  • Color scheme - light or dark, as the user set it.
  • Reading a token - injectToken, a custom property's value as a signal, for a prop or an API that takes a colour.
  • Accessibility - screen reader, reduced motion, bold text and font scale.
  • Direction - left-to-right or right-to-left.

System

  • Status bar - a stack of claims on style, visibility and color.
  • Navigation bar - a stack of claims on Android's navigation bar, its button colour and whether it shows.
  • Keyboard - height, position and animation timing.
  • Hardware back - claiming Android's hardware back button.
  • App state - whether the app is in front of the user.
  • Deep links - the launch url and every link that arrives after.
  • Android permissions - runtime permissions, granted outright on iOS.
  • Layout animation - animating a layout change CSS cannot express.

Feedback

  • Dialogs - the platform's own alert, confirmation, prompt, action sheet and toast.
  • Sharing - the system share sheet.
  • Vibration - the phone's motor.

Development

  • Dev menu - switches in the shake menu, gone in a release build.

Angular components as native iOS and Android views, built and shipped with Expo.

An alpha. MIT licensed. Sponsor its development.

An independent project, not affiliated with or endorsed by Google, the Angular team or Expo. Angular is a trademark of Google LLC.