JavaScript API

Use Android features from your app’s JavaScript: read device information, store data, share text or show notifications, schedule alarms, open links or use the clipboard. Calls return promises. Inside the APK they run natively on Android; in a browser and under koru dev the same calls use web implementations, so you can develop without a device.

On this page

Before you call an API

koru init enables device, storage and app; the basic template also enables vibration and share. To use another plugin, add its ID and rebuild:

"plugins": ["device", "storage", "app", "vibration", "share", "notifications", "alarms", "browser", "screen", "clipboard"]

This is one field to put in your existing koru.json, not a complete file. Enabling vibration adds android.permission.VIBRATE; notifications adds android.permission.POST_NOTIFICATIONS; alarms adds POST_NOTIFICATIONS and RECEIVE_BOOT_COMPLETED. browser, screen and clipboard need no permissions. In a packaged HTML page, import the generated module:

<script type="module">
  import { device } from "/__koru/core.mjs";
  document.querySelector("#model").textContent = (await device.getInfo()).model;
</script>

Add an element with id="model" to that page. Koru also injects /__koru/core.js, which exposes window.Koru. The module exports device, app, storage, vibration, share, notifications, alarms, browser, screen, clipboard, invoke, platform and isNative. With a bundler such as Vite, import a copy written by koru js instead (./koru/core.mjs); it behaves the same. All methods return a Promise. Wrap calls in try/catch to show a useful error:

try {
  const info = await device.getInfo();
  console.log(info.model);
} catch (error) {
  console.error(error.code, error.message);
}

TypeScript and editor support

The module ships with type declarations. Projects from koru create are already set up:

  • basic: types/koru.d.mts and a jsconfig.json that maps /__koru/core.mjs to it, so VS Code and tsc --checkJs type-check www/app.js.
  • vite: src/koru/core.d.mts sits next to core.mjs and is picked up automatically.

For your own TypeScript project, run koru js src/koru, or map the served path in tsconfig.json:

{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "paths": { "/__koru/core.mjs": ["./types/koru.d.mts"] }
  }
}

Types cover every method, result shape, CallOptions and the KoruError codes:

import { storage, type KoruError } from "/__koru/core.mjs";

try {
  const theme: string | null = await storage.get("theme");
} catch (error) {
  console.warn((error as KoruError).code);
}

Android or browser

platform is "android" inside the APK and "web" anywhere else. isNative() returns platform === "android". Use them for the few places where behavior should differ:

import { isNative, share } from "/__koru/core.mjs";

const label = isNative() ? "Share" : "Copy link";

In the browser, device.getInfo() reports model: "Web browser" and sdk: 0; storage uses localStorage with a koru: prefix; share.text uses the Web Share API or copies to the clipboard; notifications use the browser’s Notification API; vibration.vibrate uses navigator.vibrate where it exists. Argument validation and error codes are the same as on Android. A browser without an equivalent rejects with UNAVAILABLE. Under koru dev, calls to plugins not enabled in koru.json reject with METHOD_NOT_FOUND, as in the APK.

On a remote page loaded through server.bridge, the app adds KoruApp/1 to the user agent, so platform is "android" from the start and calls wait until the page has finished loading. Remote pages outside bridge receive koru-none: core.js reports "web" and uses the web implementations. Those pages must load core.js themselves. See Remote content and hybrid apps.

Method index

Plugin Methods Enabled by init?
Device getInfo() Yes
App getInfo() Yes
Storage get(), set(), remove(), clear() Yes
Vibration vibrate() No
Share text() No
Notifications permission(), requestPermission(), show(), cancel() No
Alarms schedule(), cancel(), list() No
Browser open() No
Screen keepAwake() No
Clipboard writeText(), readText() No
Low-level bridge invoke() Available to packaged pages

Each method below opens independently. The examples assume imports from /__koru/core.mjs and run only inside the APK.

Device and app

device.getInfo()Read the Android device

Signature: device.getInfo(options?) → Promise<DeviceInfo>. No native parameters or permissions.

