Apk compilation

This commit is contained in:
2026-06-18 14:09:54 +02:00
parent 9a23863320
commit 5b9216e764
47 changed files with 4573 additions and 620 deletions

View File

@@ -1,4 +1,4 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import Voice from "@react-native-voice/voice";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import Svg, { Path } from "react-native-svg";
@@ -6,6 +6,7 @@ import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
NativeModules,
Platform,
Pressable,
ScrollView,
@@ -26,41 +27,22 @@ function formatDuration(durationMs: number) {
.padStart(2, "0")}`;
}
function buildMimeType(uri: string) {
const extension = uri.split(".").pop()?.split("?")[0]?.toLowerCase();
switch (extension) {
case "wav":
return "audio/wav";
case "caf":
return "audio/x-caf";
case "webm":
return "audio/webm";
case "mp3":
return "audio/mpeg";
default:
return "audio/m4a";
}
}
function buildFileExtension(uri: string) {
return uri.split(".").pop()?.split("?")[0]?.toLowerCase() || "m4a";
}
export default function RecorderScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
const [locale, setLocale] = useState<Locale>("ca");
const [strings, setStrings] = useState(() => getStrings("ca"));
const [recording, setRecording] = useState<Audio.Recording | null>(null);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
const [recordingMs, setRecordingMs] = useState(0);
const [transcript, setTranscript] = useState("");
const [interimTranscript, setInterimTranscript] = useState("");
const [statusMessage, setStatusMessage] = useState("");
const [responsePreview, setResponsePreview] = useState("");
const [isUploading, setIsUploading] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isHolding, setIsHolding] = useState(false);
const recordingRef = useRef<Audio.Recording | null>(null);
const [listeningMs, setListeningMs] = useState(0);
const startRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const listeningActiveRef = useRef(false);
const refreshSettings = useCallback(() => {
let isMounted = true;
@@ -75,7 +57,6 @@ export default function RecorderScreen() {
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
setLocale(settings.language);
setStrings(getStrings(settings.language));
} catch {
@@ -95,222 +76,187 @@ export default function RecorderScreen() {
useFocusEffect(refreshSettings);
useEffect(() => {
if (!recording) {
if (Platform.OS === "web" || !NativeModules.Voice) {
return;
}
const interval = setInterval(() => {
void recording.getStatusAsync().then((status) => {
if (typeof status.durationMillis === "number") {
setRecordingMs(status.durationMillis ?? 0);
}
});
}, 250);
Voice.onSpeechStart = (e) => {
console.log("Voice.onSpeechStart", e);
setTranscript("");
setInterimTranscript("");
setStatusMessage(strings.recording);
startRef.current = Date.now();
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current;
setListeningMs(elapsed);
}, 250);
};
Voice.onSpeechEnd = (e) => {
console.log("Voice.onSpeechEnd", e);
listeningActiveRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
Voice.onSpeechResults = (e) => {
console.log("Voice.onSpeechResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setTranscript(last);
}
};
Voice.onSpeechPartialResults = (e) => {
console.log("Voice.onSpeechPartialResults", e);
const values = (e as unknown as { value: string[] }).value;
if (values && values.length > 0) {
const last = values[values.length - 1];
setInterimTranscript(last || "");
}
};
Voice.onSpeechError = (e) => {
console.log("Voice.onSpeechError", e);
const message = (e as unknown as { error?: { message: string } }).error?.message || "Speech recognition error";
setStatusMessage(message);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setListeningMs(0);
listeningActiveRef.current = false;
Alert.alert(strings.recordingFailedTitle, message);
};
return () => {
clearInterval(interval);
Voice.removeAllListeners();
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [recording]);
}, []);
useEffect(() => {
return () => {
if (recording) {
void recording.stopAndUnloadAsync().catch(() => undefined);
if (listeningActiveRef.current && Platform.OS !== "web" && NativeModules.Voice) {
Voice.stop().catch(() => undefined);
listeningActiveRef.current = false;
}
};
}, [recording]);
}, []);
async function startRecording() {
async function startListening() {
try {
setResponsePreview("");
setRecordingUri(null);
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
setStatusMessage(strings.micPermissionDenied);
Alert.alert(
strings.micAccessRequiredTitle,
strings.micAccessRequiredMsg,
);
setTranscript("");
setInterimTranscript("");
const localeCode =
locale.includes("ca")
? "ca-ES"
: locale.includes("es")
? "es-ES"
: "en-US";
setIsHolding(true);
if (Platform.OS === "web") {
console.log("Voice not available on web");
Alert.alert("Not supported", "Speech recognition is only available on mobile devices. Open the app on Android or iOS.");
setIsHolding(false);
return;
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: false,
});
const result = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
recordingRef.current = result.recording;
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage(strings.recording);
if (!NativeModules.Voice) {
Alert.alert("Not supported", "Speech recognition module not found. Make sure the app is built with native modules.");
setIsHolding(false);
return;
}
await Voice.start(localeCode);
listeningActiveRef.current = true;
} catch (error) {
setStatusMessage(strings.couldNotStartRecording);
Alert.alert(
strings.recordingFailedTitle,
error instanceof Error ? error.message : "",
);
const msg = error instanceof Error ? error.message : "Failed to start speech recognition";
console.error("Voice.start failed:", error);
Alert.alert(strings.recordingFailedTitle, msg);
}
}
async function stopRecordingAndUpload() {
if (!recordingRef.current) {
async function stopListeningAndSend() {
setIsHolding(false);
const wasListening = listeningActiveRef.current;
if (!wasListening) {
setStatusMessage(transcript ? strings.voiceMessageSent : strings.readyToRecord);
return;
}
listeningActiveRef.current = false;
if (Platform.OS === "web") {
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
if (finalText) {
await sendCommand(finalText);
} else {
setStatusMessage(strings.readyToRecord);
}
return;
}
try {
const activeRecording = recordingRef.current;
const currentStatus = await activeRecording.getStatusAsync();
const durationMillis = currentStatus.durationMillis ?? 0;
await Voice.stop();
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const finalText = (transcript + " " + interimTranscript).trim().replace(/\s+/g, " ");
const uri = activeRecording.getURI();
recordingRef.current = null;
setRecording(null);
setRecordingMs(durationMillis);
if (!uri) {
if (!finalText) {
setStatusMessage(strings.readyToRecord);
return;
}
setRecordingUri(uri);
setStatusMessage(strings.finishedUpload);
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (uploadUrl) {
try {
const mimeType = buildMimeType(uri);
const extension = buildFileExtension(uri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: uri,
} as never);
const headers: Record<string, string> = {};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(uploadUrl, {
method: "POST",
headers,
body: formData,
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
if (!response.ok) {
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage(strings.uploadFailed);
Alert.alert(
strings.uploadFailed,
error instanceof Error ? error.message : "",
);
}
} else {
setStatusMessage(strings.noBackendUrl);
}
await sendCommand(finalText);
} catch (error) {
recordingRef.current = null;
setRecording(null);
setStatusMessage(strings.stopFailedTitle);
Alert.alert(
strings.stopFailedTitle,
error instanceof Error ? error.message : "",
);
listeningActiveRef.current = false;
const msg = error instanceof Error ? error.message : "Stop failed";
console.error("Voice.stop failed:", error);
setStatusMessage(msg);
}
}
async function handlePressIn() {
if (isUploading) return;
setIsHolding(true);
await startRecording();
}
async function sendCommand(text: string) {
const trimmedUrl = backendUrl.trim().replace(/\/+$/, "");
const commandUrl = trimmedUrl.endsWith("/commands")
? `${trimmedUrl}/text`
: `${trimmedUrl}/commands/text`;
async function handlePressOut() {
if (!isHolding) return;
setIsHolding(false);
await stopRecordingAndUpload();
}
async function uploadRecording(uriOverride?: string) {
const targetUri = uriOverride ?? recordingUri;
if (!targetUri) {
return;
}
const trimmedUrl = backendUrl.trim().replace(/\/+$/, '');
const uploadUrl = trimmedUrl.endsWith('/audio/upload')
? trimmedUrl
: `${trimmedUrl}/audio/upload`;
if (!uploadUrl) {
Alert.alert(strings.missingBackendUrlTitle, strings.missingBackendUrlMsg);
if (!commandUrl) {
setStatusMessage(strings.noBackendUrl);
return;
}
try {
setIsUploading(true);
setIsSending(true);
setStatusMessage(strings.uploadingRecording);
setResponsePreview("");
const mimeType = buildMimeType(targetUri);
const extension = buildFileExtension(targetUri);
const formData = new FormData();
formData.append(fieldName.trim() || "file", {
name: `recording-${Date.now()}.${extension}`,
type: mimeType,
uri: targetUri,
} as never);
const headers: Record<string, string> = {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(uploadUrl, {
const response = await fetch(commandUrl, {
method: "POST",
headers,
body: formData,
body: JSON.stringify({ text }),
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
setTranscript("");
setInterimTranscript("");
if (!response.ok) {
throw new Error(`${response.status}. ${responseText}`);
}
setStatusMessage(strings.uploadComplete);
setStatusMessage(strings.voiceMessageSent);
} catch (error) {
setStatusMessage(strings.uploadFailed);
Alert.alert(
@@ -318,17 +264,28 @@ export default function RecorderScreen() {
error instanceof Error ? error.message : "",
);
} finally {
setIsUploading(false);
setIsSending(false);
}
}
async function handlePressIn() {
if (isSending) return;
await startListening();
}
async function handlePressOut() {
await stopListeningAndSend();
}
const releaseLabel = t("releaseToStop", locale);
const holdLabel = t("holdToRecord", locale);
const openSettingsLabel = t("openSettingsHint", locale);
const openSettingsHint = t("openSettingsHint", locale);
const appTitleLabel = t("appTitle", locale);
const recorderTitleLabel = t("recorderTitle", locale);
const serverResponseLabel = t("serverResponse", locale);
const displayText = interimTranscript || transcript;
return (
<View style={styles.safeArea}>
<KeyboardAvoidingView
@@ -367,20 +324,20 @@ export default function RecorderScreen() {
<View style={styles.panel}>
<Text style={[styles.meterValueCentered, isHolding && { color: "#d04f2d" }]}>
{formatDuration(recordingMs)}
{formatDuration(listeningMs)}
</Text>
<Pressable
disabled={isUploading}
disabled={isSending}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.micButton,
isHolding ? styles.holdingButton : styles.idleButton,
isUploading && styles.buttonDisabled,
isSending && styles.buttonDisabled,
]}
>
{isUploading ? (
{isSending ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<Svg width="64" height="64" viewBox="0 0 24 24" fill="none">
@@ -406,21 +363,23 @@ export default function RecorderScreen() {
)}
</Pressable>
<Text style={styles.statusText}>{statusMessage || strings.readyToRecord}</Text>
<Text style={styles.statusText}>
{statusMessage || strings.readyToRecord}
</Text>
<Text style={styles.helperText}>
{isHolding
? releaseLabel
: backendUrl.trim()
? holdLabel
: openSettingsLabel}
: openSettingsHint}
</Text>
{responsePreview ? (
<View style={styles.responseBox}>
<Text style={styles.responseLabel}>{serverResponseLabel}</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
{displayText && (
<View style={styles.transcriptBox}>
<Text style={styles.transcriptLabel}>{strings.serverResponse}</Text>
<Text style={styles.transcriptText}>{displayText}</Text>
</View>
) : null}
)}
</View>
</ScrollView>
</KeyboardAvoidingView>
@@ -511,14 +470,6 @@ const styles = StyleSheet.create({
backgroundColor: "#d04f2d",
transform: [{ scale: 1.08 }],
},
micButtonText: {
color: "#fff6f3",
fontSize: 20,
fontWeight: "800",
},
recordingLabel: {
fontSize: 18,
},
buttonDisabled: {
opacity: 0.45,
},
@@ -534,6 +485,28 @@ const styles = StyleSheet.create({
lineHeight: 18,
textAlign: "center",
},
transcriptBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
maxWidth: "100%",
},
transcriptLabel: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
textAlign: "center",
},
transcriptText: {
color: "#36475a",
fontSize: 16,
lineHeight: 22,
textAlign: "center",
fontWeight: "500",
},
responseBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,