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

This commit is contained in:
2026-06-11 15:09:56 +02:00
parent 152b541a6c
commit 0e7fbbfdca
28 changed files with 13874 additions and 1 deletions

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 }}

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"
]
}

View File

@@ -1,11 +1,32 @@
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
import subprocess import subprocess
import threading import threading
import time import time
import os
import json
import uuid
import hashlib
from pathlib import Path
from pydantic import BaseModel
import RPi.GPIO as GPIO import RPi.GPIO as GPIO
app = FastAPI() 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 # GPIO SETUP
# ------------------------- # -------------------------
@@ -104,6 +125,83 @@ def stop_motor(token: str):
return {"status": "motor stopped"} 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") @app.on_event("shutdown")
def shutdown(): def shutdown():
global motor_running global motor_running