Result: { model: string, manufacturer: string, osVersion: string, sdk: number, platform: "android" }. The values come from Android Build fields and differ by device; sdk is its API level.

import { device } from "/__koru/core.mjs";
const info = await device.getInfo();
console.log(
  `${info.manufacturer} ${info.model} · Android ${info.osVersion} (API ${info.sdk})`,
);

options may contain the common timeout and signal described under invoke(). A disabled plugin rejects with METHOD_NOT_FOUND; channel and timeout failures can also reject.

app.getInfo()Read the installed app identity

Signature: app.getInfo(options?) → Promise<AppInfo>. No native parameters or permissions.

Result: { appId: string, name: string, version: string, versionCode: number }, from the koru.json used to build this APK.

import { app } from "/__koru/core.mjs";
const info = await app.getInfo();
document.querySelector("#version").textContent = `${info.name} ${info.version}`;

The object describes the packaged app. It does not query an app store or available updates.

Private storage

Storage uses the app's private Android SharedPreferences. Keys are 1–128 characters from A–Z, a–z, 0–9, _, ., : and -; values are strings. Data persists across app restarts and updates signed with the same identity. It is removed when the app is uninstalled. Do not put secrets in a web page or assume storage is encrypted.

storage.get(key)Read a string or null

Signature: storage.get(key: string, options?) → Promise<string | null>.

Arguments: key is a valid storage key. Result: the stored string, or null when the key has never been set or was removed.

import { storage } from "/__koru/core.mjs";
const theme = (await storage.get("theme")) ?? "system";
console.log(theme);

A missing, wrong-type or invalid key rejects with INVALID_REQUEST. This wrapper unwraps the native { value } object for you.

storage.set(key, value)Persist a string

Signature: storage.set(key: string, value: string, options?) → Promise<{}>.

Arguments: a valid key and a string value. The operation uses a synchronous Android commit before the Promise resolves. Result: an empty object {}.

import { storage } from "/__koru/core.mjs";
await storage.set("theme", "dark");
await storage.set("visits", String(3)); // convert numbers to strings

Invalid key/value types reject with INVALID_REQUEST; an unsuccessful commit rejects with STORAGE_ERROR.

storage.remove(key)Delete one key

Signature: storage.remove(key: string, options?) → Promise<{}>. Result: an empty object after Android commits the removal. Removing a key that is absent is safe.

import { storage } from "/__koru/core.mjs";
await storage.remove("theme");
console.log(await storage.get("theme")); // null

Invalid keys reject with INVALID_REQUEST; a failed commit rejects with STORAGE_ERROR.

storage.clear()Delete all koru storage keys

Signature: storage.clear(options?) → Promise<{}>. It clears all keys in koru's private storage namespace for this app, then resolves with {}. It does not erase packaged web assets or unrelated Android app data.

import { storage } from "/__koru/core.mjs";
await storage.clear();

A failed commit rejects with STORAGE_ERROR. Use this only when the user intends to clear every key.

Device actions

vibration.vibrate(duration?)Vibrate for milliseconds

Setup: enable vibration in koru.json; koru adds android.permission.VIBRATE to the APK.

Signature: vibration.vibrate(duration = 100, options?) → Promise<{}>. duration must be an integer from 1 to 10,000 milliseconds. The Promise resolves with {} after starting the Android action; it does not wait for the vibration to finish.

import { vibration } from "/__koru/core.mjs";
await vibration.vibrate(150);

A wrong duration rejects with INVALID_REQUEST. Hardware or Android failures may reject with NATIVE_ERROR; a device without a vibrator cannot provide tactile feedback.

share.text(text)Open Android's share sheet

Setup: enable share in koru.json. No added permission.

Signature: share.text(text: string, options?) → Promise<{}>. text is the string to share. Koru opens an Android ACTION_SEND chooser with MIME type text/plain and resolves with {} after launching it. Resolution does not mean the user selected a destination or completed a share.

import { share } from "/__koru/core.mjs";
await share.text("Try my koru app");

Non-string text rejects with INVALID_REQUEST; failure to open the chooser may reject with NATIVE_ERROR.

