Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Mob.Notify.register_push(socket)
def handle_info({:push_token, :ios, token}, socket), do: ...
```

Also: `Mob.Clipboard`, `Mob.Share`, `Mob.Photos`, `Mob.Files`, `Mob.Audio`, `Mob.Motion`, `Mob.Biometric`, `Mob.Scanner`, `Mob.Permissions`.
Also: `Mob.Clipboard`, `Mob.Share`, `Mob.Photos`, `Mob.Files`, `Mob.Audio`, `Mob.IOS.FoundationModels`, `Mob.IOS.Vision`, `Mob.IOS.Speech`, `Mob.Motion`, `Mob.Biometric`, `Mob.Scanner`, `Mob.Permissions`.

## What's in the box

Expand All @@ -156,6 +156,7 @@ The pre-built OTP runtime that ships with each app includes:

Native APIs surfaced via `Mob.*` modules (above) cover camera,
location, audio, files, biometrics, push, clipboard, share, scanner,
Foundation Models, Vision, Speech,
motion sensors, permissions.

The OTP runtime tarball is ~80 MB compressed; sliced per-arch by
Expand Down
33 changes: 33 additions & 0 deletions android/jni/mob_nif.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2433,6 +2433,36 @@ export fn nif_audio_set_volume(
return erts.ok(env);
}

export fn nif_foundation_models_generate_text(
env: ?*erts.ErlNifEnv,
argc: c_int,
argv: [*]const erts.ERL_NIF_TERM,
) callconv(.c) erts.ERL_NIF_TERM {
_ = argc;
_ = argv;
return erts.atom(env, "unsupported");
}

export fn nif_vision_recognize_text(
env: ?*erts.ErlNifEnv,
argc: c_int,
argv: [*]const erts.ERL_NIF_TERM,
) callconv(.c) erts.ERL_NIF_TERM {
_ = argc;
_ = argv;
return erts.atom(env, "unsupported");
}

export fn nif_speech_transcribe_audio(
env: ?*erts.ErlNifEnv,
argc: c_int,
argv: [*]const erts.ERL_NIF_TERM,
) callconv(.c) erts.ERL_NIF_TERM {
_ = argc;
_ = argv;
return erts.atom(env, "unsupported");
}

export fn nif_motion_start(
env: ?*erts.ErlNifEnv,
argc: c_int,
Expand Down Expand Up @@ -3273,6 +3303,9 @@ const nif_funcs = [_]erts.ErlNifFunc{
.{ .name = "audio_play", .arity = 2, .fptr = nif_audio_play, .flags = 0 },
.{ .name = "audio_stop_playback", .arity = 0, .fptr = nif_audio_stop_playback, .flags = 0 },
.{ .name = "audio_set_volume", .arity = 1, .fptr = nif_audio_set_volume, .flags = 0 },
.{ .name = "foundation_models_generate_text", .arity = 2, .fptr = nif_foundation_models_generate_text, .flags = 0 },
.{ .name = "vision_recognize_text", .arity = 2, .fptr = nif_vision_recognize_text, .flags = 0 },
.{ .name = "speech_transcribe_audio", .arity = 2, .fptr = nif_speech_transcribe_audio, .flags = 0 },
.{ .name = "motion_start", .arity = 2, .fptr = nif_motion_start, .flags = 0 },
.{ .name = "motion_stop", .arity = 0, .fptr = nif_motion_stop, .flags = 0 },
.{ .name = "scanner_scan", .arity = 1, .fptr = nif_scanner_scan, .flags = 0 },
Expand Down
147 changes: 147 additions & 0 deletions guides/device_capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,153 @@ end

iOS uses `AVAudioPlayer` / `AVPlayer`. Android uses `MediaPlayer`.

## iOS Foundation Models

Generates text with Apple's on-device Foundation Models framework.
Requires an eligible physical device with Apple Intelligence enabled; the iOS
simulator reports this capability as unavailable.

Apple docs:
[Foundation Models](https://developer.apple.com/documentation/foundationmodels) and
[Adding intelligent app features with generative models](https://developer.apple.com/documentation/foundationmodels/adding-intelligent-app-features-with-generative-models).

```elixir
socket =
Mob.IOS.FoundationModels.generate_text(socket, "Turn this note into a short action list",
instructions: "Return compact plain text.",
temperature: 0.2,
maximum_response_tokens: 240
)

