Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/mobile/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,18 @@
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-import-type-side-effects": "off",
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/no-deprecated": [
"error",
{
"allow": [
{
"from": "package",
"package": "react-native-gesture-handler",
"name": ["Gesture", "Pan"]
}
]
}
],
"@typescript-eslint/no-namespace": "off",
"import/no-namespace": "off",
"oxc/no-rest-spread-properties": "off",
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Expo Router app for iOS and Android only. Use dev builds, never Expo Go. No web-

## Stack

- Expo SDK 57, React Native 0.86, React 19, strict TypeScript (`tsgo`)
- Expo SDK 58, React Native 0.88, React 19.3, strict TypeScript (`tsgo`)
- NativeWind v5 / Tailwind CSS v4; React Native Reusables in `src/components/ui/`
- Expo Router routes in `src/app/`
- oxlint and oxfmt
Expand Down Expand Up @@ -52,10 +52,10 @@ git diff --check
Before you build a UI element, find the library that already does it.

1. Check the element map below. If the concern has an entry, use that element.
2. Check `@expo/ui` (`node_modules/@expo/ui/build/`), which ships native modules for `57.x`: universal
2. Check `@expo/ui` (`node_modules/@expo/ui/build/`), which ships native modules for `58.x`: universal
`BottomSheet`, `Picker`, `Switch`, `TextInput`, `List`, `Host`, plus
`community/{segmented-control, picker, datetime-picker, masked-view, menu, pager-view, slider}`.
3. Check the Expo SDK 57 docs for the concern.
3. Check the Expo SDK 58 docs for the concern.
4. Check npm for a maintained package. A release in the last six months is the bar.

