improve tests, especially for navigation.

This commit is contained in:
Jordan 2025-02-27 08:23:27 -08:00
parent 6f941c56d1
commit 87446784ae
24 changed files with 748 additions and 448 deletions

View File

@ -14,6 +14,9 @@ class LanguageServer {
"es": { code: "es", name: "Spanish", targets: ['fr', 'en'] },
}
}
static getDefault() {
return new LanguageServer("http://localhost:5002");
}
}
class Translator {
@ -21,6 +24,9 @@ class Translator {
translate(message : string, target : string) {
return message;
}
static getDefault(code : string) {
return new Translator(code);
}
}
class CachedTranslator extends Translator{

10
__mocks__/db.ts Normal file
View File

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

16
__mocks__/settings.ts Normal file
View File

@ -0,0 +1,16 @@
const originalModule = jest.requireActual("@/app/lib/settings");
class MockSettings {
public constructor(public db = {}) {}
public setHostLanguage = jest.fn((val: string) => {});
public setLibretranslateBaseUrl(val: string) {}
getHostLanguage = jest.fn(() => {
return "en";
});
getLibretranslateBaseUrl = jest.fn(() => {
return "http://localhost:5004";
});
}
export default {
...originalModule,
Settings: MockSettings,
};

View File

@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>

View File

@ -3,7 +3,7 @@
"name": "translation-terrace",
"slug": "translation-terrace",
"version": "1.0.0",
"orientation": "portrait",
"orientation": "landscape",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
@ -30,7 +30,7 @@
[
"expo-screen-orientation",
{
"initialOrientation": "LANDSCAPE"
"orientation": "landscape"
}
],
[
@ -48,12 +48,10 @@
"enableFTS": true,
"useSQLCipher": true,
"android": {
// Override the shared configuration for Android
"enableFTS": false,
"useSQLCipher": false
},
"ios": {
// You can also override the shared configurations for iOS
"customBuildFlags": [
"-DSQLITE_ENABLE_DBSTAT_VTAB=1 -DSQLITE_ENABLE_SNAPSHOT=1"
]

View File

@ -1,11 +1,21 @@
import * as React from "react";
import { NavigationContainer } from "@react-navigation/native";
import TTNavStack from "@/components/TTNavStack";
import * as ScreenOrientation from "expo-screen-orientation";
import { NavigationContainer } from "@react-navigation/native";
import { migrateDb } from "./lib/db";
import { Text } from "react-native";
export default function Layout() {
return (
<NavigationContainer>
<TTNavStack />
</NavigationContainer>
);
const [loaded, setLoaded] = React.useState<boolean>(true);
React.useEffect(() => {
(async function () {
await migrateDb();
await ScreenOrientation.unlockAsync();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
);
setLoaded(true);
})();
});
return loaded ? <TTNavStack /> : <Text>Loading...</Text>;
}

View File

@ -24,16 +24,23 @@ export type language_matrix = {
[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 class LanguageServer {
constructor(public baseUrl : string) {}
async fetchLanguages() : Promise<language_matrix> {
async fetchLanguages(timeout = 500) : Promise<language_matrix> {
let data = {};
const res = await fetch(this.baseUrl + "/languages", {
const res = await fetchWithTimeout(this.baseUrl + "/languages", {
headers: {
"Content-Type": "application/json"
}
});
}, timeout);
try {
data = await res.json();
} catch (e) {
@ -55,16 +62,20 @@ export class LanguageServer {
static async getDefault() {
const settings = await Settings.getDefault();
return new LanguageServer(await settings.getLibretranslateBaseUrl());
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;
}
async translate(text : string, target : string|undefined = undefined) {
const url = this.languageServer.baseUrl + `/translate`;
const url = this._languageServer.baseUrl + `/translate`;
const res = await fetch(url, {
method: "POST",
body: JSON.stringify({
@ -103,4 +114,10 @@ export class CachedTranslator extends Translator {
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())
}
}

View File

@ -12,7 +12,7 @@ export function chooseCountry(lang_a2 : string) {
c => c.languages.includes(lang_a3.alpha3)
);
console.log("cc = %x, ", cs.map(c => c.alpha2))
// console.log("cc = %x, ", cs.map(c => c.alpha2))
return cs.filter(cc => Object.keys(LANG_FLAGS).includes(cc.alpha2.toLowerCase())).map(c => c.alpha2.toLowerCase());
}

View File

@ -4,7 +4,9 @@ import _LANGUAGES from "@/assets/languages.min.json"
export const LANG_FLAGS = _LANG_FLAGS
export function longLang(shortLang : string) {
return ((LANG_FLAGS as any)[shortLang] as any)["name"] as string
const obj = LANG_FLAGS[shortLang];
if (!obj) return undefined;
return obj["name"] as string;
}
export function lang_a3_a2(a3 : string) {

View File

@ -43,6 +43,9 @@ export default function Home() {
return (
<View style={styles.container}>
<Pressable onPress={() => navigation.navigate("Settings")}>
<Text>Settings</Text>
</Pressable>
<LanguageSelection onLangSelected={onLangSelected} />
</View>
);

View File

@ -1,12 +1,13 @@
import {describe, expect, beforeEach} from '@jest/globals';
import {Settings} from '@/app/lib/settings';
import { getDb } from '@/app/lib/db';
import { getDb, migrateDb } from '@/app/lib/db';
describe('Settings', () => {
let settings: Settings;
beforeEach(async () => {
// Initialize your Settings class here with a fresh database instance
await migrateDb();
const db = await getDb();
if (!db) throw new Error("Could not get db");
settings = new Settings(db);

View File

@ -31,11 +31,15 @@ export class Message {
}
}
type conversation_event_t = "add_message" | "translation_done"
type conversation_callback_t = (conversation : Conversation) => any;
export class Conversation extends Array<Message> {
public onAddMessage? : (conversation : Conversation) => any;
public onTranslationDone? : (conversation : Conversation) => any;
constructor (
public translator : Translator,
public host : Speaker,
@ -44,6 +48,15 @@ export class Conversation extends Array<Message> {
super();
}
public on(event : conversation_event_t, callback : conversation_callback_t) {
if (event === "add_message") {
this.onAddMessage = callback;
}
if (event === "translation_done") {
this.onTranslationDone = callback;
}
}
public addMessage(speaker : Speaker, text? : string) {
this.push(new Message(this, speaker, text));
}

View File

@ -1,40 +1,26 @@
import * as SQLite from 'expo-sqlite';
import * as SQLite from "expo-sqlite";
import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations";
export const MIGRATE_UP = {
1: [
`CREATE TABLE IF NOT EXISTS settings (
host_language TEXT,
libretranslate_base_url TEXT,
ui_direction INTEGER
)`,
],
2: [
`CREATE TABLE IF NOT EXISTS whisper_models (
model TEXT PRIMARY KEY,
bytes_done INTEGER,
bytes_total INTEGER,
)`,
]
export async function getDb() {
return await SQLite.openDatabaseAsync("translation_terrace");
}
export const MIGRATE_DOWN = {
1: [
`DROP TABLE IF EXISTS settings`
],
2: [
`DROP TABLE IF EXISTS whisper_models`
]
}
export async function getDb(migrationDirection : "up" | "down" = "up") {
const db = await SQLite.openDatabaseAsync('translation_terrace');
export async function migrateDb(direction: "up" | "down" = "up") {
for (let [migration, statements] of Object.entries(MIGRATE_UP)) {
for (let statement of statements) {
console.log(statement)
await db.runAsync(statement);
}
const db = await getDb();
const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN;
for (let [migration, statements] of Object.entries(m)) {
for (let statement of statements) {
console.log(statement);
try {
const result = await db.runAsync(statement);
console.log(result);
} catch (err) {
console.error(err);
}
}
return db;
}
}

23
app/lib/migrations.ts Normal file
View File

@ -0,0 +1,23 @@
export const MIGRATE_UP = {
1: [
`CREATE TABLE IF NOT EXISTS settings (
host_language TEXT,
libretranslate_base_url TEXT,
ui_direction INTEGER,
whisper_model TEXT
)`,
],
2: [
`CREATE TABLE IF NOT EXISTS whisper_models (
model TEXT PRIMARY KEY,
bytes_done INTEGER,
bytes_total INTEGER
)`,
],
};
export const MIGRATE_DOWN = {
1: [`DROP TABLE IF EXISTS settings`],
2: [`DROP TABLE IF EXISTS whisper_models`],
};

View File

@ -8,7 +8,7 @@ export class Settings {
"host_language",
"libretranslate_base_url",
'ui_direction',
"wisper_model",
"whisper_model",
]
constructor(public db: SQLiteDatabase) {

View File

@ -1,5 +1,5 @@
import { Platform } from "react-native";
import FileSystem from "expo-file-system";
import * as FileSystem from "expo-file-system";
import { File, Paths } from 'expo-file-system/next';
import { getDb } from "./db";
@ -145,10 +145,13 @@ export async function downloadWhisperModel(
}
) {
console.debug("Starting download of %s", whisper_model);
if (!WHISPER_MODEL_DIR.exists) {
await FileSystem.makeDirectoryAsync(WHISPER_MODEL_PATH, {
intermediates: true,
})
});
console.debug("Created %s", WHISPER_MODEL_DIR);
}
const whisperTarget = getWhisperTarget(whisper_model);
@ -179,6 +182,7 @@ export async function downloadWhisperModel(
data.totalBytesWritten,
data.totalBytesExpectedToWrite,
];
console.log("%s, %s of %s", whisper_model, data.totalBytesWritten, data.totalBytesExpectedToWrite);
await db.runAsync(
`INSERT OR REPLACE INTO whisper_models (model, bytes_done, bytes_remaining) VALUES (?, ?, ?)`,
args
@ -187,4 +191,4 @@ export async function downloadWhisperModel(
);
await resumable.downloadAsync();
}
}

View File

@ -1,13 +1,9 @@
import React, { useState, useEffect } from "react";
import { ScrollView, Text, TouchableHighlight, View } from "react-native";
import { 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 { WhisperContext } from "whisper.rn";
import { NavigationProp, ParamListBase } from "@react-navigation/native";
import { CachedTranslator, LanguageServer } from "@/app/i18n/api";
import { getDb } from "@/app/lib/db";
import LiveAudioStream from "react-native-live-audio-stream";
import { CachedTranslator, LanguageServer, language_matrix_entry } from "@/app/i18n/api";
const lasOptions = {
sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition
@ -35,18 +31,45 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
undefined | CachedTranslator
>();
const [languageLabels, setLanguageLabels] = useState<undefined | {
hostNative: {
host: string,
guest: string,
},
guestNative: {
host: string,
guest: string,
}
}>()
useEffect(() => {
(async () => {
setCachedTranslator(
new CachedTranslator(
"en",
conversation.guest.language,
await LanguageServer.getDefault()
)
);
if (!cachedTranslator) throw new Error("cachedTranslator is undefined");
setGuestSpeak(await cachedTranslator.translate("Speak"));
const languageServer = await LanguageServer.getDefault();
const languages = await languageServer.fetchLanguages();
const cc = new CachedTranslator(
"en",
conversation.guest.language,
languageServer,
)
setCachedTranslator(cc);
setGuestSpeak(await cc.translate("Speak"));
const hostLang1 = languages[conversation.host.language].name;
const guestLang1 = languages[conversation.host.language].name;
const hostLang2 = await cc.translate(languages[conversation.host.language].name);
const guestLang2 = await cc.translate(languages[conversation.host.language].name);
setLanguageLabels({
hostNative: {
host: hostLang1,
guest: guestLang1,
},
guestNative: {
host: hostLang2,
guest: guestLang2,
}
})
})();
const updateMessages = (c: Conversation) => {
setMessages([...c]);
};
@ -67,7 +90,11 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
return cachedTranslator ? (
<View style={{ flex: 1, flexDirection: "column" }}>
<Text>Conversation Thread</Text>
{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>
</View>)
}
<ScrollView
style={{
borderColor: "black",
@ -106,4 +133,16 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
);
};
const styles = StyleSheet.create({
languageLabels: {
},
nativeHostLabel: {
},
nativeGuestLabel: {
},
})
export default ConversationThread;

View File

@ -3,11 +3,11 @@ import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import { useEffect, useState } from "react";
import ISpeakButton from "./ui/ISpeakButton";
import { LANG_FLAGS } from "@/app/i18n/lang";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
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 } from "expo-router";
import { Link, useNavigation } from "expo-router";
export function LanguageSelection(props: {
@ -17,9 +17,11 @@ export function LanguageSelection(props: {
}) {
const [languages, setLanguages] = useState<language_matrix | undefined>();
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
const [translator, setTranslator] = useState<Translator|undefined>();
const nav = useNavigation();
const languageServer = new LanguageServer(LIBRETRANSLATE_BASE_URL);
const translator = props.translator || new CachedTranslator("en", undefined, languageServer);
function onLangSelected(language: language_matrix_entry) {
props.onLangSelected && props.onLangSelected(language)
@ -27,33 +29,32 @@ export function LanguageSelection(props: {
useEffect(() => {
const fetchData = async () => {
(async () => {
try {
// Replace with your actual async data fetching logic
const languages = await languageServer.fetchLanguages();
setTranslator(await CachedTranslator.getDefault());
const languageServer = await LanguageServer.getDefault();
const languages = await languageServer.fetchLanguages(5000);
setLanguages(languages);
setLanguagesLoaded(true);
} catch (error) {
error = error as Response;
console.error('Error fetching data (%d %s): %s', (error as Response).status, (error as Response).statusText, (error as Response).body);
console.error('Error fetching languages from %s: %s', languageServer.baseUrl, error);
}
};
fetchData();
})();
}, []);
return (
<View>
<Link href={"/settings"}>
<Pressable onPress={() => nav.navigate('Settings')}>
<Text>Settings</Text>
</Link>
</Pressable>
<ScrollView >
<SafeAreaProvider >
<SafeAreaView>
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
([lang, lang_entry]) => {
return (
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} />
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} translator={translator} />
);
}
) : <Text>Waiting...</Text>

View File

@ -3,164 +3,280 @@ import React, { useState, useEffect } from "react";
import { View, Text, TextInput, StyleSheet, Pressable } from "react-native"; // Add Picker import
import { getDb } from "@/app/lib/db";
import { Settings } from "@/app/lib/settings";
import { LanguageServer } from "@/app/i18n/api";
import {Picker} from "@react-native-picker/picker"
import { LanguageServer, fetchWithTimeout } from "@/app/i18n/api";
import { Picker } from "@react-native-picker/picker";
import { longLang } from "@/app/i18n/lang";
import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
import { WHISPER_MODELS, downloadWhisperModel, download_status, getWhisperDownloadStatus, whisper_model_tag_t } from "@/app/lib/whisper";
import {
WHISPER_MODELS,
WHISPER_MODEL_DIR,
downloadWhisperModel,
download_status,
getWhisperDownloadStatus,
getWhisperTarget,
whisper_model_tag_t,
} from "@/app/lib/whisper";
import { Paths } from "expo-file-system/next";
type Language = {
code: string;
name: string;
code: string;
name: string;
};
type LanguageMatrix = {
[key: string]: Language;
[key: string]: Language;
};
type connection_test_t =
| {
success: true;
}
| {
success: false;
error: string;
};
const SettingsComponent: React.FC = () => {
const [hostLanguage, setHostLanguage] = useState<string | null>(null);
const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState<string | null>(null);
const [languages, setLanguages] = useState<undefined|LanguageMatrix>();
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [whisperModel, setWhisperModel] = useState<undefined|whisper_model_tag_t>()
const [downloadStatus, setDownloadStatus] = useState<undefined|download_status>();
const [hostLanguage, setHostLanguage] = useState<string | null>(null);
const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState<
string | null
>(null);
const [languages, setLanguages] = useState<undefined | LanguageMatrix>();
const [isLoaded, setIsLoaded] = useState<boolean>(false);
const [whisperModel, setWhisperModel] = useState<
undefined | whisper_model_tag_t
>();
const [downloadStatus, setDownloadStatus] = useState<
undefined | download_status
>();
useEffect(() => {
(async () => {
// Fetch the database connection
const db = await getDb();
const settings = new Settings(db);
// Get the current settings values
const hostLang = await settings.getHostLanguage();
const libretranslateUrl = await settings.getLibretranslateBaseUrl();
const langServer = new LanguageServer(libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL);
const wModel = await settings.getWhisperModel()
const [langServerConn, setLangServerConn] = useState<
undefined | connection_test_t
>();
// Fetch languages from API
const langData = await langServer.fetchLanguages();
setLanguages(langData);
setHostLanguage(hostLang || "en");
setLibretranslateBaseUrl(libretranslateUrl);
setWhisperModel(wModel);
setIsLoaded(true);
})();
// Check for whether a model is currently downloading and set the status.
setInterval(async () => {
if (!whisperModel) return null;
const dlStatus = await getWhisperDownloadStatus(whisperModel);
setDownloadStatus(dlStatus)
}, 200);
}, []);
const doReadownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel, {force_redownload: true});
}
const doDownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel)
}
const handleHostLanguageChange = async (value: string) => {
setHostLanguage(value);
// Fetch the database connection
const db = await getDb();
const settings = new Settings(db);
// Save the updated setting value
await settings.setHostLanguage(value);
};
const handleLibretranslateBaseUrlChange = async (value: string) => {
setLibretranslateBaseUrl(value);
// Fetch the database connection
const db = await getDb();
const settings = new Settings(db);
// Save the updated setting value
await settings.setLibretranslateBaseUrl(value);
};
return (
isLoaded ? <View style={styles.container}>
<Text style={styles.label}>Host Language:</Text>
<Picker
selectedValue={hostLanguage || ""}
style={{ height: 50, width: "100%" }}
onValueChange={handleHostLanguageChange}
accessibilityHint="language"
>
{languages && Object.keys(languages).map((langCode) => (
<Picker.Item key={langCode} label={longLang(langCode)} value={langCode} />
))}
</Picker>
<Text style={styles.label}>LibreTranslate Base URL:</Text>
<TextInput
style={styles.input}
value={libretranslateBaseUrl || ""}
onChangeText={handleLibretranslateBaseUrlChange}
accessibilityHint="libretranslate base url"
/>
<Picker
selectedValue={whisperModel || ""}
style={{ height: 50, width: "100%" }}
onValueChange={setWhisperModel}
accessibilityHint="language"
>
{Object.entries(WHISPER_MODELS).map(([key, {label}]) => (
<Picker.Item key={key} label={label} value={key} />
))}
</Picker>
{whisperModel && (
<View>
{
downloadStatus?.status === "complete" ? (
<Pressable onPress={doReadownload}>
Re-Download
</Pressable>
) : (
downloadStatus?.status === "in_progress" ? (
<Text>
{downloadStatus.bytes.done / downloadStatus.bytes.total * 100.0} % complete
{ downloadStatus.bytes.done } bytes of { downloadStatus.bytes.total }
</Text>
) : (
<Pressable onPress={doDownload}>
Download
</Pressable>
)
)
}
</View>
)}
</View> : <View><Text>Loading ...</Text></View>
const fillHostLanguageOptions = async () => {
const settings = await Settings.getDefault();
const hostLang = await settings.getHostLanguage();
setHostLanguage(hostLang || "en");
const langServer = new LanguageServer(
libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL
);
// Fetch languages from API
try {
const langData = await langServer.fetchLanguages();
setLanguages(langData);
setLangServerConn({ success: true });
} catch (err) {
console.warn("Got an error fetching: %s", err);
setLangServerConn({
success: false,
error: `Could not connect to ${libretranslateBaseUrl}: ${err}`,
});
}
}
useEffect(() => {
(async () => {
// Fetch the database connection
// const db = await getDb("down");
const settings = await Settings.getDefault();
await fillHostLanguageOptions();
console.log("Fetched settings");
// Get the current settings values
const libretranslateUrl =
(await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL;
setLibretranslateBaseUrl(libretranslateUrl);
console.log("libretranslate url = %s", libretranslateUrl);
try {
const wModel = await settings.getWhisperModel();
setWhisperModel(wModel || "small");
} catch (err) {
console.warn(err);
}
// setWhisperModel(wModel);
setIsLoaded(true);
// console.log("Set is loaded: %s", isLoaded);
})();
// Check for whether a model is currently downloading and set the status.
setInterval(async () => {
if (!whisperModel) return null;
const dlStatus = await getWhisperDownloadStatus(whisperModel);
setDownloadStatus(dlStatus);
}, 200);
setInterval(async () => {
if (!libretranslateBaseUrl) return;
try {
const resp = await fetchWithTimeout(
libretranslateBaseUrl + "/languages",
{
method: "HEAD",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
},
5000
);
if (resp.status !== 200) {
throw new Error(resp.statusText);
}
setLangServerConn({ success: true });
} catch (err) {
setLangServerConn({
success: false,
error: `Could not connect to ${libretranslateBaseUrl}: ${err}`,
});
}
}, 1000);
setInterval(async () => {
const settings = await Settings.getDefault();
await settings.setHostLanguage(hostLanguage || "en");
await settings.setLibretranslateBaseUrl(
libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL
);
await settings.setWhisperModel(whisperModel || "small");
}, 1000);
}, []);
const doReadownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel, { force_redownload: true });
};
const doDownload = async () => {
if (!whisperModel) return;
await downloadWhisperModel(whisperModel);
};
const handleHostLanguageChange = async (value: string) => {
setHostLanguage(value);
// Fetch the database connection
const db = await getDb();
const settings = new Settings(db);
// Save the updated setting value
await settings.setHostLanguage(value);
};
const handleLibretranslateBaseUrlChange = async (value: string) => {
setLibretranslateBaseUrl(value);
const settings = await Settings.getDefault();
// Save the updated setting value
await settings.setLibretranslateBaseUrl(value);
await fillHostLanguageOptions();
};
return isLoaded ? (
<View style={styles.container}>
<Text style={styles.label}>Host Language:</Text>
<Picker
selectedValue={hostLanguage || ""}
style={{ height: 50, width: "100%" }}
onValueChange={handleHostLanguageChange}
accessibilityHint="hostLanguage"
>
{languages &&
Object.entries(languages).map((lang) => (
<Picker.Item key={lang[0]} label={lang[1].name} value={lang[0]} />
))}
</Picker>
<Text style={styles.label}>LibreTranslate Base URL:</Text>
<TextInput
style={styles.input}
value={libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL}
onChangeText={handleLibretranslateBaseUrlChange}
accessibilityHint="libretranslate base url"
/>
{langServerConn &&
(langServerConn.success ? (
<Text>Success connecting to {libretranslateBaseUrl}</Text>
) : (
<Text>
Error connecting to {libretranslateBaseUrl}: {langServerConn.error}
</Text>
))}
<Picker
selectedValue={whisperModel || ""}
style={{ height: 50, width: "100%" }}
onValueChange={setWhisperModel}
accessibilityHint="language"
>
{Object.entries(WHISPER_MODELS).map(([key, { label }]) => (
<Picker.Item key={key} label={label} value={key} />
))}
</Picker>
{whisperModel && (
<View>
{downloadStatus?.status === "complete" ? (
<Pressable onPress={doReadownload}>Re-Download</Pressable>
) : downloadStatus?.status === "in_progress" ? (
<Text>
{(downloadStatus.bytes.done / downloadStatus.bytes.total) * 100.0}{" "}
% complete
{downloadStatus.bytes.done} bytes of {downloadStatus.bytes.total}
</Text>
) : (
<View>
<Pressable onPress={doDownload} style={styles.button}>
<Text style={styles.buttonText}>Download</Text>
</Pressable>
<Text>
This will download to {Paths.join(WHISPER_MODEL_DIR, WHISPER_MODELS[whisperModel].target)}
</Text>
</View>
)}
</View>
)}
</View>
) : (
<View>
<Text>Loading ...</Text>
</View>
);
};
// Create styles for the component
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
label: {
fontSize: 16,
marginBottom: 8,
},
input: {
height: 40,
borderColor: "gray",
borderWidth: 1,
marginBottom: 20,
paddingHorizontal: 8,
},
button: {
backgroundColor: "blue",
flexDirection: "row",
display: "flex",
flexShrink: 1,
padding: 20,
alignItems: "center",
alignContent: "center",
},
buttonText: {
color: "white",
alignSelf: "center",
},
container: {
flex: 1,
padding: 20,
},
label: {
fontSize: 16,
marginBottom: 8,
},
input: {
height: 40,
borderColor: "gray",
borderWidth: 1,
marginBottom: 20,
paddingHorizontal: 8,
},
});
export default SettingsComponent;
export default SettingsComponent;

View File

@ -1,42 +1,42 @@
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import SettingsComponent from '@/components/Settings';
import { LanguageSelection } from '@/components/LanguageSelection';
import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack';
import * as React from "react";
import SettingsComponent from "@/components/Settings";
import { LanguageSelection } from "@/components/LanguageSelection";
import {
useNavigation,
} from '@react-navigation/native'
import ConversationThread from '@/components/ConversationThread';
import { language_matrix_entry, Translator } from '@/app/i18n/api';
import { useRouter } from 'expo-router';
import { Conversation } from '@/app/lib/conversation';
import { Settings } from '@/app/lib/settings';
import { RootStackParamList } from '@/navigation.types';
createNativeStackNavigator,
NativeStackNavigationProp,
} from "@react-navigation/native-stack";
import { useNavigation } from "@react-navigation/native";
import ConversationThread from "@/components/ConversationThread";
import { language_matrix_entry, Translator } from "@/app/i18n/api";
import { Conversation } from "@/app/lib/conversation";
import { Settings } from "@/app/lib/settings";
import { RootStackParamList } from "@/navigation.types";
const Stack = createNativeStackNavigator();
export default function TTNavStack() {
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'ConversationThread'>>();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, "Conversation">>()
async function onLangSelected(lang: language_matrix_entry) {
const settings = await Settings.getDefault();
const hostLanguage = await settings.getHostLanguage();
const conversation = new Conversation(
(await Translator.getDefault(lang.code)),
await Translator.getDefault(lang.code),
{ id: "host", language: hostLanguage },
{ "id": "guest", language: lang.code, }
)
nav.navigate("Conversation", { conversation, })
{ id: "guest", language: lang.code }
);
nav.navigate("ConversationThread", { conversation });
}
return (
<Stack.Navigator initialRouteName='LanguageSelection'>
<Stack.Screen name="LanguageSelection" >
{ props => <LanguageSelection {...props} onLangSelected={(l) => onLangSelected(l)} />}
</Stack.Screen>
<Stack.Screen name="ConversationThread" component={ConversationThread} />
<Stack.Screen name="Settings" component={SettingsComponent} />
</Stack.Navigator>
<Stack.Navigator initialRouteName="LanguageSelection">
<Stack.Screen name="LanguageSelection">
{(props) => (
<LanguageSelection {...props} onLangSelected={onLangSelected} />
)}
</Stack.Screen>
<Stack.Screen name="ConversationThread" component={ConversationThread} />
<Stack.Screen name="Settings" component={SettingsComponent} />
</Stack.Navigator>
);
}
}

View File

@ -1,35 +1,61 @@
import {dirname, resolve} from 'path'
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import { createStackNavigator } from '@react-navigation/stack';
jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts"));
import { renderRouter} from 'expo-router/testing-library';
import React from "react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react-native";
import {
NavigationContainer,
createNavigationContainerRef,
} from "@react-navigation/native";
import TTNavStack from "../TTNavStack";
import TTNavStack from '../TTNavStack';
const Stack = createStackNavigator();
describe('Navigation', () => {
describe("Navigation", () => {
beforeEach(() => {
// Reset the navigation state before each test
jest.clearAllMocks();
jest.useFakeTimers();
});
it('Navigates to ConversationThread on language selection', async () => {
render(<TTNavStack />);
const languageSelectionText = await screen.findByText(/I Speak French\./i);
it("Navigates to ConversationThread on language selection", async () => {
const MockComponent = jest.fn(() => <TTNavStack />);
renderRouter(
{
index: MockComponent,
},
{
initialUrl: '/',
}
);
const languageSelectionText = await waitFor(() =>
screen.getByText(/.*I Speak French.*/i)
);
act(() => {
fireEvent.press(languageSelectionText);
})
});
expect(await screen.findByText("Conversation Thread")).toBeOnTheScreen();
});
it('Navigates to Settings on settings selection', async () => {
render(<TTNavStack />);
const settingsButton = await screen.findByText("Settings");
act(() => {
fireEvent.press(settingsButton)
})
expect(await screen.findByText("Settings")).toBeOnTheScreen();
it("Navigates to Settings on settings selection", async () => {
const MockComponent = jest.fn(() => <TTNavStack />);
renderRouter(
{
index: MockComponent,
},
{
initialUrl: '/',
}
);
const settingsButton = await waitFor(() =>
screen.getByText(/.*Settings.*/i)
);
fireEvent.press(settingsButton);
expect(await waitFor(() => screen.getByText(/Settings/i))).toBeOnTheScreen();
// expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy()
expect(screen.getByText("Settings")).toBeOnTheScreen();
});
});
});

View File

@ -1,121 +1,142 @@
// import AsyncStorage from '@react-native-async-storage/async-storage';
import { CachedTranslator, Translator, language_matrix_entry } from "@/app/i18n/api"
import { longLang } from "@/app/i18n/lang"
import React, { useEffect, useRef, useState } from "react"
import { Button, Image, ImageBackground, Pressable, StyleSheet, TouchableOpacity, View } from "react-native"
import { Text } from 'react-native';
import {
CachedTranslator,
Translator,
language_matrix_entry,
} from "@/app/i18n/api";
import { longLang } from "@/app/i18n/lang";
import React, { useEffect, useRef, useState } from "react";
import {
Button,
Image,
ImageBackground,
Pressable,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Text } from "react-native";
import CountryFlag from "react-native-country-flag";
import { chooseCountry } from '@/app/i18n/countries';
import { chooseCountry } from "@/app/i18n/countries";
type ISpeakButtonProps = {
language: language_matrix_entry,
translator?: Translator,
onLangSelected?: (lang : language_matrix_entry) => any | Promise<any>,
language: language_matrix_entry;
translator?: Translator;
onLangSelected?: (lang: language_matrix_entry) => any | Promise<any>;
};
function iSpeak(language: language_matrix_entry) {
return `I speak ${language.name}.`;
}
function iSpeak(language : language_matrix_entry) {
return `I speak ${language.name}.`
}
async function iSpeakTr(translator : CachedTranslator, targetLang : language_matrix_entry) {
const sourceStr = iSpeak(targetLang)
return await translator.translate(sourceStr, targetLang.code);
async function iSpeakTr(
translator: CachedTranslator,
targetLang: language_matrix_entry
) {
const sourceStr = iSpeak(targetLang);
return await translator.translate(sourceStr, targetLang.code);
}
const DEFAULT_FLAGS = {
"en": ["us", "gb"],
// "sq": ["al"],
"ar": ["ae"],
"es": ["es"],
"pt": ["pt"],
"ru": ["ru"],
"it": ["it"],
"ir": ["ie"],
"sk": ["sk"],
"ro": ["ro"],
"ja": ["jp"],
"ko": ["kp", "kr"],
"el": ["gr"],
"fr": ["fr"],
"de": ["de"],
"nl": ["nl"],
"cz": ["cz"],
"uk": ["ua"],
"he": ["il"],
"hi": ["in"],
"gl": ["es"],
"fa": ["ir"],
"ur": ["pk"],
"ga": ["ie"],
"eo": ["es"]
}
en: ["us", "gb"],
// "sq": ["al"],
ar: ["ae"],
es: ["es"],
pt: ["pt"],
ru: ["ru"],
it: ["it"],
ir: ["ie"],
sk: ["sk"],
ro: ["ro"],
ja: ["jp"],
ko: ["kp", "kr"],
el: ["gr"],
fr: ["fr"],
de: ["de"],
nl: ["nl"],
cz: ["cz"],
uk: ["ua"],
he: ["il"],
hi: ["in"],
gl: ["es"],
fa: ["ir"],
ur: ["pk"],
ga: ["ie"],
eo: ["es"],
};
const ISpeakButton = (props : ISpeakButtonProps) => {
const ISpeakButton = (props: ISpeakButtonProps) => {
const [title, setTitle] = useState<string | undefined>();
const [titleLoaded, setTitleLoaded] = useState<boolean>(false);
const [translator, setTranslator] = useState<Translator | undefined>(
undefined
);
const [title, setTitle] = useState<string | undefined>();
const [titleLoaded, setTitleLoaded] = useState<boolean>(false);
const translator = props.translator || new CachedTranslator("en");
useEffect(() => {
(async function () {
const tr = props.translator || (await CachedTranslator.getDefault());
if (!tr) {
console.error("Failed to construct cachedTranslator");
}
setTranslator(tr);
try {
// Replace with your actual async data fetching logic
const title2 = await iSpeakTr(tr, props.language);
setTitle(title2);
} catch (error) {
console.error("Error fetching data from %s: %s", tr.languageServer.baseUrl, error);
} finally {
setTitleLoaded(true);
}
})();
}, []);
useEffect(() => {
const fetchData = async () => {
try {
// Replace with your actual async data fetching logic
const title = await iSpeakTr(translator, props.language);
setTitle(title);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setTitleLoaded(true);
}
};
fetchData();
}, []);
const countries = DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
return (
title ? (
<TouchableOpacity style={styles.button} onPress={() => props.onLangSelected && props.onLangSelected(props.language)}>
<View>
<View style={styles.flag}>
{countries &&
countries.map( c => {
return <CountryFlag isoCode={c} size={25} key={c}/> }
)
}
</View>
<View style={styles.iSpeak}>
<Text style={styles.iSpeakText}>{ title }</Text>
</View>
</View>
</TouchableOpacity>
) : (
<Text>Loading...</Text>
)
)
const countries =
// @ts-ignore
DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
}
return title ? (
<TouchableOpacity
style={styles.button}
onPress={() =>
props.onLangSelected && props.onLangSelected(props.language)
}
>
<View>
<View style={styles.flag}>
{countries &&
countries.map((c) => {
return <CountryFlag isoCode={c} size={25} key={c} />;
})}
</View>
<View>
<Text style={styles.iSpeakText}>{title}</Text>
</View>
</View>
</TouchableOpacity>
) : (
<Text>Loading...</Text>
);
};
const styles = StyleSheet.create({
button: {
width: "20%",
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
height: 110,
alignSelf: "flex-start",
margin: 8,
},
flag: {
},
iSpeak: {
textAlign: "center",
},
iSpeakText: {
textAlign: "center"
}
})
button: {
width: "20%",
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
height: 110,
alignSelf: "flex-start",
margin: 8,
},
flag: {},
iSpeak: {
textAlign: "center",
},
iSpeakText: {
textAlign: "center",
},
});
export default ISpeakButton;
export default ISpeakButton;

View File

@ -1,9 +1,9 @@
import React, { Dispatch } from "react";
import { render, screen, fireEvent, act } from "@testing-library/react-native";
import SettingsComponent from "@/components/Settings";
import { Settings } from "@/app/lib/settings";
import { getDb } from "@/app/lib/db";
import { language_matrix } from "@/app/i18n/api";
import { Settings } from "@/app/lib/settings";
import { getDb, migrateDb } from "@/app/lib/db";
const RENDER_TIME = 1000;
@ -49,45 +49,10 @@ jest.mock("@/app/i18n/api", () => {
}
})
jest.mock("@/app/lib/db", () => {
return {
getDb: jest.fn(() => {
return {
runAsync: jest.fn((statement : string, value : string) => {}),
getFirstAsync: jest.fn((statement : string, value : string) => {
return []
}),
}
})
}
})
jest.mock("@/app/lib/settings", () => {
const originalModule = jest.requireActual('@/app/lib/settings');
class MockSettings {
public constructor(public db = {}) {}
public setHostLanguage = jest.fn((val : string) => {
})
public setLibretranslateBaseUrl(val : string) {
}
getHostLanguage = jest.fn(() => {
return "en"
})
getLibretranslateBaseUrl = jest.fn(() => {
return "http://localhost:5004"
});
}
return {
...originalModule,
Settings: MockSettings
}
})
describe("SettingsComponent", () => {
beforeEach(async() => {
const settings = new Settings(await getDb());
await migrateDb();
const settings = await Settings.getDefault();
await settings.setHostLanguage("en");
await settings.setLibretranslateBaseUrl("https://example.com");
})
@ -116,13 +81,12 @@ describe("SettingsComponent", () => {
test("updates host language setting when input changes", async () => {
render(<SettingsComponent />);
// Wait for the component to fetch and display the initial settings
await screen.findByText(/Host Language:/i);
await screen.findByText(/LibreTranslate Base URL:/i);
// Change the host language input value
const picker = screen.getByAccessibilityHint("language");
const picker = screen.getByAccessibilityHint("hostLanguage");
fireEvent(picker, "onvalueChange", "es");
expect(picker.props.selectedIndex).toStrictEqual(0);
});

View File

@ -1,29 +1,71 @@
// jestSetup.ts
jest.mock('expo-sqlite', () => {
return {
openDatabaseAsync: async (name: string) => {
const {DatabaseSync} = require("node:sqlite")
const db = new DatabaseSync(':memory:');
// include this line for mocking react-native-gesture-handler
import 'react-native-gesture-handler/jestSetup';
return {
closeAsync: jest.fn(() => db.close()),
executeSql: jest.fn((sql: string) => db.exec(sql)),
runAsync: jest.fn(async (sql: string, params = []) => {
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 = []) => {
const stmt = db.prepare(sql)
// const result = stmt.run(...params);
return stmt.get(params)
})
};
},
jest.mock("expo-sqlite", () => {
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations");
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);
}),
};
});
};
return {
migrateDb: async (direction: "up" | "down" = "up") => {
const db = await openDatabaseAsync("translation_terrace");
for (let m of Object.values(MIGRATE_UP)) {
for (let stmt of m) {
await db.executeSql(stmt);
}
}
},
openDatabaseAsync,
};
});
// include this section and the NativeAnimatedHelper section for mocking react-native-reanimated
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock');
// The mock for `call` immediately calls the callback which is incorrect
// So we override it with a no-op
Reanimated.default.call = () => {};
return Reanimated;
});
// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
// jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');