Compare commits

..

18 Commits

Author SHA1 Message Date
0e7fbbfdca Things
All checks were successful
Build / build-web (push) Successful in 16s
Build / build-backend (push) Successful in 3s
Build / release (push) Successful in 3s
Build APK / build (push) Successful in 8m10s
2026-06-11 15:09:56 +02:00
152b541a6c Test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 13:49:12 +02:00
44fe3361a3 Now working
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 13:41:39 +02:00
e43aee9b13 Wrong direction and also not enabled on boot
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 6s
2026-04-22 12:51:31 +02:00
d4c69e4b32 Frontend test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 6s
2026-04-22 12:48:27 +02:00
28457eb200 Frontend test
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 5s
2026-04-22 12:45:29 +02:00
f1c530e5b1 Motor driver fix
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 5s
2026-04-22 12:34:26 +02:00
1e3198d09b Motor driver fix
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 5s
2026-04-22 12:31:15 +02:00
965d6dde63 Test Motor
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 6s
2026-04-22 12:19:49 +02:00
e5a65f2dcf TEst CI
All checks were successful
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Successful in 7s
2026-04-22 11:54:16 +02:00
ad6d369dcf TEst CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:53:18 +02:00
5a4d0b9348 Test CI
All checks were successful
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Successful in 4s
2026-04-22 11:49:20 +02:00
42979daaf2 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:48:31 +02:00
f8172d9323 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 4s
2026-04-22 11:46:02 +02:00
4256f65161 Test CI
Some checks failed
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 13s
Build / release (push) Failing after 2s
2026-04-22 11:44:47 +02:00
c113139303 Test CI
Some checks failed
Build / build-backend (push) Successful in 4s
Build / build-web (push) Successful in 14s
Build / release (push) Failing after 1s
2026-04-22 11:43:05 +02:00
6ea0479059 Test CI
Some checks failed
Build / build-backend (push) Successful in 3s
Build / build-web (push) Successful in 12s
Build / release (push) Failing after 13s
2026-04-22 11:40:28 +02:00
0c673ab5f1 Test CI
Some checks failed
Build / build-backend (push) Failing after 4s
Build / build-web (push) Failing after 11s
Build / release (push) Has been skipped
2026-04-22 11:39:25 +02:00
35 changed files with 14196 additions and 9 deletions

0
.codex Normal file
View File

View File

@@ -0,0 +1,63 @@
name: Build APK
on:
push:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./apk
steps:
- name: 📥 Checkout
uses: actions/checkout@v4
- name: 🟢 Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: 📦 Install dependencies
run: npm install
- name: ☕ Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: 📱 Setup Android SDK
uses: android-actions/setup-android@v3
- name: ⚙️ Expo prebuild
run: npx expo prebuild --clean --non-interactive
- name: 🔐 Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/release.keystore
- name: 🏗️ Build APK (Release)
working-directory: ./apk/android
run: |
./gradlew assembleRelease
- name: 📦 Zip APK
working-directory: .
run: |
mkdir -p dist
cp apk/android/app/build/outputs/apk/release/app-release.apk dist/
zip -j dist/build.zip dist/app-release.apk
- name: Create Release
uses: https://gitea.com/actions/gitea-release-action@v1
working-directory: dist
with:
tag_name: latest
name: Latest Build
files: |
- build.zip
env:
GITEA_TOKEN: ${{ secrets.GITEA }}

View File