Local notifications

Enable notifications in koru.json and rebuild. Koru adds android.permission.POST_NOTIFICATIONS. Android 13/API 33+ may require a runtime decision; older supported Android versions use the system notification setting. The API 33+ dialog has not been exercised in the recorded API 30 matrix. Notifications are immediate, local, and use koru's koru-local channel; this API does not schedule future alarms or remote push.

notifications.permission()Read notification status

Signature: notifications.permission(options?) → Promise<{ status: "granted" | "denied" }>.

import { notifications } from "/__koru/core.mjs";
const { status } = await notifications.permission();
console.log(status); // "granted" or "denied"

This reads the current Android permission and notification setting. It does not show a prompt. A disabled plugin rejects with METHOD_NOT_FOUND.

notifications.requestPermission()Ask for notification access

Signature: notifications.requestPermission(options?) → Promise<{ status: "granted" | "denied" }>.

On Android 13/API 33+, a denied status opens the runtime permission prompt. On Android 8–12/API 26–32, it opens app notification settings and resolves when the user returns. If access was already granted, it resolves immediately. The wrapper's default timeout is 120,000 ms for this method; options.timeout can override it up to that limit. Only one request can be pending.

import { notifications } from "/__koru/core.mjs";
const { status } = await notifications.requestPermission();
if (status === "granted") console.log("Notifications enabled");

BUSY means another request is pending; TIMEOUT means the decision did not arrive in time. A returned denied status is a normal result, not a thrown error.

notifications.show({ id, title, body })Post a notification now

Signature: notifications.show({ id: number, title: string, body: string }, options?) → Promise<{}>.

id is an integer notification identifier; use the same ID to replace or cancel a notification. The native default is 1 if id is omitted, but passing it explicitly makes later cancellation clear. title and body are required strings. The Promise resolves with {} after posting the notification, not after the user opens it.

import { notifications } from "/__koru/core.mjs";
const { status } = await notifications.permission();
if (status === "granted") {
  await notifications.show({
    id: 7,
    title: "Ready",
    body: "Your export finished.",
  });
}

If notifications are disabled or the Android 13+ permission is absent, it rejects with PERMISSION_DENIED. Wrong parameter types reject with INVALID_REQUEST.

notifications.cancel(id)Remove one notification

Signature: notifications.cancel(id: number, options?) → Promise<{}>. Pass the same integer ID used for show; it resolves with {} after asking Android to cancel that notification. An absent ID is safe to cancel. No permission prompt is shown.

import { notifications } from "/__koru/core.mjs";
await notifications.cancel(7);

A non-integer ID rejects with INVALID_REQUEST. A native Android failure may reject with NATIVE_ERROR.

Scheduled alarms

Enable alarms (and, for Android 13+, notifications) in koru.json and rebuild. Koru adds android.permission.POST_NOTIFICATIONS and android.permission.RECEIVE_BOOT_COMPLETED. These plugins have been tested in Chromium under koru dev (web versions) and their bytecode passes the koru build verifier; they have not been tested on an Android device.

alarms.schedule({ id, title, body, at })Show a notification later

Signature: alarms.schedule({ id, title, body, at }, options?) → Promise<{}>; alarms.cancel(id) → Promise<{}>; alarms.list() → Promise<string[]>, the IDs still pending. at is a Date or milliseconds since the epoch. Scheduling an id that already exists replaces the earlier alarm.

import { alarms, notifications } from "/__koru/core.mjs";
await notifications.requestPermission(); // needed on Android 13+
await alarms.schedule({
  id: "tea",
  title: "Tea",
  body: "Your tea is ready.",
  at: Date.now() + 60_000,
});
console.log(await alarms.list()); // ["tea"]
await alarms.cancel("tea");

On Android koru uses AlarmManager.setAndAllowWhileIdle. The alarm is inexact: it can arrive a few minutes late, especially at rest or in Doze. Exact alarms are not used because they require a permission the user grants in Settings. Alarms are stored and rescheduled after the phone restarts or the app is updated (BOOT_COMPLETED, MY_PACKAGE_REPLACED); those that came due while the phone was off are shown at boot. Tapping the notification opens the app. To be displayed on Android 13+ the notification permission must be granted: enable notifications too and call notifications.requestPermission().

