4 Commits

22 changed files with 855 additions and 471 deletions

View File

@ -1,7 +1,8 @@
export default {
getDb: jest.fn(() => {
return {
runAsync: jest.fn((statement: string, value: string) => {}),
runAsync: jest.fn((statement: string, ... values: string []) => {}),
runSync: jest.fn((statement: string, ... values : string []) => {}),
getFirstAsync: jest.fn((statement: string, value: string) => {
return [];
}),

View File

@ -3,5 +3,5 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
<application android:largeHeap="true" android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -1,7 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
@ -13,7 +15,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:largeHeap="true">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>

View File

@ -57,10 +57,11 @@
]
}
}
]
],
"expo-audio"
],
"experiments": {
"typedRoutes": true
}
}
}
}

View File

@ -1,123 +1,155 @@
import { Cache } from "react-native-cache";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import AsyncStorage from '@react-native-async-storage/async-storage';
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Settings } from "../lib/settings";
type language_t = string;
const cache = new Cache({
namespace: "translation_terrace",
policy: {
maxEntries: 50000, // if unspecified, it can have unlimited entries
stdTTL: 0 // the standard ttl as number in seconds, default: 0 (unlimited)
},
backend: AsyncStorage
namespace: "translation_terrace",
policy: {
maxEntries: 50000, // if unspecified, it can have unlimited entries
stdTTL: 0, // the standard ttl as number in seconds, default: 0 (unlimited)
},
backend: AsyncStorage,
});
export type language_matrix_entry = {
code: string,
name: string,
targets: string []
}
code: string;
name: string;
targets: string[];
};
export type language_matrix = {
[key:string] : language_matrix_entry
}
[key: string]: language_matrix_entry;
};
export async function fetchWithTimeout(url : string, options : RequestInit, timeout = 5000) : Promise<Response> {
return Promise.race([
fetch(url, options),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
]);
export async function fetchWithTimeout(
url: string,
options: RequestInit,
timeout = 5000
): Promise<Response> {
return Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("timeout")), timeout)
),
]);
}
export class LanguageServer {
constructor(public baseUrl : string) {}
constructor(public baseUrl: string) {}
async fetchLanguages(timeout = 500) : Promise<language_matrix> {
let data = {};
const res = await fetchWithTimeout(this.baseUrl + "/languages", {
headers: {
"Content-Type": "application/json"
}
}, timeout);
try {
data = await res.json();
} catch (e) {
throw new Error(`Parsing data from ${await res.text()}: ${e}`)
}
try {
return Object.fromEntries(
Object.values(data as language_matrix_entry []).map((obj : language_matrix_entry) => {
return [
obj["code"],
obj,
]
})
)
} catch(e) {
throw new Error(`Can't extract values from data: ${JSON.stringify(data)}`)
}
async fetchLanguages(timeout = 500): Promise<language_matrix> {
let data = {};
const res = await fetchWithTimeout(
this.baseUrl + "/languages",
{
headers: {
"Content-Type": "application/json",
},
},
timeout
);
try {
data = await res.json();
} catch (e) {
throw new Error(`Parsing data from ${await res.text()}: ${e}`);
}
try {
return Object.fromEntries(
Object.values(data as language_matrix_entry[]).map(
(obj: language_matrix_entry) => {
return [obj["code"], obj];
}
)
);
} catch (e) {
throw new Error(
`Can't extract values from data: ${JSON.stringify(data)}`
);
}
}
static async getDefault() {
const settings = await Settings.getDefault();
return new LanguageServer(await settings.getLibretranslateBaseUrl() || LIBRETRANSLATE_BASE_URL);
}
static async getDefault() {
const settings = await Settings.getDefault();
return new LanguageServer(
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
);
}
}
export class Translator {
constructor(public source : language_t, public defaultTarget : string = "en", private _languageServer : LanguageServer) {
}
constructor(
public source: language_t,
public defaultTarget: string = "en",
private _languageServer: LanguageServer
) {}
get languageServer() {
return this._languageServer;
}
get languageServer() {
return this._languageServer;
}
async translate(text : string, target : string|undefined = undefined) {
const url = this._languageServer.baseUrl + `/translate`;
const res = await fetch(url, {
method: "POST",
body: JSON.stringify({
q: text,
source: this.source,
target: target || this.defaultTarget,
format: "text",
alternatives: 3,
api_key: ""
}),
headers: { "Content-Type": "application/json" }
});
async translate(text: string, target: string | undefined = undefined) {
const url = this._languageServer.baseUrl + `/translate`;
console.log(url);
const postData = {
method: "POST",
body: JSON.stringify({
q: text,
source: this.source,
target: target || this.defaultTarget,
format: "text",
alternatives: 3,
api_key: "",
}),
headers: { "Content-Type": "application/json" },
};
const data = await res.json();
console.log(data)
return data.translatedText
}
console.debug("Requesting %s with %o", url, postData);
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage();
return new Translator(source, defaultTarget, await LanguageServer.getDefault())
const res = await fetch(url, postData);
const data = await res.json();
if (res.status === 200) {
console.log(data);
return data.translatedText;
} else {
console.error(data);
}
}
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage();
return new Translator(
source,
defaultTarget,
await LanguageServer.getDefault()
);
}
}
export class CachedTranslator extends Translator {
async translate (text : string, target : string|undefined = undefined) {
const targetKey = target || this.defaultTarget;
// console.debug(`Translating from ${this.source} -> ${targetKey}`)
const key1 = `${this.source}::${targetKey}::${text}`
const tr1 = await cache.get(key1);
if (tr1) return tr1;
const tr2 = await super.translate(text, target);
const key2 = `${this.source}::${targetKey}::${text}`
await cache.set(key2, tr2);
return tr2;
}
async translate(text: string, target: string | undefined = undefined) {
const targetKey = target || this.defaultTarget;
// console.debug(`Translating from ${this.source} -> ${targetKey}`)
const key1 = `${this.source}::${targetKey}::${text}`;
const tr1 = await cache.get(key1);
if (tr1) return tr1;
const tr2 = await super.translate(text, target);
const key2 = `${this.source}::${targetKey}::${text}`;
await cache.set(key2, tr2);
return tr2;
}
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage();
return new CachedTranslator(source, defaultTarget, await LanguageServer.getDefault())
}
}
static async getDefault(defaultTarget: string | undefined = undefined) {
const settings = await Settings.getDefault();
const source = await settings.getHostLanguage() || "en";
return new CachedTranslator(
source,
defaultTarget,
await LanguageServer.getDefault()
);
}
}