@@ -1,4 +1,4 @@
name: Build Nuxt App name: Build
on: on:
push: push:
@@ -6,7 +6,7 @@ on:
- master - master
jobs: jobs:
build: build-web:
runs-on: docker runs-on: docker
defaults: defaults:
@@ -43,12 +43,63 @@ jobs:
cd dist_package cd dist_package
zip -r ../quibot-web.zip . zip -r ../quibot-web.zip .
# Create or update release and upload asset - name: Upload Web artifact
- name: Upload Release uses: actions/upload-artifact@v3
uses: softprops/action-gh-release@v1 with:
name: quibot-web
path: quibot-web/quibot-web.zip
build-backend:
runs-on: docker
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: |
apt update
apt install -y zip
- name: Create zip
run: |
zip -r backend.zip .
- name: Upload Backend artifact
uses: actions/upload-artifact@v3
with:
name: backend
path: backend/backend.zip
release:
runs-on: docker
needs: [build-web, build-backend]
steps:
- name: Download Web Artifact
uses: actions/download-artifact@v3
with:
name: quibot-web
path: dist
- name: Download Backend Artifact
uses: actions/download-artifact@v3
with:
name: backend
path: dist
- name: Create Release
uses: https://gitea.com/actions/gitea-release-action@v1
with: with:
tag_name: latest tag_name: latest
name: Latest Build name: Latest Build
files: quibot-web/quibot-web.zip overwrite_files: true
files: |
dist/quibot-web.zip
dist/backend.zip
env: env:
GITHUB_TOKEN: ${{ secrets.GITEATOKEN }} GITEA_TOKEN: ${{ secrets.GITEATOKEN }}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 quibot-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,3 +1,11 @@
# QuiBot-3Dnew
Documentació oficial i codi
Normes del repositori:
- Cada alumne és responsable del seu codi
- Sha de treballar en branques pròpies (opcional)
- No es pot modificar la carpeta daltres zones
# Nuxt Minimal Starter # Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.

43
apk/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
apk/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
apk/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

39
apk/README.md Normal file
View File

@@ -0,0 +1,39 @@
# VoiceDrop
Minimal Expo app for one job: record audio and send it to a backend endpoint.
## What It Does
- Requests microphone permission.
- Records a single audio clip on-device.
- Uploads that clip as `multipart/form-data`.
- Lets you configure the backend URL, bearer token, and form field name from the UI.
## Run It
```bash
npm install
npx expo start
```
If you build native apps after changing config/plugins, run prebuild again:
```bash
npx expo prebuild
```
## Backend Expectations
The app sends a `POST` request to the URL you enter with a multipart body containing one file field. By default the field name is `file`.
If you enter a bearer token, the request includes:
```txt
Authorization: Bearer <token>
```
## Notes
- `expo-av` is required for native recording.
- The backend URL and auth token are persisted locally with AsyncStorage.
- The current environment may require a fresh `npm install` before linting or running because the dependency manifest was simplified around the new app.

65
apk/app.json Normal file
View File

@@ -0,0 +1,65 @@
{
"expo": {
"name": "VoiceDrop",
"slug": "voice-drop",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "voicedrop",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.arandano69.voicedrop"
},
"android": {
"permissions": [
"RECORD_AUDIO"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.arandano69.voicedrop"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-av",
{
"microphonePermission": "Allow VoiceDrop to use your microphone."
}
],
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "f761fcbd-46f2-4387-8282-005e44223075"
}
}
}
}

16
apk/app/_layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: "#f4efe4" },
}}
/>
</>
);
}

449
apk/app/index.tsx Normal file
View File

