@glassly/bluetooth-sdk 0.3.1-dev.496 → 0.3.1-dev.497

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,224 @@
1
+ import Foundation
2
+
3
+ #if canImport(Translation) && canImport(UIKit)
4
+ import SwiftUI
5
+ import Translation
6
+ import UIKit
7
+ #endif
8
+
9
+ enum OfflineTranslationError: LocalizedError {
10
+ case unavailable
11
+ case notInstalled(String)
12
+ case noPresenter
13
+ case failed(String)
14
+
15
+ /// Leading token is the stable code the JS side matches on.
16
+ var errorDescription: String? {
17
+ switch self {
18
+ case .unavailable: "unavailable: on-device translation requires iOS 26"
19
+ case let .notInstalled(pair): "not_installed: \(pair) is not downloaded"
20
+ case .noPresenter: "no_presenter: no visible window to present the download prompt"
21
+ case let .failed(message): "failed: \(message)"
22
+ }
23
+ }
24
+ }
25
+
26
+ /// On-device text translation backed by Apple's Translation framework.
27
+ /// Sessions are created without UI (`TranslationSession(installedSource:target:)`,
28
+ /// iOS 26), so a pair must be installed before it can translate; installing
29
+ /// goes through the system download prompt in `download`.
30
+ enum OfflineTranslator {
31
+ static var isSupported: Bool {
32
+ #if canImport(Translation) && canImport(UIKit)
33
+ if #available(iOS 26.0, *) {
34
+ return true
35
+ }
36
+ #endif
37
+ return false
38
+ }
39
+
40
+ /// Every language the system can translate `source` into, with its install state.
41
+ static func languages(source: String) async -> [[String: String]] {
42
+ #if canImport(Translation) && canImport(UIKit)
43
+ if #available(iOS 26.0, *) {
44
+ let availability = makeAvailability()
45
+ let from = Locale.Language(identifier: source)
46
+ var rows: [[String: String]] = []
47
+ for language in await availability.supportedLanguages {
48
+ let code = language.minimalIdentifier
49
+ let status = await availability.status(from: from, to: language)
50
+ if status == .unsupported {
51
+ continue
52
+ }
53
+ rows.append([
54
+ "code": code,
55
+ "name": Locale.current.localizedString(forIdentifier: code) ?? code,
56
+ "status": statusName(status),
57
+ ])
58
+ }
59
+ return rows.sorted { ($0["name"] ?? "") < ($1["name"] ?? "") }
60
+ }
61
+ #endif
62
+ return []
63
+ }
64
+
65
+ static func status(source: String, target: String) async -> String {
66
+ #if canImport(Translation) && canImport(UIKit)
67
+ if #available(iOS 26.0, *) {
68
+ let status = await makeAvailability().status(
69
+ from: Locale.Language(identifier: source),
70
+ to: Locale.Language(identifier: target)
71
+ )
72
+ return statusName(status)
73
+ }
74
+ #endif
75
+ return "unavailable"
76
+ }
77
+
78
+ /// Shows the system prompt that downloads the pair, then reports its status.
79
+ static func download(source: String, target: String) async throws -> String {
80
+ #if canImport(Translation) && canImport(UIKit)
81
+ if #available(iOS 26.0, *) {
82
+ try await presentDownload(
83
+ source: Locale.Language(identifier: source),
84
+ target: Locale.Language(identifier: target)
85
+ )
86
+ await SessionStore.shared.drop(source: source, target: target)
87
+ return await status(source: source, target: target)
88
+ }
89
+ #endif
90
+ throw OfflineTranslationError.unavailable
91
+ }
92
+
93
+ static func translate(text: String, source: String, target: String) async throws -> String {
94
+ #if canImport(Translation) && canImport(UIKit)
95
+ if #available(iOS 26.0, *) {
96
+ return try await SessionStore.shared.translate(text: text, source: source, target: target)
97
+ }
98
+ #endif
99
+ throw OfflineTranslationError.unavailable
100
+ }
101
+ }
102
+
103
+ #if canImport(Translation) && canImport(UIKit)
104
+ @available(iOS 26.0, *)
105
+ private extension OfflineTranslator {
106
+ static func makeAvailability() -> LanguageAvailability {
107
+ // Low latency keeps the downloadable language-pack flow, which is
108
+ // what works with no network.
109
+ if #available(iOS 26.4, *) {
110
+ return LanguageAvailability(preferredStrategy: .lowLatency)
111
+ }
112
+ return LanguageAvailability()
113
+ }
114
+
115
+ static func statusName(_ status: LanguageAvailability.Status) -> String {
116
+ switch status {
117
+ case .installed: return "installed"
118
+ case .supported: return "supported"
119
+ case .unsupported: return "unsupported"
120
+ @unknown default: return "unsupported"
121
+ }
122
+ }
123
+
124
+ @MainActor
125
+ static func presentDownload(source: Locale.Language, target: Locale.Language) async throws {
126
+ guard let presenter = topViewController() else { throw OfflineTranslationError.noPresenter }
127
+ let configuration: TranslationSession.Configuration = if #available(iOS 26.4, *) {
128
+ .init(source: source, target: target, preferredStrategy: .lowLatency)
129
+ } else {
130
+ .init(source: source, target: target)
131
+ }
132
+
133
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
134
+ var host: UIViewController?
135
+ var finished = false
136
+ let view = DownloadHost(configuration: configuration) { result in
137
+ if finished {
138
+ return
139
+ }
140
+ finished = true
141
+ host?.willMove(toParent: nil)
142
+ host?.view.removeFromSuperview()
143
+ host?.removeFromParent()
144
+ continuation.resume(with: result)
145
+ }
146
+ let controller = UIHostingController(rootView: view)
147
+ controller.view.backgroundColor = .clear
148
+ controller.view.isUserInteractionEnabled = false
149
+ controller.view.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
150
+ presenter.addChild(controller)
151
+ presenter.view.addSubview(controller.view)
152
+ controller.didMove(toParent: presenter)
153
+ host = controller
154
+ }
155
+ }
156
+
157
+ @MainActor
158
+ static func topViewController() -> UIViewController? {
159
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
160
+ let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
161
+ let window = scene?.windows.first { $0.isKeyWindow } ?? scene?.windows.first
162
+ var top = window?.rootViewController
163
+ while let presented = top?.presentedViewController {
164
+ top = presented
165
+ }
166
+ return top
167
+ }
168
+ }
169
+
170
+ /// Invisible host: `.translationTask` is the only API that hands out a
171
+ /// session allowed to request downloads.
172
+ @available(iOS 26.0, *)
173
+ private struct DownloadHost: View {
174
+ let configuration: TranslationSession.Configuration
175
+ let onFinish: @MainActor (Result<Void, Error>) -> Void
176
+
177
+ var body: some View {
178
+ Color.clear.translationTask(configuration) { @MainActor session in
179
+ do {
180
+ try await session.prepareTranslation()
181
+ onFinish(.success(()))
182
+ } catch {
183
+ onFinish(.failure(OfflineTranslationError.failed(error.localizedDescription)))
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ @available(iOS 26.0, *)
190
+ private actor SessionStore {
191
+ static let shared = SessionStore()
192
+
193
+ private var sessions: [String: TranslationSession] = [:]
194
+
195
+ func drop(source: String, target: String) {
196
+ sessions.removeValue(forKey: "\(source)>\(target)")?.cancel()
197
+ }
198
+
199
+ func translate(text: String, source: String, target: String) async throws -> String {
200
+ let key = "\(source)>\(target)"
201
+ let session = sessions[key] ?? makeSession(source: source, target: target)
202
+ sessions[key] = session
203
+ do {
204
+ return try await session.translate(text).targetText
205
+ } catch {
206
+ // A failed session may be cancelled or stale; rebuild on the next call.
207
+ sessions.removeValue(forKey: key)
208
+ if TranslationError.notInstalled ~= error {
209
+ throw OfflineTranslationError.notInstalled(key)
210
+ }
211
+ throw OfflineTranslationError.failed(error.localizedDescription)
212
+ }
213
+ }
214
+
215
+ private func makeSession(source: String, target: String) -> TranslationSession {
216
+ let from = Locale.Language(identifier: source)
217
+ let to = Locale.Language(identifier: target)
218
+ if #available(iOS 26.4, *) {
219
+ return TranslationSession(installedSource: from, target: to, preferredStrategy: .lowLatency)
220
+ }
221
+ return TranslationSession(installedSource: from, target: to)
222
+ }
223
+ }
224
+ #endif
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/bluetooth-sdk",
3
- "version": "0.3.1-dev.496",
3
+ "version": "0.3.1-dev.497",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -257,6 +257,10 @@ export type GlassesMediaVolumeSetResult = {
257
257
  statusCode: number
258
258
  }
259
259
 
260
+ export type OfflineTranslationStatus = "installed" | "supported" | "unsupported" | "unavailable"
261
+
262
+ export type OfflineTranslationLanguage = {code: string; name: string; status: OfflineTranslationStatus}
263
+
260
264
  export type LocalTranscriptionEvent = {
261
265
  text: string
262
266
  isFinal?: boolean
@@ -1562,6 +1566,12 @@ export interface BluetoothSdkPublicModule {
1562
1566
  extractTarBz2(sourcePath: string, destinationPath: string): Promise<boolean>
1563
1567
  restartTranscriber(): Promise<void>
1564
1568
 
1569
+ // Offline translation (iOS 26+; rejects elsewhere)
1570
+ getOfflineTranslationLanguages(source: string): Promise<OfflineTranslationLanguage[]>
1571
+ getOfflineTranslationStatus(source: string, target: string): Promise<OfflineTranslationStatus>
1572
+ downloadOfflineTranslation(source: string, target: string): Promise<OfflineTranslationStatus>
1573
+ translateOffline(text: string, source: string, target: string): Promise<string>
1574
+
1565
1575
  // TTS Commands (TODO: MOVE TO CRUST)
1566
1576
  setTtsModelDetails(path: string, languageCode: string): Promise<void>
1567
1577
  getTtsModelPath(): Promise<string>
@@ -2,6 +2,8 @@ import {NativeModule, requireNativeModule} from "expo"
2
2
 
3
3
  import {CAMERA_FOV_DEFAULT, CAMERA_FOV_MAX, CAMERA_FOV_MIN} from "../BluetoothSdk.types"
4
4
  import type {
5
+ OfflineTranslationLanguage,
6
+ OfflineTranslationStatus,
5
7
  BluetoothSettingsUpdate,
6
8
  BluetoothSdkModuleEvents,
7
9
  BluetoothStatus,
@@ -280,6 +282,12 @@ declare class BluetoothSdkNativeModule extends NativeModule<BluetoothSdkModuleEv
280
282
  validateSttModel(path: string): Promise<boolean>
281
283
  extractTarBz2(sourcePath: string, destinationPath: string): Promise<boolean>
282
284
 
285
+ // Offline translation (iOS 26+; rejects elsewhere)
286
+ getOfflineTranslationLanguages(source: string): Promise<OfflineTranslationLanguage[]>
287
+ getOfflineTranslationStatus(source: string, target: string): Promise<OfflineTranslationStatus>
288
+ downloadOfflineTranslation(source: string, target: string): Promise<OfflineTranslationStatus>
289
+ translateOffline(text: string, source: string, target: string): Promise<string>
290
+
283
291
  // Speaker Identification (on-device; see SpeakerIdEngine)
284
292
  setSpeakerIdEnabled(enabled: boolean): Promise<void>
285
293
  setSpeakerIdModelPath(path: string): Promise<void>
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "0.3.1",
15
- "releaseIdentity": "0.3.1-dev.496",
16
- "releaseSetId": "glassly-0.3.1-dev.496",
17
- "sourceCommit": "77c2bb7e91ed12c414e1eaa54c786a324f232c66",
15
+ "releaseIdentity": "0.3.1-dev.497",
16
+ "releaseSetId": "glassly-0.3.1-dev.497",
17
+ "sourceCommit": "e8375fec1441ab00893f2bc1e7c29530e35fb419",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -192,6 +192,10 @@ export const BluetoothSdk: BluetoothSdkPublicModule = Object.freeze({
192
192
  validateSttModel: bindPublicMethod("validateSttModel"),
193
193
  extractTarBz2: bindPublicMethod("extractTarBz2"),
194
194
  restartTranscriber: bindPublicMethod("restartTranscriber"),
195
+ getOfflineTranslationLanguages: bindPublicMethod("getOfflineTranslationLanguages"),
196
+ getOfflineTranslationStatus: bindPublicMethod("getOfflineTranslationStatus"),
197
+ downloadOfflineTranslation: bindPublicMethod("downloadOfflineTranslation"),
198
+ translateOffline: bindPublicMethod("translateOffline"),
195
199
  setTtsModelDetails: bindPublicMethod("setTtsModelDetails"),
196
200
  getTtsModelPath: bindPublicMethod("getTtsModelPath"),
197
201
  getTtsModelLanguage: bindPublicMethod("getTtsModelLanguage"),