A hand-built element is correct only when steps 1–4 fail. Record the decision in the PR body: the library
Expand Down
32 changes: 29 additions & 3 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@ Humans: follow instructions below or talk to [@iscekic](https://github.com/iscek

Generally speaking, you only need a new dev build if making dependency/native changes.

The app uses Expo SDK 58, React Native 0.88 and React 19.3. Align SDK-managed
dependencies with the selected release's `bundledNativeModules.json`, and retain
the workspace release-age policy when resolving upgrades. Existing development-tool
exemptions remain separate from the SDK's release eligibility.

Native compatibility requirements:

- Keep versioned dependency patches and their lockfile hashes together. Regenerate
them against pristine package sources, and rebuild both development clients.
- Android builds the patched background-task, notifications and task-manager modules
from source, together with task-manager's app loader.
- `android.uniquePackageNames=false` retains compatibility with AppsFlyer's SDK and
purchase connector, which share a namespace. AGP 9 otherwise rejects that vendor pair.
- The supported Gesture Handler builder API remains explicitly allowed by the
deprecation lint rule; other deprecated APIs remain checked.
- The `react-native-css` patch forwards React Native's native component registry
through its CommonJS facade for Router's native screen host.
- The `llama.rn` iOS patch installs JSI bindings through React Native's TurboModule
runtime hook and conforms to the generated native spec.
- The iOS App Actions bridge uses `@JS` for Expo's noncopyable JavaScript values.
Its runtime-owned reference releases the dispatcher on the JavaScript thread.
- Root React types stay aligned with the root/web runtime; mobile declares its own
SDK-matched types. Keep both explicit so cached dependency hoisting cannot mix
the web and native JSX type identities.
- Deferred cache maintenance keeps a 500 ms timer fallback alongside idle work.
The first delivery cancels the other so invalidation runs once during sustained JS work.

Native permission translations live in `plugins/permission-prompt-copy.json` for all 87 supported languages.
The camera and photo library prompts explain AI agent attachments and include specific examples.
Background location descriptions remain disabled because the app does not request background location.
Expand Down Expand Up @@ -43,21 +70,20 @@ Changes require a new iOS build; an over-the-air update cannot change the native
#### iPhone Duo and iOS 27

- Use Xcode 27.1 or later to test native iPhone Duo layouts.
- Keep `expo-build-properties.ios.enableSceneSupport` enabled in `app.config.ts` while the app uses Expo SDK 57.
- Expo SDK 58 includes scene lifecycle support in its template; no scene-support opt-in is needed.
- Expo's scene delegate creates the window and forwards lifecycle events and links to the app delegate.
- After a native config change, regenerate the iOS project and rebuild the app.
- Do not edit the generated `ios/` files.
- Sign local simulator builds with an Apple Development identity and its correct team ID.
- Unsigned builds cannot restore the local account from the keychain on this simulator.
- Check both displays, all three fold poses, and each orientation after the rebuild.
- When the app moves to Expo SDK 58, remove the scene support option; its template includes scene support.

#### Widget layout checks

- Home widgets keep a 16pt band on all four sides, including the updating placeholder.
- Check the medium placeholder on a 164pt-tall card: its header, bars and footer must fit within 132pt or SwiftUI centres the overflow into the padding.
- Arabic Live Activity direction comes from `homeWidgetLocale` in the shared app group, not the simulator language.
- The Live Activity direction override is native code in `patches/expo-widgets@57.0.22.patch`. Rebuild the iOS extension after changing the patch; restarting Metro alone leaves the installed extension unchanged.
- The Live Activity direction override is native code in `patches/expo-widgets@58.0.17.patch`. Rebuild the iOS extension after changing the patch; restarting Metro alone leaves the installed extension unchanged.
- After switching the app language, reopen the development client on Metro before applying a fixture and capturing widgets.

#### Responsive layout checks
Expand Down
9 changes: 1 addition & 8 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,18 +301,11 @@ const config: ExpoConfig = {
},
ios: {
ccacheEnabled: true,
// SDK 27 requires the scene lifecycle, including scene-based window and link handling.
enableSceneSupport: true,
// iOS consumes React Native Core prebuilt by default, so the pnpm patch
// over RCTComponentViewFactory.mm would never compile into the app.
// The Expo Podfile maps this to ENV['RCT_USE_PREBUILT_RNCORE'] = '0'
// (KILO-APP-6H; react/react-native#58299).
// Compile React Native from the patched source instead of linking the
// prebuilt core. The App Store core ships with assertions enabled
// (react/react-native#57454), so the Fabric unmount assert must carry
// the bounds guard from react/react-native#57865 or a stale child
// index SIGABRTs release builds (Kilo-Org/kilocode#14065; see
// patches/react-native@0.86.3.patch).
// Compile the registration and mounting guards in patches/react-native@0.88.0.patch.
buildReactNativeFromSource: true,
// GoogleSignIn is a Swift static lib that imports GoogleUtilities/RecaptchaInterop
// (pulled transitively alongside expo-iap's AppCheckCore); those pods don't define
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import expo.modules.kotlin.jni.JavaScriptFunction
import expo.modules.kotlin.jni.JavaScriptValue
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.types.ConverterContext

/**
* The Android half of the shared app-action contract.
Expand All @@ -26,7 +27,7 @@ class KiloAppActionsModule : Module() {
* not lost. JS runs each returned payload through the same dispatcher.
*/
Function("registerAppActionDispatcher") { handler: JavaScriptFunction<JavaScriptValue> ->
AppActionDispatcher.register(handler) { block ->
AppActionDispatcher.register(handler, appContext) { block ->
// A JSI function may only be invoked on the JS thread, and an entry point
// calls in from the main thread. `appContext.reactContext` is typed as a
// plain Android `Context`, so the hop goes through the runtime's
Expand Down Expand Up @@ -67,6 +68,7 @@ internal object AppActionDispatcher {
/** The registered JS dispatcher and the hop that puts a call on its thread. */
private class Registration(
val handler: JavaScriptFunction<JavaScriptValue>,
val converterContext: ConverterContext,
val queue: (() -> Unit) -> Unit
)

Expand All @@ -79,9 +81,10 @@ internal object AppActionDispatcher {
/** Stores the JS dispatcher and returns the payloads that arrived first. */
fun register(
next: JavaScriptFunction<JavaScriptValue>,
converterContext: ConverterContext,
queue: (() -> Unit) -> Unit
): List<String> = synchronized(lock) {
registration = Registration(next, queue)
registration = Registration(next, converterContext, queue)
val pending = buffered.toList()
buffered.clear()
pending
Expand All @@ -107,7 +110,7 @@ internal object AppActionDispatcher {
// captures to Sentry), so a throw here only means this payload produced no
// result for the caller.
runCatching {
val returned = active.handler(payload)
val returned = active.handler(payload, converterContext = active.converterContext)
if (returned.isString()) {
complete(payload, returned.getString())
}
Expand Down
97 changes: 0 additions & 97 deletions apps/mobile/modules/kilo-app-actions/ios-app-intents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ function readSource(name: string): string {
}

const intents = readSource('KiloAppIntents.swift');
const bridge = readSource('KiloAppActionBridge.swift');
const actionsModule = readSource('KiloAppActionsModule.swift');
const podspec = readSource('KiloAppActions.podspec');
const configSource = readFileSync(
fileURLToPath(new URL('../../app.config.ts', import.meta.url)),
'utf8'
Expand Down Expand Up @@ -240,100 +237,6 @@ describe('the Swift action URLs', () => {
});
});

describe('the native handshake', () => {
it('registers the JS dispatcher under the module name the JS side requires', () => {
// A synchronous `Function` runs on the JS thread. An `AsyncFunction` runs on
// a background queue, where replacing the dispatcher crashed (KILO-APP-3JG).
expect(actionsModule).toContain('Function("registerAppActionDispatcher")');
expect(actionsModule).not.toContain('AsyncFunction("registerAppActionDispatcher")');
expect(actionsModule).toContain('JavaScriptActor.assumeIsolated');
expect(actionsModule).toContain('Name("KiloAppActions")');
expect(actionsModule).toContain('KiloAppActionBridge.shared');
expect(bridge).toContain('static let shared = KiloAppActionBridge()');
});

it('bounds the wait for registration and reports a failure', () => {
expect(bridge).toContain('waitUntilRegistered');
expect(bridge).toMatch(/registrationTimeout: TimeInterval = 25/);
expect(bridge).toContain('KiloAppActionError.dispatcherUnavailable');
expect(bridge).toContain('func perform(payload: [String: String]) async throws -> String');
});

it('resumes a parked wait from register instead of polling for one', () => {
// The old wait re-checked `registered()` and slept 50 ms in a loop for up to
// 25 s — up to 500 wakeups for a value `register(...)` can hand over. The
// bridge now parks each waiting run's continuation and resumes it from the
// registration; one deadline task is the only sleep left.
expect(bridge, 'the poll interval is gone').not.toContain('registrationPollInterval');
expect(bridge, 'no continuation store').toContain(
'waiters: [UUID: CheckedContinuation<DispatcherRegistration, Error>]'
);

const wait = swiftBlock(bridge, 'func waitUntilRegistered');
expect(wait).toContain('withCheckedThrowingContinuation');
expect(wait, 'the wait must not sleep in a loop').not.toContain('Task.sleep');

// `register(...)` stores the registration and hands it to every parked waiter.
const register = swiftBlock(bridge, 'func register(');
expect(register).toMatch(/waiters = \[:\]/);
expect(register).toContain('.resume(returning: next)');

// The one deadline task fails the waiter that outlived registration.
expect([...bridge.matchAll(/Task\.sleep/g)], 'one sleep, the deadline').toHaveLength(1);
const deadline = swiftBlock(bridge, 'func startDeadline');
expect(deadline).toContain('Task.sleep');
expect(deadline).toContain('failWaiter(id, seconds: Int(timeout))');
expect(swiftBlock(bridge, 'func failWaiter')).toContain(
'KiloAppActionError.dispatcherUnavailable(seconds: seconds)'
);
});

it('marks the throwing runtime lookup with try', () => {
// `AppContext.runtime` is a throwing property: it raises `RuntimeLost` until
// the runtime exists, so an unmarked read stops the pod compiling. The ios
// job failed on exactly that — `KiloAppActionsModule.swift:19:27: error:
// property access can throw but is not marked with 'try'` — and this guard
// is where the module reads it.
const reads = actionsModule
.split('\n')
.filter(line => /appContext\?\.runtime|appContext\.runtime/.test(line));
expect(reads.length, 'the module no longer reads the runtime').toBeGreaterThan(0);
for (const line of reads) {
expect(line, `${line.trim()} reads a throwing property`).toMatch(/\btry\b/);
}
});

it('lets the runtime teardown release the dispatcher on the JS thread', () => {
// The singleton outlives the runtime. On a reload (the RTL language switch),
// the module's `OnDestroy` runs off the JS thread, maybe after the runtime is
// freed, so it must not release a JSI value (KILO-APP-3JG). The runtime's
// long-lived objects own the dispatcher, and their teardown sweep releases it
// on the JS thread while the runtime is still valid.
expect(actionsModule).not.toContain('OnDestroy');
expect(bridge).toContain('private final class DispatcherRegistration: LongLivedObject');
expect(swiftBlock(bridge, 'func register(')).toContain('runtime.longLivedObjects.add(next)');
const release = swiftBlock(bridge, 'func allowRelease()');
expect(release).toContain('dispatcher = nil');
expect(release).toContain('KiloAppActionBridge.shared.unregister(self)');
// A later runtime's registration survives the earlier runtime's sweep.
expect(swiftBlock(bridge, 'func unregister(')).toContain('guard registration === released');
});

it('reads the result fields the JS contract answers with', () => {
// The `AppActionResult` fields of src/lib/app-actions/app-action-contract.ts:
// a renamed field here would silently turn every run into a malformed result.
for (const field of ['ok', 'sessionId', 'message', 'retryable']) {
expect(bridge, `${field} is not read from the answer`).toContain(`getProperty("${field}")`);
}
});

it('ships the pod the module config autolinks', () => {
expect(podspec).toContain("s.name = 'KiloAppActions'");
expect(podspec).toContain("s.platforms = { :ios => '16.4' }");
expect(podspec).toContain("s.source_files = '**/*.swift'");
});
});

describe('the App Intent copy', () => {
it('holds exactly the keys the plugin renders', () => {
expect(Object.keys(ENGLISH_COPY).toSorted()).toEqual([...INTENT_COPY_KEYS].toSorted());
Expand Down
28 changes: 15 additions & 13 deletions apps/mobile/modules/kilo-app-actions/ios/KiloAppActionBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ final class KiloAppActionBridge: @unchecked Sendable {
/// waiting. A late registration resumes its callers directly instead of them
/// re-checking on a timer.
///
/// Runs on the JS thread of `runtime` (the module's synchronous `Function`),
/// Runs on the JS thread of `runtime` (the module's synchronous `@JS` method),
/// because it adds the registration to `runtime.longLivedObjects` and can
/// release a replaced dispatcher of the same runtime. A replaced registration
/// of an earlier runtime is not released here: that runtime's teardown sweep
/// owns it, and on reload it already ran, before this runtime started.
@JavaScriptActor
func register(dispatcher: JavaScriptValue, runtime: JavaScriptRuntime) {
func register(dispatcher: consuming JavaScriptValue, runtime: JavaScriptRuntime) {
let next = DispatcherRegistration(dispatcher: dispatcher, runtime: runtime)
runtime.longLivedObjects.add(next)
lock.lock()
Expand Down Expand Up @@ -209,7 +209,7 @@ final class KiloAppActionBridge: @unchecked Sendable {
/// The dispatcher may answer with the result or with a promise of it; both
/// are accepted, and a rejected promise propagates its JS error.
@JavaScriptActor
private static func resolved(_ answer: JavaScriptValue) async throws -> JavaScriptValue {
private static func resolved(_ answer: consuming JavaScriptValue) async throws -> JavaScriptValue {
do {
return try await answer.asPromise().await()
} catch is JavaScriptValue.TypeError {
Expand All @@ -220,7 +220,8 @@ final class KiloAppActionBridge: @unchecked Sendable {

/// The caller-facing result: the session id a `StartAgent` run created.
/// A refusal throws with the pipeline's message.
private static func result(from answer: JavaScriptValue) throws -> String {
@JavaScriptActor
private static func result(from answer: borrowing JavaScriptValue) throws -> String {
let decoded = try decode(answer)
guard let ok = decoded.ok else {
throw KiloAppActionError.malformedResult
Expand All @@ -242,7 +243,8 @@ final class KiloAppActionBridge: @unchecked Sendable {

/// Reads the answer as the `AppActionResult` object, or as its JSON text —
/// the two forms the contract accepts.
private static func decode(_ answer: JavaScriptValue) throws -> Answer {
@JavaScriptActor
private static func decode(_ answer: borrowing JavaScriptValue) throws -> Answer {
if answer.isString() {
guard let data = answer.getString().data(using: .utf8) else {
throw KiloAppActionError.malformedResult
Expand Down Expand Up @@ -291,29 +293,29 @@ final class KiloAppActionBridge: @unchecked Sendable {
@JavaScriptActor
private final class DispatcherRegistration: LongLivedObject {
nonisolated let runtime: JavaScriptRuntime
private var dispatcher: JavaScriptValue?
private let dispatcher: JavaScriptValue.Ref

init(dispatcher: JavaScriptValue, runtime: JavaScriptRuntime) {
self.dispatcher = dispatcher
init(dispatcher: consuming JavaScriptValue, runtime: JavaScriptRuntime) {
self.dispatcher = dispatcher.ref()
self.runtime = runtime
}

/// Calls the dispatcher with one payload. The dispatcher reference is not
/// kept past this call, so a teardown while the answer is pending still
/// releases the dispatcher on time.
func call(payload: [String: String]) throws -> JavaScriptValue {
guard let dispatcher else {
guard let answer = try dispatcher.withUnwrappedValue({ (dispatcher: borrowing JavaScriptValue) in
try dispatcher.getFunction().call(arguments: payload)
}) else {
throw KiloAppActionError.dispatcherUnavailable(
seconds: Int(KiloAppActionBridge.registrationTimeout)
)
}
// `JavaScriptValue` only holds the reference; the callable form is its
// function view. Same idiom as ExpoModulesJSI's own promise resolvers.
return try dispatcher.getFunction().call(arguments: payload)
return answer
}

func allowRelease() {
dispatcher = nil
dispatcher.release()
KiloAppActionBridge.shared.unregister(self)
}
}
Loading
Loading