@@ -0,0 +1,449 @@
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from "expo-av";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { loadRecorderSettings } from "@/lib/recorder-settings";
function formatDuration(durationMs: number) {
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.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 [recording, setRecording] = useState<Audio.Recording | null>(null);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
const [recordingMs, setRecordingMs] = useState(0);
const [statusMessage, setStatusMessage] = useState(
"Ready to record and send audio.",
);
const [responsePreview, setResponsePreview] = useState("");
const [isUploading, setIsUploading] = useState(false);
const refreshSettings = useCallback(() => {
let isMounted = true;
async function loadStoredValues() {
try {
const settings = await loadRecorderSettings();
if (!isMounted) {
return;
}
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
} catch {
if (isMounted) {
setStatusMessage("Could not load saved backend settings.");
}
}
}
void loadStoredValues();
return () => {
isMounted = false;
};
}, []);
useFocusEffect(refreshSettings);
useEffect(() => {
if (!recording) {
return;
}
const interval = setInterval(() => {
void recording.getStatusAsync().then((status) => {
if (typeof status.durationMillis === "number") {
setRecordingMs(status.durationMillis ?? 0);
}
});
}, 250);
return () => {
clearInterval(interval);
};
}, [recording]);
useEffect(() => {
return () => {
if (recording) {
void recording.stopAndUnloadAsync().catch(() => undefined);
}
};
}, [recording]);
async function startRecording() {
try {
setResponsePreview("");
setRecordingUri(null);
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) {
setStatusMessage("Microphone permission was denied.");
Alert.alert(
"Microphone access required",
"Enable microphone access to record audio.",
);
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,
);
setRecording(result.recording);
setRecordingMs(0);
setStatusMessage("Recording in progress.");
} catch (error) {
setStatusMessage("Recording could not be started.");
Alert.alert(
"Recording failed",
error instanceof Error ? error.message : "Unknown recording error.",
);
}
}
async function stopRecording() {
if (!recording) {
return;
}
try {
const activeRecording = recording;
const currentStatus = await activeRecording.getStatusAsync();
await activeRecording.stopAndUnloadAsync();
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = activeRecording.getURI();
setRecording(null);
setRecordingMs(currentStatus.durationMillis ?? recordingMs);
setRecordingUri(uri);
setStatusMessage(
uri ? "Recording finished. Preparing to send voice message." : "Recording finished.",
);
if (uri && backendUrl.trim()) {
await uploadRecording(uri);
}
} catch (error) {
setStatusMessage("Recording could not be stopped cleanly.");
Alert.alert(
"Stop failed",
error instanceof Error ? error.message : "Unknown stop error.",
);
}
}
async function uploadRecording(uriOverride?: string) {
const targetUri = uriOverride ?? recordingUri;
if (!targetUri) {
return;
}
const trimmedUrl = backendUrl.trim();
if (!trimmedUrl) {
Alert.alert("Missing backend URL", "Enter the backend endpoint first.");
return;
}
try {
setIsUploading(true);
setStatusMessage("Uploading recording.");
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> = {};
if (authToken.trim()) {
headers.Authorization = `Bearer ${authToken.trim()}`;
}
const response = await fetch(trimmedUrl, {
method: "POST",
headers,
body: formData,
});
const responseText = await response.text();
setResponsePreview(responseText.slice(0, 400));
if (!response.ok) {
throw new Error(`Upload failed with ${response.status}. ${responseText}`);
}
setStatusMessage("Upload complete.");
} catch (error) {
setStatusMessage("Upload failed.");
Alert.alert(
"Upload failed",
error instanceof Error ? error.message : "Unknown upload error.",
);
} finally {
setIsUploading(false);
}
}
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.hero}>
<View style={styles.heroTopRow}>
<View style={styles.heroBadge}>
<Text style={styles.heroBadgeText}>Assistant Voice</Text>
</View>
<Pressable onPress={() => router.push("/settings")} style={styles.settingsLink}>
<Text style={styles.settingsLinkText}>Settings</Text>
</Pressable>
</View>
<Text style={styles.subtitle}>
Record a voice message and send it to your backend.
</Text>
</View>
<View style={styles.panel}>
<Text style={styles.meterValueCentered}>
{formatDuration(recordingMs)}
</Text>
<Pressable
disabled={isUploading}
onPress={recording ? stopRecording : startRecording}
style={[
styles.micButton,
recording ? styles.stopButton : styles.recordButton,
isUploading && styles.buttonDisabled,
]}
>
{isUploading ? (
<ActivityIndicator color="#fff6f3" size="large" />
) : (
<Text style={styles.micButtonText}>
{recording ? "Stop" : "Record"}
</Text>
)}
</Pressable>
<Text style={styles.statusText}>{statusMessage}</Text>
<Text style={styles.helperText}>
{backendUrl.trim()
? recording
? "Tap again when you finish speaking."
: "Tap the button to start a new voice message."
: "Open settings to add your backend URL before sending voice messages."}
</Text>
{responsePreview ? (
<View style={styles.responseBox}>
<Text style={styles.responseLabel}>Server response</Text>
<Text style={styles.responseText}>{responsePreview}</Text>
</View>
) : null}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: "#f4efe4",
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 32,
paddingTop: 8,
gap: 18,
},
hero: {
backgroundColor: "#13304a",
borderRadius: 28,
paddingHorizontal: 22,
paddingVertical: 24,
gap: 12,
},
heroTopRow: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
heroBadge: {
alignSelf: "flex-start",
backgroundColor: "#f2b15d",
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 6,
},
heroBadgeText: {
color: "#13304a",
fontSize: 12,
fontWeight: "700",
letterSpacing: 0.5,
textTransform: "uppercase",
},
settingsLink: {
borderColor: "#58718d",
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 7,
},
settingsLinkText: {
color: "#d3deea",
fontSize: 13,
fontWeight: "700",
},
subtitle: {
color: "#d3deea",
fontSize: 16,
lineHeight: 22,
},
panel: {
backgroundColor: "#fffaf1",
borderColor: "#dccfb9",
borderRadius: 24,
borderWidth: 1,
gap: 12,
padding: 18,
},
meterValueCentered: {
color: "#d04f2d",
fontSize: 40,
fontWeight: "800",
textAlign: "center",
},
micButton: {
alignItems: "center",
borderRadius: 999,
height: 164,
justifyContent: "center",
marginVertical: 6,
width: 164,
alignSelf: "center",
},
recordButton: {
backgroundColor: "#d04f2d",
},
stopButton: {
backgroundColor: "#8c1c13",
},
micButtonText: {
color: "#fff6f3",
fontSize: 24,
fontWeight: "800",
},
buttonDisabled: {
opacity: 0.45,
},
statusText: {
color: "#1f2d3d",
fontSize: 15,
lineHeight: 21,
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
},
responseBox: {
backgroundColor: "#f7f0e0",
borderRadius: 16,
gap: 6,
marginTop: 4,
padding: 14,
},
responseLabel: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
},
responseText: {
color: "#36475a",
fontSize: 14,
lineHeight: 20,
},
});