def handle_info({:foundation_models, :generated_text, %{text: text}}, socket) do
{:noreply, Mob.Socket.assign(socket, :result, text)}
end

def handle_info({:foundation_models, :error, %{reason: reason}}, socket) do
{:noreply, Mob.Socket.assign(socket, :error, reason)}
end
```

## iOS Vision text recognition

Recognizes text in a local image file with Apple's Vision framework. Combine
with `Mob.Photos.pick/2` to OCR a user-selected image.

Apple docs:
[VNRecognizeTextRequest](https://developer.apple.com/documentation/vision/vnrecognizetextrequest).

```elixir
socket =
Mob.Photos.pick(socket, max: 1, types: [:image])

def handle_info({:photos, :picked, [%{path: path} | _]}, socket) do
socket =
Mob.IOS.Vision.recognize_text(socket, path,
recognition_level: :accurate,
uses_language_correction: true
)

{:noreply, socket}
end

def handle_info({:vision, :recognized_text, %{text: text}}, socket) do
{:noreply, Mob.Socket.assign(socket, :ocr_text, text)}
end
```

## iOS Speech transcription

Transcribes an existing audio file with Apple's Speech framework. Use
`Mob.Audio` to record microphone input first, then transcribe the saved
recording path.

Apple docs:
[SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) and
[SFSpeechURLRecognitionRequest](https://developer.apple.com/documentation/speech/sfspeechurlrecognitionrequest).

```elixir
socket = Mob.Audio.start_recording(socket, format: :aac, quality: :high)
socket = Mob.Audio.stop_recording(socket)

def handle_info({:audio, :recorded, %{path: path}}, socket) do
socket =
Mob.IOS.Speech.transcribe_audio(socket, path,
locale: "en-US",
requires_on_device_recognition: false
)

{:noreply, socket}
end

def handle_info({:speech, :transcribed_audio, %{text: text}}, socket) do
{:noreply, Mob.Socket.assign(socket, :transcript, text)}
end
```

Speech recognition requires iOS speech authorization. If
`requires_on_device_recognition` is true, iOS may reject locales that do not
support local recognition.

### iOS native intelligence testing

| Capability | iOS simulator | Physical iPhone |
|---|---:|---:|
| `Mob.IOS.FoundationModels.generate_text/3` | No. The simulator does not provide the on-device system language model. | Yes, on Apple Intelligence-capable devices with Apple Intelligence enabled and the model ready. |
| `Mob.IOS.Vision.recognize_text/3` | Yes. Pass a readable image path in the simulator app container or pick a simulator photo. | Yes. |
| `Mob.IOS.Speech.transcribe_audio/3` | Usually yes for file transcription, subject to simulator Speech authorization and runtime locale/service availability. | Yes, subject to Speech authorization and locale support. |

The lightest simulator smoke test is:

1. Build a Mob app that exposes a screen with `Mob.Photos.pick/2` and calls
`Mob.IOS.Vision.recognize_text/3` with the selected photo path.
2. Add a photo with readable text to the simulator photo library, select it in
the picker, and confirm OCR returns the expected text.
3. Build two audio actions: one calls `Mob.Audio.start_recording/2`; the other
calls `Mob.Audio.stop_recording/1`. Pass the recorded file path from
`{:audio, :recorded, %{path: path}}` to `Mob.IOS.Speech.transcribe_audio/3`.
4. Confirm Foundation Models returns the expected simulator-unavailable error.

For path-based debugging in a simulator app that is already installed, the
useful setup commands are:

```sh
SIM_ID="booted"
BUNDLE_ID="com.example.my_mob_app"
CONTAINER="$(xcrun simctl get_app_container "$SIM_ID" "$BUNDLE_ID" data)"