View File

@ -1,18 +1,19 @@
import { Settings } from '@/app/lib/settings';
import { getDb } from '@/app/lib/db';
import { Knex } from 'knex';
import { getDb, migrateDb } from '@/app/lib/db';
import { SQLiteDatabase } from 'expo-sqlite';
describe('Settings', () => {
let settings : Settings;
let db : Knex;
let settings: Settings;
let db: SQLiteDatabase;
beforeEach(async () => {
db = await getDb("development");
settings = new Settings(db)
await migrateDb("development");
settings = new Settings(db);
});
afterEach(async () => {
await db.migrate.down();
await migrateDb("development", "down");
});
it('should set the host language in the database', async () => {

View File

@ -1,101 +1,170 @@
// components/ui/__tests__/WhisperFile.spec.tsx
// app/lib/__tests__/whisper.spec.tsx
import React from "react";
import { render, act } from "@testing-library/react-native";
import { WhisperFile } from "@/app/lib/whisper"; // Adjust the import path as necessary
import { getDb } from "@/app/lib/db";
import { WhisperFile, WhisperModelTag } from "@/app/lib/whisper"; // Corrected to use WhisperFile and WhisperModelTag instead of WhisperDownloader
import { Settings } from "@/app/lib/settings";
import { File } from "expo-file-system/next";
jest.mock('expo-file-system');
import * as FileSystem from 'expo-file-system';
jest.mock("@/app/lib/db", () => ({
getDb: jest.fn().mockResolvedValue({
runAsync: jest.fn(),
upsert: jest.fn(), // Mock the upsert method used in addToDatabase
}),
}));
jest.mock("@/app/lib/settings", () => ({
Settings: {
getDefault: jest.fn(() => ({
getValue: jest.fn((key) => {
switch (key) {
case "whisper_model":
return "base";
default:
throw new Error(`Invalid setting: '${key}'`);
}
}),
})),
},
}));
jest.mock("expo-file-system/next", () => {
const _next = jest.requireActual("expo-file-system/next");
return {
..._next,
File: jest.fn().mockImplementation(() => ({
..._next.File,
text: jest.fn(() => {
return new String("text");
}),
})),
};
});
describe("WhisperFile", () => {
// Corrected to use WhisperFile instead of WhisperDownloader
let whisperFile: WhisperFile;
beforeEach(() => {
beforeEach(async () => {
whisperFile = new WhisperFile("small");
});
it("should initialize correctly", () => {
expect(whisperFile).toBeInstanceOf(WhisperFile);
it("should create a download resumable with existing data if available", async () => {
const mockExistingData = "mockExistingData";
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
await whisperFile.createDownloadResumable();
// expect(whisperFile.targetFileName).toEqual("small.bin");
expect(whisperFile.targetPath).toContain("small.bin");
expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
"https://huggingface.co/openai/whisper-small/resolve/main/pytorch_model.bin",
"file:///whisper/small.bin",
{},
expect.any(Function),
expect.anything(),
);
});
describe("getModelFileSize", () => {
it("should return the correct model file size", async () => {
expect(whisperFile.size).toBeUndefined();
await whisperFile.updateMetadata();
expect(whisperFile.size).toBeGreaterThan(1000);
});
});
// it("should create a download resumable without existing data if not available", async () => {
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
describe("getWhisperDownloadStatus", () => {
it("should return the correct download status", async () => {
const mockStatus = {
doesTargetExist: true,
isDownloadComplete: false,
hasDownloadStarted: true,
progress: {
current: 100,
total: 200,
remaining: 100,
percentRemaining: 50.0,
},
};
jest
.spyOn(whisperFile, "getDownloadStatus")
.mockResolvedValue(mockStatus);
// await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
const result = await whisperFile.getDownloadStatus();
// expect(FileSystem.createDownloadResumable).toHaveBeenCalledWith(
// "http://mock.model.com/model",
// "mockTargetPath",
// {},
// expect.any(Function),
// undefined
// );
// });
expect(result).toEqual(mockStatus);
});
});
// it("should update the download status in the database", async () => {
// const mockRunAsync = jest.fn();
// (getDb as jest.Mock).mockResolvedValue({ runAsync: mockRunAsync });
describe("initiateWhisperDownload", () => {
it("should initiate the download with default options", async () => {
const mockModelLabel = "small";
jest
.spyOn(whisperFile, "createDownloadResumable")
.mockResolvedValue(true);
// const downloadable = await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
// await downloadable.resumeAsync();
await whisperFile.initiateWhisperDownload(mockModelLabel);
// jest.advanceTimersByTime(1000);
expect(whisperFile.createDownloadResumable).toHaveBeenCalledWith(
mockModelLabel
);
});
// expect(mockRunAsync).toHaveBeenCalled();
// });
it("should initiate the download with custom options", async () => {
const mockModelLabel = "small";
const mockOptions = { force_redownload: true };
jest
.spyOn(whisperFile, "createDownloadResumable")
.mockResolvedValue(true);
// it("should record the latest target hash after downloading", async () => {
// const mockRecordLatestTargetHash = jest.spyOn(
// whisperFile,
// "recordLatestTargetHash"
// );
await whisperFile.initiateWhisperDownload(mockModelLabel, mockOptions);
// await whisperFile.createDownloadResumable(); // Updated to use createDownloadResumable instead of download
expect(whisperFile.createDownloadResumable).toHaveBeenCalledWith(
mockModelLabel,
mockOptions
);
});
// expect(mockRecordLatestTargetHash).toHaveBeenCalled();
// });
it("should return the correct download status when target exists and is complete", async () => {
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(true);
// it("should call the onData callback if provided", async () => {
// const mockOnData = jest.fn();
// const options = { onData: mockOnData };
expect(await whisperFile.doesTargetExist()).toEqual(true);
expect(await whisperFile.isDownloadComplete()).toEqual(true);
});
// await whisperFile.createDownloadResumable(options); // Updated to use createDownloadResumable instead of download
it("should return the correct download status when target does not exist", async () => {
jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
// expect(mockOnData).toHaveBeenCalledWith(expect.any(Object));
// });
const result = await whisperFile.getDownloadStatus();
// describe("getDownloadStatus", () => {
// it("should return the correct download status when model size is known and download has started", async () => {
// whisperFile.size = 1024;
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
// jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(false);
// jest.spyOn(whisperFile, "targetFile").mockReturnValue({
// size: 512,
// });
expect(result).toEqual({
doesTargetExist: false,
isDownloadComplete: false,
hasDownloadStarted: false,
progress: undefined,
});
});
});
// const status = await whisperFile.getDownloadStatus();
// Add more tests as needed for other methods in WhisperFile
// expect(status).toEqual({
// doesTargetExist: true,
// isDownloadComplete: false,
// hasDownloadStarted: true,
// progress: {
// current: 512,
// total: 1024,
// remaining: 512,
// percentRemaining: 50.0,
// },
// });
// });
// it("should return the correct download status when model size is known and download is complete", async () => {
// whisperFile.size = 1024;
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(true);
// jest.spyOn(whisperFile, "isDownloadComplete").mockResolvedValue(true);
// const status = await whisperFile.getDownloadStatus();
// expect(status).toEqual({
// doesTargetExist: true,
// isDownloadComplete: true,
// hasDownloadStarted: false,
// progress: undefined,
// });
// });
// it("should return the correct download status when model size is unknown", async () => {
// jest.spyOn(whisperFile, "doesTargetExist").mockResolvedValue(false);
// const status = await whisperFile.getDownloadStatus();
// expect(status).toEqual({
// doesTargetExist: false,
// isDownloadComplete: false,
// hasDownloadStarted: false,
// progress: undefined,
// });
// });
// });
});

View File

@ -1,14 +1,16 @@
import * as SQLite from "expo-sqlite";
import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations";
export async function getDb() {
return await SQLite.openDatabaseAsync("translation_terrace");
export type db_mode = "development" | "staging" | "production";
export async function getDb(mode : db_mode = "production") {
return await SQLite.openDatabaseAsync(`translation_terrace_${mode}`);
}
export async function migrateDb(direction: "up" | "down" = "up") {
export async function migrateDb(mode : db_mode = "production", direction: "up" | "down" = "up") {
const db = await getDb();
const db = await getDb(mode);
const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN;

View File

@ -2,15 +2,16 @@
export const MIGRATE_UP = {
1: [
`CREATE TABLE IF NOT EXISTS settings (
host_language TEXT,
libretranslate_base_url TEXT,
ui_direction INTEGER,
whisper_model TEXT
)`,
key TEXT PRIMARY KEY,
value TEXT
)`,
],
2: [
`CREATE TABLE IF NOT EXISTS whisper_models (
model TEXT PRIMARY KEY,
download_status STRING(255),
expected_size INTEGER,
last_hash STRING(1024),
bytes_done INTEGER,
bytes_total INTEGER
)`,

61
app/lib/readstream.ts Normal file
View File

@ -0,0 +1,61 @@
/* eslint-disable unicorn/no-null */
import * as fs from 'expo-file-system';
import { Readable } from 'readable-stream';
class ExpoReadStream extends Readable {
private readonly fileUri: string;
private fileSize: number;
private currentPosition: number;
private readonly chunkSize: number;
constructor(fileUri: string, options: fs.ReadingOptions) {
super();
this.fileUri = fileUri;
this.fileSize = 0; // Initialize file size (could be fetched if necessary)
this.currentPosition = options.position ?? 0;
/**
* Default chunk size in bytes. React Native Expo will OOM at 110MB, so we set this to 1/100 of it to balance speed and memory usage and importantly the feedback for user.
* If this is too large, the progress bar will be stuck when down stream processing this chunk.
*/
this.chunkSize = options.length ?? 1024 * 1024;
void this._init();
}
async _init() {
try {
const fileInfo = await fs.getInfoAsync(this.fileUri, { size: true });
if (fileInfo.exists) {
this.fileSize = fileInfo.size ?? 0;
} else {
this.fileSize = 0;
}
} catch (error) {
this.emit('error', error);
}
}
_read() {
const readingOptions = {
encoding: fs.EncodingType.Base64,
position: this.currentPosition,
length: this.chunkSize,
} satisfies fs.ReadingOptions;
fs.readAsStringAsync(this.fileUri, readingOptions).then(chunk => {
if (chunk.length === 0) {
// End of the stream
this.emit('progress', 1);
this.push(null);
} else {
this.currentPosition = Math.min(this.chunkSize + this.currentPosition, this.fileSize);
this.emit('progress', this.fileSize === 0 ? 0.5 : (this.currentPosition / this.fileSize));
this.push(Buffer.from(chunk, 'base64'));
}
}).catch(error => {
this.emit('error', error);
});
}
}
export function createReadStream(fileUri: string, options: { encoding?: fs.EncodingType; end?: number; highWaterMark?: number; start?: number } = {}): ExpoReadStream {
return new ExpoReadStream(fileUri, options);
}

View File

@ -1,5 +1,6 @@
import { SQLiteDatabase } from "expo-sqlite";
import { getDb } from "./db";
import { Knex } from "knex";
import { WhisperFile, whisper_model_tag_t } from "./whisper";
export class Settings {
@ -10,7 +11,7 @@ export class Settings {
"whisper_model",
]
constructor(public db: Knex) {
constructor(public db: SQLiteDatabase) {
}
@ -20,9 +21,9 @@ export class Settings {
throw new Error(`Invalid setting: '${key}'`)
}
const row = await this.db("settings").select(key).limit(1).first();
if (!(row && row[key])) return undefined;
return row[key];
const row: { value: string } | null = this.db.getFirstSync(`SELECT value FROM settings WHERE key = ?`, key)
return row?.value;
}
@ -32,17 +33,11 @@ export class Settings {
}
// Check if the key already exists
const [exists] = await this.db("settings").select(1).whereNotNull(key).limit(1);
if (exists) {
// Update the existing column
await this.db("settings").update({ [key]: value });
} else {
// Insert a new value into the specified column
const insertData: { [key: string]: any } = {};
insertData[key] = value;
await this.db("settings").insert(insertData);
}
this.db.runSync(`INSERT OR REPLACE INTO
settings
(key, value)
VALUES
(?, ?)`, key, value);
}
async setHostLanguage(value: string) {
@ -53,7 +48,7 @@ export class Settings {
return await this.getValue("host_language")
}
async setLibretranslateBaseUrl(value : string) {
async setLibretranslateBaseUrl(value: string) {
await this.setValue("libretranslate_base_url", value)
}
@ -61,16 +56,15 @@ export class Settings {
return await this.getValue("libretranslate_base_url")
}
async setWhisperModel(value : string) {
async setWhisperModel(value: string) {
await this.setValue("whisper_model", value);
}
async getWhisperModel() {
return await this.getValue("whisper_model");
return await this.getValue("whisper_model") as whisper_model_tag_t;
}
static async getDefault() {
return new Settings(await getDb())
}
}

9
app/lib/util.ts Normal file
View File

@ -0,0 +1,9 @@
import { TextDecoder } from "util";
export function arrbufToStr(arrayBuffer : ArrayBuffer) {
return new TextDecoder().decode(new Uint8Array(arrayBuffer));
}
export function strToArrBuf(input : string) : Uint8Array<ArrayBufferLike> {
return new TextEncoder().encode(input)
}

View File

@ -3,6 +3,8 @@ import * as FileSystem from "expo-file-system";
import { File, Paths } from "expo-file-system/next";
import { getDb } from "./db";
import * as Crypto from "expo-crypto";
import { arrbufToStr, strToArrBuf } from "./util";
import { createReadStream } from "./readstream";
export const WHISPER_MODEL_PATH = Paths.join(
FileSystem.documentDirectory || "file:///",
@ -75,7 +77,7 @@ export const WHISPER_MODELS = {
size: 6173629930,
},
} as {
[key:whisper_model_tag_t]: {
[key: whisper_model_tag_t]: {
source: string;
target: string;
label: string;
@ -114,6 +116,13 @@ export type download_status_t = {
};
export class WhisperFile {
hf_metadata: hf_metadata_t | undefined;
target_hash: string | undefined;
does_target_exist: boolean = false;
does_part_target_exist: boolean = false;
download_data: FileSystem.DownloadProgressData | undefined;
constructor(
public tag: whisper_model_tag_t,
private targetFileName?: string,
@ -122,11 +131,15 @@ export class WhisperFile {
) {
this.targetFileName = this.targetFileName || `${tag}.bin`;
this.label =
this.label || `${tag[0].toUpperCase}${tag.substring(1).toLowerCase()}`;
this.label || `${tag[0].toUpperCase()}${tag.substring(1).toLowerCase()}`;
}
get targetPath() {
return Paths.join(WHISPER_MODEL_DIR, this.targetFileName as string);
return Paths.join(WHISPER_MODEL_PATH, this.targetFileName as string);
}
get targetPartPath () {
return this.targetPath + ".part";
}
get targetFile() {
@ -137,78 +150,44 @@ export class WhisperFile {
return await FileSystem.getInfoAsync(this.targetPath);
}
async doesTargetExist() {
return (await this.getTargetInfo()).exists;
async getTargetPartInfo() {
return await FileSystem.getInfoAsync(this.targetPartPath);
}
public async recordLatestTargetHash() {
if (!(await this.doesTargetExist())) {
console.debug("%s does not exist", this.targetPath);
}
const digest1Str = await this.getActualTargetHash();
if (!digest1Str) {
return;
}
const db = await getDb();
await db("whisper_models")
.upsert({
model: this.tag,
last_hash: digest1Str,
})
.where({ model: this.tag });
async updateTargetExistence() {
this.does_target_exist = (await this.getTargetInfo()).exists;
this.does_part_target_exist = (await this.getTargetPartInfo()).exists;
}
public async getRecordedTargetHash(): Promise<string> {
const db = await getDb();
const row = await db("whisper_models").select("last_hash").where({
model: this.tag,
}).first();
return row["last_hash"]
}
public async getActualTargetHash(): Promise<string | undefined> {
if (!(await this.doesTargetExist())) {
public async getTargetSha() {
await this.updateTargetExistence();
if (!this.does_target_exist) {
console.debug("%s does not exist", this.targetPath);
return undefined;
}
const digest1 = await Crypto.digest(
Crypto.CryptoDigestAlgorithm.SHA256,
this.targetFile.bytes()
);
const digest1Str = new TextDecoder().decode(new Uint8Array(digest1));
return digest1Str;
}
async isTargetCorrupted() {
const recordedTargetHash = await this.getRecordedTargetHash();
const actualTargetHash = await this.getActualTargetHash();
if (!(actualTargetHash || recordedTargetHash)) return false;
return actualTargetHash !== recordedTargetHash;
}
const strData = await FileSystem.readAsStringAsync(this.targetPath, {
encoding: FileSystem.EncodingType.Base64,
});
const data = strToArrBuf(strData);
async isDownloadComplete() {
if (!(await this.doesTargetExist())) {
console.debug("%s does not exist", this.targetPath);
return false;
}
const data = this.targetFile.bytes();
const meta = await this.fetchMetadata();
const expectedHash = meta.oid;
const digest1: ArrayBuffer = await Crypto.digest(
const digest = await Crypto.digest(
Crypto.CryptoDigestAlgorithm.SHA256,
data
);
const digest1Str = new TextDecoder().decode(new Uint8Array(digest1));
const doesMatch = digest1Str === expectedHash;
if (!doesMatch) {
console.debug(
"sha256 of '%s' does not match expected '%s'",
digest1Str,
expectedHash
);
return false;
}
return true;
return digest;
}
public async updateTargetHash() {
const targetSha = await this.getTargetSha();
if (!targetSha) return;
this.target_hash = arrbufToStr(targetSha);
}
get isHashValid() {
return this.target_hash === this.hf_metadata?.oid;
}
delete(ignoreErrors = true) {
@ -231,7 +210,21 @@ export class WhisperFile {
return create_hf_url(this.tag, "raw");
}
private async fetchMetadata(): Promise<hf_metadata_t> {
get percentDone() {
if (!this.download_data) return 0;
return (
(this.download_data.totalBytesWritten /
this.download_data.totalBytesExpectedToWrite) *
100
);
}
get percentLeft() {
if (!this.download_data) return 0;
return 100 - this.percentDone;
}
public async syncHfMetadata() {
try {
const resp = await fetch(this.metadataUrl, {
credentials: "include",
@ -253,7 +246,7 @@ export class WhisperFile {
mode: "cors",
});
const text = await resp.text();
return Object.fromEntries(
this.hf_metadata = Object.fromEntries(
text.split("\n").map((line) => line.split(" "))
) as hf_metadata_t;
} catch (err) {
@ -262,87 +255,94 @@ export class WhisperFile {
}
}
async updateMetadata() {
const metadata = await this.fetchMetadata();
this.size = Number.parseInt(metadata.size);
}
async addToDatabase() {
const db = await getDb();
await db("whisper_models").upsert({
model: this.tag,
expected_size: this.size,
}).where({
model: this.tag,
});
}
async createDownloadResumable(
options: {
onData?: DownloadCallback | undefined;
onComplete?: CompletionCallback | undefined;
} = {
onData: undefined,
}
) {
const existingData = (await this.doesTargetExist())
? this.targetFile.text()
: undefined;
if (await this.doesTargetExist()) {
}
return FileSystem.createDownloadResumable(
this.modelUrl,
this.targetPath,
{},
async (data: FileSystem.DownloadProgressData) => {
const db = await getDb();
await db.upsert({
model: this.tag,
download_status: "active",
})
await this.recordLatestTargetHash();
if (options.onData) await options.onData(data);
},
existingData ? existingData : undefined
);
}
async getDownloadStatus() : Promise<download_status_t> {
const doesTargetExist = await this.doesTargetExist();
const isDownloadComplete = await this.isDownloadComplete();
const hasDownloadStarted = doesTargetExist && !isDownloadComplete;
if (!this.size) {
return {
doesTargetExist: false,
isDownloadComplete: false,
hasDownloadStarted: false,
progress: undefined,
onData: undefined,
onComplete: undefined,
}
) {
await this.syncHfMetadata();
// If the whisper model dir doesn't exist, create it.
if (!WHISPER_MODEL_DIR.exists) {
FileSystem.makeDirectoryAsync(WHISPER_MODEL_PATH, {
intermediates: true,
});
}
const remaining = hasDownloadStarted
? this.size - (this.targetFile.size as number)
: 0;
// Check for the existence of the target file
// If it exists, load the existing data.
await this.updateTargetExistence();
const progress = hasDownloadStarted
? {
current: this.targetFile.size || 0,
total: this.size,
remaining: this.size - (this.targetFile.size as number),
percentRemaining: (remaining / this.size) * 100.0,
}
: undefined;
if (this.does_part_target_exist) {
options.onComplete && options.onComplete(this)
}
return {
doesTargetExist,
isDownloadComplete,
hasDownloadStarted,
progress,
};
try {
const existingData = this.does_target_exist
? await FileSystem.readAsStringAsync(this.targetPath, {
encoding: FileSystem.EncodingType.Base64,
})
: undefined;
// Create the resumable.
return FileSystem.createDownloadResumable(
this.modelUrl,
this.targetPath,
{},
async (data: FileSystem.DownloadProgressData) => {
// console.debug("yes, I'm still downloading");
try {
this.download_data = data;
} catch (err) {
console.error("Failed to set downloadData: %s", err);
}
try {
await this.syncHfMetadata();
} catch (err) {
console.error("Failed to update HuggingFace metadata: %s", err)
}
// try {
// await this.updateTargetHash();
// } catch (er) {
// console.error("Failed to update target hash: %s", er);
// }
try {
await this.updateTargetExistence();
} catch (err) {
console.error("Failed to update target existence: %s", err)
}
if (options.onData) await options.onData(this);
if (data.totalBytesExpectedToWrite === 0) {
console.debug("Finalizing; copying from %s -> %s", this.targetPartPath, this.targetPath);
await FileSystem.copyAsync({
from: this.targetPartPath,
to: this.targetPath,
});
await this.updateTargetExistence();
options.onComplete && options.onComplete(this);
}
},
existingData ? existingData : undefined
);
} catch (err) {
console.error("Could not read %s: %s", this.targetPath, err);
}
}
}
export type DownloadCallback = (arg0: WhisperFile) => any;
export type CompletionCallback = (arg0: WhisperFile) => any;
export type DownloadCallback = (arg0: FileSystem.DownloadProgressData) => any;
export const WHISPER_FILES = {
small: new WhisperFile("small"),
medium: new WhisperFile("medium"),
large: new WhisperFile("large"),
};

View File

@ -1,9 +1,14 @@
import React, { useState, useEffect } from "react";
import { ScrollView, StyleSheet, Text, TouchableHighlight, View } from "react-native";
import { Alert, ScrollView, StyleSheet, Text, TouchableHighlight, View } from "react-native";
import { useNavigation, Route } from "@react-navigation/native";
import { Conversation, Message } from "@/app/lib/conversation";
import MessageBubble from "@/components/ui/MessageBubble";
import { CachedTranslator, LanguageServer, language_matrix_entry } from "@/app/i18n/api";
import { Settings } from "@/app/lib/settings";
import { WHISPER_FILES } from "@/app/lib/whisper";
import { initWhisper, WhisperContext } from 'whisper.rn'
import { useAudioRecorder, AudioModule, RecordingPresets } from 'expo-audio';
const lasOptions = {
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
@ -14,9 +19,9 @@ const lasOptions = {
};
// LiveAudioStream.init(lasOptions as any);
const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversation : Conversation}>}) => {
const ConversationThread = ({ route }: { route?: Route<"Conversation", { conversation: Conversation }> }) => {
const navigation = useNavigation();
if (!route) {
return (<View><Text>Missing Params!</Text></View>)
}
@ -27,6 +32,7 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
const [messages, setMessages] = useState<Message[]>([]);
const [guestSpeak, setGuestSpeak] = useState<string | undefined>();
const [guestSpeakLoaded, setGuestSpeakLoaded] = useState<boolean>(false);
const [whisperContext, setWhisperContext] = useState<WhisperContext | undefined>();
const [cachedTranslator, setCachedTranslator] = useState<
undefined | CachedTranslator
>();
@ -42,17 +48,50 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
}
}>()
// recorder settings
const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const record = async () => {
await audioRecorder.prepareToRecordAsync();
audioRecorder.record();
};
const stopRecording = async () => {
// The recording will be available on `audioRecorder.uri`.
await audioRecorder.stop();
};
useEffect(() => {
(async () => {
(async function () {
const languageServer = await LanguageServer.getDefault();
const languages = await languageServer.fetchLanguages();
const cc = new CachedTranslator(
"en",
conversation.guest.language,
languageServer,
)
setCachedTranslator(cc);
try {
const languages = await languageServer.fetchLanguages(5000);
const cc = new CachedTranslator(
"en",
conversation.guest.language,
languageServer,
)
console.log("Set cached translator from %s", languageServer.baseUrl)
setCachedTranslator(cc);
} catch (err) {
console.error("Could not set translator from %s: %s", languageServer.baseUrl, err)
}
const settings = await Settings.getDefault();
const whisperFile = WHISPER_FILES[await settings.getWhisperModel() || "en"];
setWhisperContext(await initWhisper({
filePath: whisperFile.targetPath,
}));
// recorder settings
(async () => {
const status = await AudioModule.requestRecordingPermissionsAsync();
if (!status.granted) {
Alert.alert('Permission to access microphone was denied');
}
})();
setGuestSpeak(await cc.translate("Speak"));
const hostLang1 = languages[conversation.host.language].name;
const guestLang1 = languages[conversation.host.language].name;
@ -69,18 +108,22 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
}
})
})();
const updateMessages = (c: Conversation) => {
setMessages([...c]);
};
conversation.onAddMessage = updateMessages;
conversation.onTranslationDone = updateMessages;
if (!conversation) {
console.warn("Conversation is null or undefined.")
}
return () => {
conversation.onAddMessage = undefined;
conversation.onTranslationDone = undefined;
};
conversation.on("add_message", updateMessages);
conversation.on("translation_done", updateMessages);
// return () => {
// conversation.on("add_message", undefined);
// conversation.on("translation_done", undefined);
// };
}, [conversation, guestSpeak]);
const renderMessages = () =>
@ -91,8 +134,8 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
return cachedTranslator ? (
<View style={{ flex: 1, flexDirection: "column" }}>
{languageLabels && (<View style={styles.languageLabels}>
<Text style={styles.nativeHostLabel}>{ languageLabels.hostNative.host } / { languageLabels.hostNative.guest }</Text>
<Text style={styles.nativeGuestLabel}>{ languageLabels.guestNative.host } / { languageLabels.guestNative.guest }</Text>
<Text style={styles.nativeHostLabel}>{languageLabels.hostNative.host} / {languageLabels.hostNative.guest}</Text>
<Text style={styles.nativeGuestLabel}>{languageLabels.guestNative.host} / {languageLabels.guestNative.guest}</Text>
</View>)
}
<ScrollView

View File

@ -8,6 +8,7 @@ import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { Conversation, Speaker } from "@/app/lib/conversation";
import { NavigationProp, ParamListBase } from "@react-navigation/native";
import { Link, useNavigation } from "expo-router";
import { migrateDb } from "@/app/lib/db";
export function LanguageSelection(props: {
@ -17,7 +18,7 @@ export function LanguageSelection(props: {
}) {
const [languages, setLanguages] = useState<language_matrix | undefined>();
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
const [translator, setTranslator] = useState<Translator|undefined>();
const [translator, setTranslator] = useState<Translator | undefined>();
const nav = useNavigation();
@ -30,6 +31,7 @@ export function LanguageSelection(props: {
useEffect(() => {
(async () => {
await migrateDb();
try {
// Replace with your actual async data fetching logic
setTranslator(await CachedTranslator.getDefault());
@ -49,8 +51,8 @@ export function LanguageSelection(props: {
<Text>Settings</Text>
</Pressable>
<ScrollView >
<SafeAreaProvider >
<SafeAreaView>
<SafeAreaProvider>
<SafeAreaView style={styles.table}>
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
([lang, lang_entry]) => {
return (
@ -66,11 +68,15 @@ export function LanguageSelection(props: {
)
}
const DEBUG_BORDER = {
borderWidth: 3,
borderStyle: "dotted",
borderColor: "blue",
}
const styles = StyleSheet.create({
column: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
padding: 8,
table: {
flexDirection: "row",
flexWrap: "wrap",
},
})

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
import {
WHISPER_FILES,
WhisperFile,
download_status_t,
whisper_tag_t,
@ -34,33 +35,39 @@ const SettingsComponent = () => {
} | null>(null);
const [whisperModel, setWhisperModel] =
useState<keyof typeof WHISPER_MODELS>("small");
const [downloader, setDownloader] = useState<any>(null);
const [whisperFile, setWhisperFile] = useState<WhisperFile | undefined>();
const [downloadStatus, setDownloadStatus] = useState<
undefined | download_status_t
>();
const [whisperFileExists, setWhisperFileExists] = useState<boolean>(false);
const [isWhisperHashValid, setIsWhisperHashValid] = useState<boolean>(false);
const [downloader, setDownloader] = useState<any>(null);
const [bytesDone, setBytesDone] = useState<number | undefined>();
const [bytesRemaining, setBytesRemaining] = useState<number | undefined>();
const [statusTimeout, setStatusTimeout] = useState<
NodeJS.Timeout | undefined
>();
useEffect(() => {
loadSettings();
}, []);
(async function () {
const settings = await Settings.getDefault();
setHostLanguage((await settings.getHostLanguage()) || "en");
setLibretranslateBaseUrl(
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
);
setWhisperModel((await settings.getWhisperModel()) || "small");
setWhisperFile(WHISPER_FILES[whisperModel]);
if (whisperFile) {
await whisperFile.syncHfMetadata();
await whisperFile.updateTargetExistence();
await whisperFile.updateTargetHash();
setWhisperFileExists(whisperFile.does_target_exist)
}
})();
}, [whisperFile]);
const getLanguageOptions = async () => {
const languageServer = await LanguageServer.getDefault();
setLanguageOptions(await languageServer.fetchLanguages());
};
const loadSettings = async () => {
const settings = await Settings.getDefault();
setHostLanguage((await settings.getHostLanguage()) || "en");
setLibretranslateBaseUrl(
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL
);
setWhisperModel(await settings.getWhisperModel());
};
const handleHostLanguageChange = async (lang: string) => {
const settings = await Settings.getDefault();
setHostLanguage(lang);
@ -83,34 +90,47 @@ const SettingsComponent = () => {
}
};
const intervalUpdateDownloadStatus = async () => {
if (!whisperFile) return;
const status = await whisperFile.getDownloadStatus();
setDownloadStatus(status);
};
const handleWhisperModelChange = async (model: whisper_tag_t) => {
const settings = await Settings.getDefault();
await settings.setWhisperModel(model);
setWhisperModel(model);
setWhisperFile(new WhisperFile(model));
const wFile = WHISPER_FILES[whisperModel];
await wFile.syncHfMetadata();
await wFile.updateTargetExistence();
// await wFile.updateTargetHash();
// setIsWhisperHashValid(wFile.isHashValid);
setWhisperFile(wFile);
setWhisperFileExists(wFile.does_target_exist);
};
const doSetDownloadStatus = (arg0: WhisperFile) => {
console.log("Downloading ....")
setIsWhisperHashValid(arg0.isHashValid);
setBytesDone(arg0.download_data?.totalBytesWritten);
setBytesRemaining(arg0.download_data?.totalBytesExpectedToWrite);
};
const doOnComplete = (arg0: WhisperFile) => {
setWhisperFile(arg0);
}
const doDownload = async () => {
if (!whisperModel) {
throw new Error("Could not start download because whisperModel not set.");
}
console.log("Starging download of %s", whisperModel)
console.log("Starting download of %s", whisperModel);
const whisperFile = new WhisperFile(whisperModel);
if (!whisperFile) throw new Error("No whisper file");
const resumable = await whisperFile.createDownloadResumable();
setDownloader(resumable);
try {
await resumable.downloadAsync();
const statusTimeout = setInterval(intervalUpdateDownloadStatus, 200);
setStatusTimeout(statusTimeout);
const resumable = await whisperFile.createDownloadResumable({
onData: doSetDownloadStatus,
onComplete: doOnComplete,
});
setDownloader(resumable);
if (!resumable) throw new Error("Could not construct resumable");
await resumable.resumeAsync();
} catch (error) {
console.error("Failed to download whisper model:", error);
}
@ -173,29 +193,34 @@ const SettingsComponent = () => {
/>
))}
</Picker>
<View>
{whisperModel &&
(downloadStatus?.isDownloadComplete ? (
downloadStatus?.doesTargetExist ? (
<Pressable onPress={doDelete}>
<Text>DELETE {whisperModel.toUpperCase()}</Text>
</Pressable>
) : (
<Pressable onPress={doStopDownload}>
<Text>PAUSE</Text>
</Pressable>
)
) : (
<Pressable onPress={doDownload}>
<Text>DOWNLOAD {whisperModel.toUpperCase()}</Text>
<View style={styles.downloadButtonWrapper}>
{((!downloader) && whisperFile) &&
(whisperFile.does_target_exist && (<Pressable onPress={doDelete} style={styles.deleteButton}>
<Text style={styles.buttonText}>DELETE {whisperModel.toUpperCase()}</Text>
</Pressable>))
}
<Pressable onPress={doDownload} style={styles.downloadButton}>
<Text style={styles.buttonText}>DOWNLOAD {whisperModel.toUpperCase()}</Text>
</Pressable>
))
{
downloader && (
<Pressable onPress={doStopDownload} style={styles.pauseDownloadButton}>
<Text style={styles.buttonText}>STOP DOWNLOAD</Text>
</Pressable>
))}
{downloadStatus?.progress && (
)
}
{bytesDone && bytesRemaining && whisperFile?.does_part_target_exist && (
<View>
{whisperFile &&
(<Text>
Downloading to {whisperFile.targetPath}
</Text>)}
<Text>
{downloadStatus.progress.current} of{" "}
{downloadStatus.progress.total} (
{downloadStatus.progress.percentRemaining} %){" "}
{bytesDone} of{" "}
{bytesRemaining} (
{bytesDone / bytesRemaining * 100} %){" "}
</Text>
</View>
)}

View File

@ -1,5 +1,5 @@
jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts"));
import { renderRouter} from 'expo-router/testing-library';
import { renderRouter } from "expo-router/testing-library";
import React from "react";
import {
act,
@ -13,14 +13,21 @@ import {
createNavigationContainerRef,
} from "@react-navigation/native";
import TTNavStack from "../TTNavStack";
import { migrateDb } from "@/app/lib/db";
describe("Navigation", () => {
beforeEach(() => {
beforeEach(async () => {
await migrateDb("development", "up");
// Reset the navigation state before each test
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(async () => {
await migrateDb("development", "down");
jest.clearAllMocks();
jest.useRealTimers();
});
it("Navigates to ConversationThread on language selection", async () => {
const MockComponent = jest.fn(() => <TTNavStack />);
renderRouter(
@ -28,7 +35,7 @@ describe("Navigation", () => {
index: MockComponent,
},
{
initialUrl: '/',
initialUrl: "/",
}
);
const languageSelectionText = await waitFor(() =>
@ -47,14 +54,16 @@ describe("Navigation", () => {
index: MockComponent,
},
{
initialUrl: '/',
initialUrl: "/",
}
);
const settingsButton = await waitFor(() =>
screen.getByText(/.*Settings.*/i)
);
fireEvent.press(settingsButton);
expect(await waitFor(() => screen.getByText(/Settings/i))).toBeOnTheScreen();
expect(
await waitFor(() => screen.getByText(/Settings/i))
).toBeOnTheScreen();
// expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy()
expect(screen.getByText("Settings")).toBeOnTheScreen();
});

View File

@ -92,7 +92,7 @@ const ISpeakButton = (props: ISpeakButtonProps) => {
}, []);
const countries =
// @ts-ignore
// @ts-ignore
DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
return title ? (
@ -106,7 +106,9 @@ const ISpeakButton = (props: ISpeakButtonProps) => {
<View style={styles.flag}>
{countries &&
countries.map((c) => {
return <CountryFlag isoCode={c} size={25} key={c} />;
return (
<CountryFlag isoCode={c} size={25} key={c} />
);
})}
</View>
<View>
@ -121,14 +123,13 @@ const ISpeakButton = (props: ISpeakButtonProps) => {
const styles = StyleSheet.create({
button: {
width: "20%",
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
height: 110,
alignSelf: "flex-start",
margin: 8,
width: 170,
margin: 10,
},
flag: {},
iSpeak: {

View File

@ -3,9 +3,10 @@ import { render, screen, fireEvent, act } from "@testing-library/react-native";
import SettingsComponent from "@/components/Settings";
import { language_matrix } from "@/app/i18n/api";
import { Settings } from "@/app/lib/settings";
import { getDb } from "@/app/lib/db";
import { getDb, migrateDb } from "@/app/lib/db";
import { Knex } from "knex";
import { WhisperFile } from "@/app/lib/whisper";
import { SQLiteDatabase } from "expo-sqlite";
const RENDER_TIME = 1000;
@ -77,11 +78,12 @@ jest.mock("@/app/i18n/api", () => {
describe("SettingsComponent", () => {
let db: Knex;
let db: SQLiteDatabase;
let settings: Settings;
beforeEach(async () => {
db = await getDb("development");
await migrateDb("development");
settings = new Settings(db);
jest.spyOn(Settings, 'getDefault').mockResolvedValue(settings);
await settings.setHostLanguage("en");
@ -90,8 +92,7 @@ describe("SettingsComponent", () => {
afterEach(async () => {
jest.restoreAllMocks();
await db.migrate.down();
await db.destroy();
await migrateDb("development", "down");
});
beforeAll(async () => {

View File

@ -9,43 +9,37 @@ jest.mock("expo-sqlite", () => {
const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations");
const genericRun = (sql: string, ... params : string []) => {
// console.log("Running %s with %s", sql, params);
try {
const stmt = db.prepare(sql);
stmt.run(...params);
} catch (e) {
throw new Error(
`running ${sql} with params ${JSON.stringify(params)}: ${e}`
);
}
}
const genericGetFirst = (sql: string, params = []) => {
const stmt = db.prepare(sql);
// const result = stmt.run(...params);
return stmt.get(params);
};
const openDatabaseAsync = async (name: string) => {
return {
closeAsync: jest.fn(() => db.close()),
executeSql: jest.fn((sql: string) => db.exec(sql)),
runAsync: jest.fn(async (sql: string, params = []) => {
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
const s = db.prepare(stmt);
s.run();
}
}
const stmt = db.prepare(sql);
// console.log("Running %s with %s", sql, params);
try {
stmt.run(params);
} catch (e) {
throw new Error(
`running ${sql} with params ${JSON.stringify(params)}: ${e}`
);
}
}),
getFirstAsync: jest.fn(async (sql: string, params = []) => {
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
const s = db.prepare(stmt);
s.run();
}
}
const stmt = db.prepare(sql);
// const result = stmt.run(...params);
return stmt.get(params);
}),
runAsync: jest.fn(genericRun),
runSync: jest.fn(genericRun),
getFirstAsync: jest.fn(genericGetFirst),
getFirstSync: jest.fn(genericGetFirst),
};
};
return {
migrateDb: async (direction: "up" | "down" = "up") => {
const db = await openDatabaseAsync("translation_terrace");
const db = await openDatabaseAsync("translation_terrace_development");
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
await db.executeSql(stmt);
@ -119,4 +113,18 @@ jest.mock('@/app/lib/settings', () => {
...originalModule,
default: MockSettings
};
});
});
jest.mock('expo-file-system', () => ({
// ... other methods ...
createDownloadResumable: jest.fn(() => ({
downloadAsync: jest.fn(() => Promise.resolve({ uri: 'mocked-uri' })),
pauseAsync: jest.fn(() => Promise.resolve()),
resumeAsync: jest.fn(() => Promise.resolve()),
cancelAsync: jest.fn(() => Promise.resolve()),
})),
getInfoAsync: jest.fn(() => ({
exists: () => false,
}))
// ... other methods ...
}));

129
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native-stack": "^7.2.0",
"expo": "~52.0.28",
"expo-audio": "~0.3.4",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.6",
@ -49,6 +50,7 @@
"react-native-sqlite-storage": "^6.0.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"readable-stream": "^4.7.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"whisper.rn": "^0.3.9"
@ -65,6 +67,7 @@
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-navigation": "^3.0.8",
"@types/react-test-renderer": "^18.3.1",
"@types/readable-stream": "^4.0.18",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"expo": "~52.0.28",
@ -5046,6 +5049,24 @@
"@types/react": "^18"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz",
"integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"safe-buffer": "~5.1.1"
}
},
"node_modules/@types/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@ -5589,6 +5610,21 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/are-we-there-yet/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -6030,6 +6066,20 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/bn.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
@ -8162,6 +8212,17 @@
"react-native": "*"
}
},
"node_modules/expo-audio": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-0.3.5.tgz",
"integrity": "sha512-gzpDH3vZI1FDL1Q8pXryACtNIW+idZ/zIZ8WqdTRzJuzxucazrG2gLXUS2ngcXQBn09Jyz4RUnU10Tu2N7/Hgg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-background-fetch": {
"version": "13.0.5",
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-13.0.5.tgz",
@ -15204,17 +15265,43 @@
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": ">= 6"
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readable-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/readline": {
@ -16439,6 +16526,20 @@
"readable-stream": "^3.5.0"
}
},
"node_modules/stream-browserify/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/stream-buffers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
@ -16837,6 +16938,20 @@
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tar/node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",

View File

@ -24,6 +24,7 @@
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native-stack": "^7.2.0",
"expo": "~52.0.28",
"expo-audio": "~0.3.4",
"expo-background-fetch": "~13.0.5",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.6",
@ -56,6 +57,7 @@
"react-native-sqlite-storage": "^6.0.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"readable-stream": "^4.7.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"whisper.rn": "^0.3.9"
@ -85,6 +87,7 @@
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-navigation": "^3.0.8",
"@types/react-test-renderer": "^18.3.1",
"@types/readable-stream": "^4.0.18",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"expo": "~52.0.28",