202
apk/app/settings.tsx Normal file
View File

@@ -0,0 +1,202 @@
import { router } from "expo-router";
import { useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import {
loadRecorderSettings,
saveRecorderSettings,
} from "@/lib/recorder-settings";
export default function SettingsScreen() {
const [backendUrl, setBackendUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [fieldName, setFieldName] = useState("file");
useEffect(() => {
let isMounted = true;
async function loadSettings() {
try {
const settings = await loadRecorderSettings();
if (!isMounted) {
return;
}
setBackendUrl(settings.backendUrl);
setAuthToken(settings.authToken);
setFieldName(settings.fieldName);
} catch {
if (isMounted) {
Alert.alert("Settings error", "Could not load backend settings.");
}
}
}
void loadSettings();
return () => {
isMounted = false;
};
}, []);
async function handleSave() {
try {
await saveRecorderSettings({
authToken,
backendUrl,
fieldName,
});
router.back();
} catch {
Alert.alert("Save failed", "Could not save backend settings.");
}
}
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.headerRow}>
<Pressable onPress={() => router.back()} style={styles.navButton}>
<Text style={styles.navButtonText}>Back</Text>
</Pressable>
<Text style={styles.title}>Settings</Text>
<Pressable onPress={handleSave} style={styles.navButton}>
<Text style={styles.navButtonText}>Save</Text>
</Pressable>
</View>
<View style={styles.panel}>
<Text style={styles.label}>Backend URL</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
onChangeText={setBackendUrl}
placeholder="https://api.example.com/upload"
placeholderTextColor="#8f8a7c"
style={styles.input}
value={backendUrl}
/>
<Text style={styles.label}>Bearer token</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setAuthToken}
placeholder="Optional"
placeholderTextColor="#8f8a7c"
secureTextEntry
style={styles.input}
value={authToken}
/>
<Text style={styles.label}>Form field name</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setFieldName}
placeholder="file"
placeholderTextColor="#8f8a7c"
style={styles.input}
value={fieldName}
/>
<Text style={styles.helperText}>
The recording is uploaded as multipart field `{fieldName.trim() || "file"}`.
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: "#f4efe4",
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
},
content: {
gap: 18,
paddingHorizontal: 20,
paddingBottom: 32,
paddingTop: 8,
},
headerRow: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
navButton: {
borderColor: "#cdbfa8",
borderRadius: 999,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
},
navButtonText: {
color: "#13304a",
fontSize: 14,
fontWeight: "700",
},
title: {
color: "#13304a",
fontSize: 28,
fontWeight: "800",
},
panel: {
backgroundColor: "#fffaf1",
borderColor: "#dccfb9",
borderRadius: 24,
borderWidth: 1,
gap: 12,
padding: 18,
},
label: {
color: "#13304a",
fontSize: 13,
fontWeight: "700",
marginBottom: -4,
textTransform: "uppercase",
},
input: {
backgroundColor: "#f7f0e0",
borderColor: "#d9ccb5",
borderRadius: 16,
borderWidth: 1,
color: "#1a2b39",
fontSize: 16,
paddingHorizontal: 14,
paddingVertical: 14,
},
helperText: {
color: "#665f54",
fontSize: 13,
lineHeight: 18,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
apk/assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

59
apk/build.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
export ANDROID_HOME=$HOME/android
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$PATH
set -e # stop on error
echo "🚧 Starting APK build..."
# Step 1: Ensure native android folder exists
if [ ! -d "android" ]; then
echo "📦 Running Expo prebuild..."
npx expo prebuild
fi
# Step 2: Go to android folder
cd android
# Step 3: Check if keystore exists
KEYSTORE_FILE="app/my-release-key.keystore"
if [ ! -f "$KEYSTORE_FILE" ]; then
echo "🔑 Generating keystore..."
keytool -genkeypair -v \
-storetype PKCS12 \
-keystore $KEYSTORE_FILE \
-alias my-key-alias \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass password \
-keypass password \
-dname "CN=Your Name, OU=Dev, O=MyApp, L=City, S=State, C=US"
fi
# Step 4: Ensure gradle.properties has signing config
GRADLE_PROPS="gradle.properties"
if ! grep -q "MYAPP_UPLOAD_STORE_FILE" $GRADLE_PROPS; then
echo "⚙️ Adding signing config..."
cat <<EOF >> $GRADLE_PROPS
MYAPP_UPLOAD_STORE_FILE=my-release-key.keystore
MYAPP_UPLOAD_KEY_ALIAS=my-key-alias
MYAPP_UPLOAD_STORE_PASSWORD=password
MYAPP_UPLOAD_KEY_PASSWORD=password
EOF
fi
# Step 5: Build release APK
echo "🏗️ Building APK..."
./gradlew assembleRelease
# Step 6: Output path
APK_PATH="app/build/outputs/apk/release/app-release.apk"
echo "✅ Build complete!"
echo "📦 APK located at: android/$APK_PATH"

10
apk/eslint.config.js Normal file
View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@@ -0,0 +1,37 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
export const STORAGE_KEYS = {
authToken: "recorder.authToken",
backendUrl: "recorder.backendUrl",
fieldName: "recorder.fieldName",
};
export type RecorderSettings = {
authToken: string;
backendUrl: string;
fieldName: string;
};
export async function loadRecorderSettings(): Promise<RecorderSettings> {
const entries = await AsyncStorage.multiGet([
STORAGE_KEYS.backendUrl,
STORAGE_KEYS.authToken,
STORAGE_KEYS.fieldName,
]);
const values = Object.fromEntries(entries);
return {
authToken: values[STORAGE_KEYS.authToken] ?? "",
backendUrl: values[STORAGE_KEYS.backendUrl] ?? "",
fieldName: values[STORAGE_KEYS.fieldName] ?? "file",
};
}
export async function saveRecorderSettings(settings: RecorderSettings) {
await AsyncStorage.multiSet([
[STORAGE_KEYS.backendUrl, settings.backendUrl],
[STORAGE_KEYS.authToken, settings.authToken],
[STORAGE_KEYS.fieldName, settings.fieldName || "file"],
]);
}

12705
apk/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
apk/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "voice-drop",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "~54.0.33",
"expo-av": "~16.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-web": "~0.21.0",
"react-native-screens": "~4.16.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
},
"private": true
}