cp ./ocr_fixture.png "$CONTAINER/Documents/ocr_fixture.png"
```

Then pass the copied path to the app:

```elixir
image_path = "/path/from/xcrun/simctl/get_app_container/Documents/ocr_fixture.png"
Mob.IOS.Vision.recognize_text(socket, image_path)
```

For Speech, recording inside the Mob app is the most representative smoke test:

```elixir
socket = Mob.Audio.start_recording(socket)
socket = Mob.Audio.stop_recording(socket)

def handle_info({:audio, :recorded, %{path: path}}, socket) do
{:noreply, Mob.IOS.Speech.transcribe_audio(socket, path)}
end
```

### Scope and follow-up ideas

This first bridge includes plain Foundation Models text generation, Vision OCR
from a local image path, and Speech transcription from a local audio file.

Not included yet: Foundation Models structured generation with `@Generable`,
streaming partial Foundation Models responses, tool calling, multi-turn session
persistence, Vision requests beyond text recognition, live Speech recognition,
custom Speech language models, Natural Language framework features, image
generation, Private Cloud Compute-backed server features, and Android ML Kit or
platform-equivalent implementations.

## Location

Requires `:location` permission.
Expand Down
91 changes: 91 additions & 0 deletions ios/MobFoundationModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation

#if canImport(FoundationModels)
import FoundationModels
#endif

@objcMembers
public final class MobFoundationModels: NSObject {
public static func generateText(
_ prompt: String,
optionsJSON: String,
completion: @escaping (String?, String?) -> Void
) {
#if targetEnvironment(simulator)
completion(nil, "Foundation Models does not run in the iOS simulator.")
#else
guard #available(iOS 26.0, *) else {
completion(nil, "Foundation Models requires iOS 26.0 or newer.")
return
}

#if canImport(FoundationModels)
let opts = decodeOptions(optionsJSON)
let instructions = opts["instructions"] as? String ?? ""
let temperature = opts["temperature"] as? Double ?? 0.2
let maximumResponseTokens = opts["maximum_response_tokens"] as? Int ?? 256

Task {
let model = SystemLanguageModel.default

guard model.supportsLocale(Locale.current) else {
completion(nil, "Foundation Models does not support the current locale: \(Locale.current.identifier).")
return
}

switch model.availability {
case .available:
do {
let session = instructions.isEmpty
? LanguageModelSession()
: LanguageModelSession(instructions: instructions)
let response = try await session.respond(
to: prompt,
options: GenerationOptions(
temperature: temperature,
maximumResponseTokens: maximumResponseTokens
)
)
completion(response.content, nil)
} catch {
completion(nil, "Foundation Models generation error: \(error.localizedDescription)")
}

case .unavailable(let reason):
completion(nil, availabilityMessage(reason))
}
}
#else
completion(nil, "This build of Xcode does not expose the FoundationModels module.")
#endif
#endif
}

private static func decodeOptions(_ optionsJSON: String) -> [String: Any] {
guard let data = optionsJSON.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dict = object as? [String: Any] else {
return [:]
}

return dict
}

#if canImport(FoundationModels)
@available(iOS 26.0, *)
private static func availabilityMessage(
_ reason: SystemLanguageModel.Availability.UnavailableReason
) -> String {
switch reason {
case .deviceNotEligible:
return "Foundation Models unavailable: this device is not eligible for Apple Intelligence."
case .appleIntelligenceNotEnabled:
return "Foundation Models unavailable: Apple Intelligence is not enabled in Settings."
case .modelNotReady:
return "Foundation Models unavailable: the on-device model is not ready yet."
@unknown default:
return "Foundation Models unavailable: \(String(describing: reason))."
}
}
#endif
}
Loading