In a browser and under koru dev, alarms fire only while the page is open (setTimeout plus Notification if permission was granted). A missing title, body or at rejects with INVALID_REQUEST.

Enable browser. No permissions.

browser.open(url)Open a link in another app

Signature: browser.open(url: string, options?) → Promise<{}>. It opens the link in another app: a browser, mail, the dialer and so on.

import { browser } from "/__koru/core.mjs";
await browser.open("https://koru.js.org");
await browser.open("mailto:hello@example.com");

Allowed schemes: https, http, mailto, tel, sms, geo and market. Any other scheme rejects with INVALID_REQUEST. If no app can open the link it rejects with NATIVE_ERROR. In a browser it uses window.open. With server in koru.json, external links already open this way on their own; see Remote content.

Keep the screen on

Enable screen. No permissions.

screen.keepAwake(enabled = true)Stop the screen from turning off

Signature: screen.keepAwake(enabled = true, options?) → Promise<{}>. It keeps the screen on while the app is visible (FLAG_KEEP_SCREEN_ON); keepAwake(false) lets it turn off again.

import { screen } from "/__koru/core.mjs";
await screen.keepAwake(); // e.g. while showing a recipe
await screen.keepAwake(false);

In a browser it uses the Screen Wake Lock API when it exists; otherwise it rejects with UNAVAILABLE.

Clipboard

Enable clipboard. No permissions.

clipboard.writeText(text)Copy and paste text

Signature: clipboard.writeText(text: string, options?) → Promise<{}>; clipboard.readText(options?) → Promise<string | null>, which is null when the clipboard is empty.

import { clipboard } from "/__koru/core.mjs";
await clipboard.writeText("hello");
const text = await clipboard.readText(); // "hello", or null if empty

From Android 10 only the app in the foreground can read the clipboard. In a browser it uses navigator.clipboard, which may ask for permission or reject with UNAVAILABLE.

Low-level bridge and errors

invoke(method, params?, options?)Call a native handler directly

Signature: invoke(method: string, params = {}, options = {}) → Promise<object>. It is the exported function in /__koru/core.mjs; window.Koru.invoke is the equivalent global. The method is a string such as "device.info"; params must be a JSON-serializable plain object. Use this for a custom plugin or when you need the unwrapped native result.

import { invoke } from "/__koru/core.mjs";
const info = await invoke("device.info", {});
const raw = await invoke("storage.get", { key: "theme" });
console.log(info.model, raw.value); // raw.value is string or null

options.timeout is a finite number of milliseconds from 1 to 120,000; default 10,000. The timer also covers waiting for the native channel. options.signal accepts an AbortSignal; a cancelled request rejects with CANCELLED. Cancellation prevents an unsent request, but cannot undo a native action already dispatched. At most 256 calls can be pending. A request's serialized JSON must stay below 65,536 characters.

Result: the native result object. Built-in action methods return {}; the wrapper storage.get() converts the raw { value } result into a string or null. Custom handlers define their own result object.

Errors reject with an Error whose code and message can be displayed or logged. INVALID_REQUEST means invalid method/arguments; METHOD_NOT_FOUND means the method or plugin is unavailable; STORAGE_ERROR means a storage commit failed; PERMISSION_DENIED means a required notification permission or setting is off; NATIVE_ERROR means Android threw an error (for example, browser.open found no app for the link); UNAVAILABLE means the browser has no equivalent (web implementations only). BUSY, TIMEOUT, CANCELLED, CHANNEL_ERROR, CHANNEL_REPLACED, INVALID_RESPONSE and PAGE_CLOSED describe the bridge lifecycle. A timeout or cancellation does not prove an already dispatched native action was rolled back.

Extend your app

To add your own features, follow the step-by-step guide Write a plugin, or see the native adapter and the custom plugin reference.

Configuration · Troubleshooting