17
apk/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
venv/

210
backend/main.py Normal file
View File

@@ -0,0 +1,210 @@
from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
import subprocess
import threading
import time
import os
import json
import uuid
import hashlib
from pathlib import Path
from pydantic import BaseModel
import RPi.GPIO as GPIO
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
INCOMING_DIR = Path("/tmp/quibot-audio/incoming")
LOCKED_DIR = Path("/tmp/quibot-audio/locked")
PROCESSED_DIR = Path("/tmp/quibot-audio/processed")
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
LOCKED_DIR.mkdir(parents=True, exist_ok=True)
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
# -------------------------
# GPIO SETUP
# -------------------------
STEP = 23
DIR = 24
EN = 25
GPIO.setmode(GPIO.BCM)
GPIO.setup(STEP, GPIO.OUT)
GPIO.setup(DIR, GPIO.OUT)
GPIO.setup(EN, GPIO.OUT)
GPIO.output(EN, GPIO.LOW)
motor_thread = None
def step_motor(steps, direction, delay=0.001):
GPIO.output(DIR, direction)
for _ in range(steps):
GPIO.output(STEP, GPIO.HIGH)
time.sleep(delay)
GPIO.output(STEP, GPIO.LOW)
time.sleep(delay)
def motor_step(dir):
dir_pin = GPIO.HIGH if dir == "forward" else GPIO.LOW
time.sleep(0.02) # small delay before starting
print("Motor running...")
step_motor(200, dir_pin, 0.001)
# -------------------------
# SAFE COMMAND WHITELIST
# -------------------------
COMMANDS = {
"restart_nginx": ["sudo", "systemctl", "restart", "nginx"],
"uptime": ["uptime"],
"update": ["sudo", "apt", "update"]
}
# -------------------------
# API ENDPOINTS
# -------------------------
@app.post("/run")
def run_task(task: str, token: str):
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
if task not in COMMANDS:
raise HTTPException(status_code=400, detail="Invalid task")
try:
result = subprocess.check_output(COMMANDS[task], text=True)
return {"output": result}
except subprocess.CalledProcessError as e:
return {"error": e.output}
@app.post("/motor/step/forward")
def start_motor(token: str):
global motor_thread
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
motor_thread = threading.Thread(target=motor_step, args=("forward",), daemon=True)
motor_thread.start()
return {"status": "motor started"}
@app.post("/motor/step/backwards")
def start_motor(token: str):
global motor_thread
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
motor_thread = threading.Thread(target=motor_step, args=("backwards",), daemon=True)
motor_thread.start()
return {"status": "motor started"}
@app.post("/motor/stop")
def stop_motor(token: str):
if token != "MY_SECRET_TOKEN":
raise HTTPException(status_code=403, detail="Unauthorized")
GPIO.output(EN, GPIO.HIGH) # disable driver
return {"status": "motor stopped"}
@app.post("/audio/upload")
async def upload_audio(file: UploadFile = File(...), format: str = "wav"):
raw_content = await file.read()
checksum = hashlib.sha256(raw_content).hexdigest()[:16]
filename = f"{checksum[:10]}-{uuid.uuid4().hex[:8]}.wav"
filepath = INCOMING_DIR / filename
filepath.write_bytes(raw_content)
return {"status": "received", "filename": str(filepath), "lock_url": f"/audio/lock/{filepath.name}"}
@app.get("/audio/incoming")
def list_incoming():
files = []
for f in sorted(INCOMING_DIR.iterdir()):
meta = f.stat()
files.append({
"filename": f.name,
"size_bytes": meta.st_size,
"modified_iso": time.ctime(meta.st_mtime),
})
return {"count": len(files), "files": files}
@app.post("/audio/lock/{filename}")
def lock_audio(filename: str):
src = INCOMING_DIR / filename
dst = LOCKED_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
if dst.exists():
return {"status": "already_locked", "filename": filename}
os.rename(str(src), str(dst))
return {"status": "locked", "filename": filename}
@app.post("/audio/unlock/{filename}")
def unlock_audio(filename: str):
src = LOCKED_DIR / filename
dst = INCOMING_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(src), str(dst))
return {"status": "unlocked", "filename": filename}
@app.post("/audio/cancel/{filename}")
def cancel_audio(filename: str):
src = LOCKED_DIR / filename
dst = INCOMING_DIR / filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(src), str(dst))
return {"status": "cancelled", "filename": filename}
@app.post("/audio/process/{filename}")
def process_audio(filename: str):
locked = LOCKED_DIR / filename
processed = PROCESSED_DIR / filename
if not locked.exists():
raise HTTPException(status_code=404, detail=f"File {filename} not found")
os.rename(str(locked), str(processed))
return {"status": "processed", "filename": filename}
@app.on_event("shutdown")
def shutdown():
global motor_running
motor_running = False
GPIO.output(EN, GPIO.HIGH)
GPIO.cleanup()

View File

@@ -1,3 +1,120 @@
<template> <template>
<h1>Test</h1> <main class="panel">
</template> <h1>Quibot Motor Control</h1>
<div class="actions">
<button :disabled="pending" @click="callMotor('step/forward')">
{{ pending && action === 'step/forward' ? 'Starting...' : 'Step Forward' }}
</button>
<button :disabled="pending" @click="callMotor('step/backwards')">
{{ pending && action === 'step/backwards' ? 'Starting...' : 'Step Backwards' }}
</button>
<button :disabled="pending" class="stop" @click="callMotor('stop')">
{{ pending && action === 'stop' ? 'Stopping...' : 'Stop' }}
</button>
</div>
<p v-if="message" class="message">
{{ message }}
</p>
<p v-if="error" class="error">
{{ error }}
</p>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
type MotorAction = 'step/forward' | 'step/backwards' | 'stop'
const pending = ref(false)
const action = ref<MotorAction | null>(null)
const message = ref('')
const error = ref('')
async function callMotor(nextAction: MotorAction) {
pending.value = true
action.value = nextAction
message.value = ''
error.value = ''
try {
const response = await $fetch<{ status?: string }>(`/api/motor/${nextAction}`, {
method: 'POST',
})
message.value = response.status || `${nextAction} request sent`
} catch (err: any) {
error.value = err?.data?.statusMessage || err?.data?.message || err?.message || 'Request failed'
} finally {
pending.value = false
action.value = null
}
}
</script>
<style scoped>
:global(body) {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
background: #f3f4f6;
}
.panel {
min-height: 100vh;
display: grid;
place-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
h1 {
margin: 0;
color: #111827;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
}
button {
border: 0;
border-radius: 0.75rem;
padding: 0.9rem 1.4rem;
font-size: 1rem;
font-weight: 700;
color: #fff;
background: #2563eb;
cursor: pointer;
}
button.stop {
background: #dc2626;
}
button:disabled {
opacity: 0.7;
cursor: wait;
}
.message,
.error {
margin: 0;
font-size: 0.95rem;
}
.message {
color: #166534;
}
.error {
color: #b91c1c;
}
</style>

View File

@@ -2,6 +2,10 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: true }, devtools: { enabled: true },
runtimeConfig: {
quibotBaseUrl: process.env.QUIBOT_BASE_URL || 'http://quibot:8000',
quibotToken: process.env.QUIBOT_TOKEN || 'MY_SECRET_TOKEN',
},
vite: { vite: {
optimizeDeps: { optimizeDeps: {
include: [ include: [

View File

@@ -0,0 +1,18 @@
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const direction = getRouterParam(event, 'direction')
if (direction !== 'forward' && direction !== 'backwards') {
throw createError({
statusCode: 400,
statusMessage: 'Invalid motor direction',
})
}
return await $fetch(`${config.quibotBaseUrl}/motor/step/${direction}`, {
method: 'POST',
query: {
token: config.quibotToken,
},
})
})

View File

@@ -0,0 +1,10 @@
export default defineEventHandler(async () => {
const config = useRuntimeConfig()
return await $fetch(`${config.quibotBaseUrl}/motor/stop`, {
method: 'POST',
query: {
token: config.quibotToken,
},
})
})