This commit is contained in:
parent
ab3a76cd0f
commit
56d8fb4d56
85 changed files with 8879 additions and 0 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -64,3 +64,13 @@ recordings/
|
||||||
# Lock files (optional - uncomment if you want to track these)
|
# Lock files (optional - uncomment if you want to track these)
|
||||||
# uv.lock
|
# uv.lock
|
||||||
# flake.lock
|
# flake.lock
|
||||||
|
|
||||||
|
# Local config
|
||||||
|
local.properties
|
||||||
|
*.jar
|
||||||
|
|
||||||
|
# Claude settings (local)
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Data directories
|
||||||
|
data/
|
||||||
|
|
|
||||||
10
clio-ai/real_time/.gitignore
vendored
Normal file
10
clio-ai/real_time/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
1
clio-ai/real_time/.python-version
Normal file
1
clio-ai/real_time/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.13
|
||||||
0
clio-ai/real_time/README.md
Normal file
0
clio-ai/real_time/README.md
Normal file
154
clio-ai/real_time/collect_samples.sh
Executable file
154
clio-ai/real_time/collect_samples.sh
Executable file
|
|
@ -0,0 +1,154 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Training sample collection script for voice command classification
|
||||||
|
# Creates organized recordings for wake word and category phrases
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
RECORDINGS_DIR="$SCRIPT_DIR/recordings"
|
||||||
|
SAMPLE_RATE=16000
|
||||||
|
DURATION=4
|
||||||
|
|
||||||
|
# Categories
|
||||||
|
declare -A CATEGORIES=(
|
||||||
|
["wake_word"]="ok, note this"
|
||||||
|
["pressure_below_15"]="eye pressure is less than 15"
|
||||||
|
["pressure_15_to_25"]="eye pressure is 15 to 25"
|
||||||
|
["pressure_25_to_35"]="eye pressure is 25 to 35"
|
||||||
|
["pressure_above_35"]="eye pressure is greater than 35"
|
||||||
|
["drop_l"]="L drop"
|
||||||
|
["drop_p"]="P drop"
|
||||||
|
["drop_d"]="D drop"
|
||||||
|
["both_eyes"]="both eyes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Corsair mic source
|
||||||
|
get_mic_source() {
|
||||||
|
wpctl status | grep -i "corsair" | grep -i "mono" | grep -oE '[0-9]+\.' | head -1 | tr -d '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
setup_dirs() {
|
||||||
|
for category in "${!CATEGORIES[@]}"; do
|
||||||
|
mkdir -p "$RECORDINGS_DIR/$category"
|
||||||
|
done
|
||||||
|
echo "Created recording directories in: $RECORDINGS_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Count existing samples for a category
|
||||||
|
count_samples() {
|
||||||
|
local category=$1
|
||||||
|
find "$RECORDINGS_DIR/$category" -name "*.wav" 2>/dev/null | wc -l
|
||||||
|
}
|
||||||
|
|
||||||
|
# Record a single sample
|
||||||
|
record_sample() {
|
||||||
|
local category=$1
|
||||||
|
local voice_name=$2
|
||||||
|
local source_id=$3
|
||||||
|
|
||||||
|
local count=$(count_samples "$category")
|
||||||
|
local filename="${voice_name}_$(printf "%03d" $((count + 1))).wav"
|
||||||
|
local filepath="$RECORDINGS_DIR/$category/$filename"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Recording: $category"
|
||||||
|
echo "Say: \"${CATEGORIES[$category]}\""
|
||||||
|
echo ""
|
||||||
|
echo "Press ENTER to start recording (${DURATION}s)..."
|
||||||
|
read
|
||||||
|
|
||||||
|
echo ">>> RECORDING NOW - SPEAK! <<<"
|
||||||
|
pw-record --target "$source_id" --rate "$SAMPLE_RATE" --channels 1 --format s16 "$filepath" &
|
||||||
|
local pid=$!
|
||||||
|
sleep "$DURATION"
|
||||||
|
kill "$pid" 2>/dev/null
|
||||||
|
wait "$pid" 2>/dev/null
|
||||||
|
|
||||||
|
# Check audio level
|
||||||
|
local max_vol=$(ffmpeg -i "$filepath" -af "volumedetect" -f null /dev/null 2>&1 | grep "max_volume" | sed 's/.*max_volume: \([-0-9.]*\).*/\1/')
|
||||||
|
|
||||||
|
echo "Saved: $filename (max volume: ${max_vol} dB)"
|
||||||
|
|
||||||
|
if [ "${max_vol%.*}" -lt -50 ] 2>/dev/null; then
|
||||||
|
echo "WARNING: Recording seems quiet. Keep? [Y/n]"
|
||||||
|
read -r keep
|
||||||
|
if [[ "$keep" =~ ^[Nn] ]]; then
|
||||||
|
rm "$filepath"
|
||||||
|
echo "Deleted. Try again."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive recording session
|
||||||
|
record_session() {
|
||||||
|
local source_id=$(get_mic_source)
|
||||||
|
|
||||||
|
if [ -z "$source_id" ]; then
|
||||||
|
echo "ERROR: Corsair mic not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Voice Command Sample Collection ==="
|
||||||
|
echo "Mic source: $source_id"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Enter your name/voice identifier (e.g., 'john', 'sarah'):"
|
||||||
|
read -r voice_name
|
||||||
|
voice_name=${voice_name:-default}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
echo ""
|
||||||
|
echo "=== Current sample counts ==="
|
||||||
|
for category in "${!CATEGORIES[@]}"; do
|
||||||
|
local count=$(count_samples "$category")
|
||||||
|
echo " $category: $count samples"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Select category to record:"
|
||||||
|
echo " 1) wake_word - \"ok, note this\""
|
||||||
|
echo " 2) pressure_below_15 - \"eye pressure is less than 15\""
|
||||||
|
echo " 3) pressure_15_to_25 - \"eye pressure is 15 to 25\""
|
||||||
|
echo " 4) pressure_25_to_35 - \"eye pressure is 25 to 35\""
|
||||||
|
echo " 5) pressure_above_35 - \"eye pressure is greater than 35\""
|
||||||
|
echo " 6) drop_l - \"L drop\""
|
||||||
|
echo " 7) drop_p - \"P drop\""
|
||||||
|
echo " 8) drop_d - \"D drop\""
|
||||||
|
echo " 9) both_eyes - \"both eyes\""
|
||||||
|
echo " a) Record ALL categories (one each)"
|
||||||
|
echo " q) Quit"
|
||||||
|
echo ""
|
||||||
|
read -r choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1) record_sample "wake_word" "$voice_name" "$source_id" ;;
|
||||||
|
2) record_sample "pressure_below_15" "$voice_name" "$source_id" ;;
|
||||||
|
3) record_sample "pressure_15_to_25" "$voice_name" "$source_id" ;;
|
||||||
|
4) record_sample "pressure_25_to_35" "$voice_name" "$source_id" ;;
|
||||||
|
5) record_sample "pressure_above_35" "$voice_name" "$source_id" ;;
|
||||||
|
6) record_sample "drop_l" "$voice_name" "$source_id" ;;
|
||||||
|
7) record_sample "drop_p" "$voice_name" "$source_id" ;;
|
||||||
|
8) record_sample "drop_d" "$voice_name" "$source_id" ;;
|
||||||
|
9) record_sample "both_eyes" "$voice_name" "$source_id" ;;
|
||||||
|
a|A)
|
||||||
|
for category in wake_word pressure_below_15 pressure_15_to_25 pressure_25_to_35 pressure_above_35 drop_l drop_p drop_d both_eyes; do
|
||||||
|
record_sample "$category" "$voice_name" "$source_id"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
q|Q)
|
||||||
|
echo "Done. Total samples collected:"
|
||||||
|
for category in "${!CATEGORIES[@]}"; do
|
||||||
|
echo " $category: $(count_samples "$category")"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Invalid choice" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
setup_dirs
|
||||||
|
record_session
|
||||||
61
clio-ai/real_time/flake.lock
generated
Normal file
61
clio-ai/real_time/flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778443072,
|
||||||
|
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
60
clio-ai/real_time/flake.nix
Normal file
60
clio-ai/real_time/flake.nix
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
description = "Voice-activated eye pressure classifier";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
config.allowUnfree = true;
|
||||||
|
config.cudaSupport = true;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
# Python and uv
|
||||||
|
python313
|
||||||
|
uv
|
||||||
|
|
||||||
|
# Audio
|
||||||
|
portaudio
|
||||||
|
pipewire
|
||||||
|
alsa-lib
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
|
# CUDA (for faster-whisper)
|
||||||
|
cudaPackages.cudatoolkit
|
||||||
|
cudaPackages.cudnn
|
||||||
|
|
||||||
|
# SSL certificates
|
||||||
|
cacert
|
||||||
|
|
||||||
|
# Text-to-speech
|
||||||
|
espeak-ng
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [
|
||||||
|
pkgs.portaudio
|
||||||
|
pkgs.pipewire
|
||||||
|
pkgs.alsa-lib
|
||||||
|
pkgs.cudaPackages.cudatoolkit
|
||||||
|
pkgs.cudaPackages.cudnn
|
||||||
|
]}:/run/opengl-driver/lib:$LD_LIBRARY_PATH"
|
||||||
|
|
||||||
|
export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
||||||
|
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
|
||||||
|
|
||||||
|
echo "Voice classifier dev environment"
|
||||||
|
echo "Run: uv run python listener.py"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
562
clio-ai/real_time/listener.py
Executable file
562
clio-ai/real_time/listener.py
Executable file
|
|
@ -0,0 +1,562 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Voice-activated eye pressure classifier.
|
||||||
|
|
||||||
|
Listens for wake word "ok, note this" then classifies the following speech
|
||||||
|
into eye pressure categories.
|
||||||
|
|
||||||
|
Uses two classification methods:
|
||||||
|
1. Transcription + regex pattern matching (fallback)
|
||||||
|
2. Audio embeddings + trained classifier (primary, if available)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python listener.py [--model large-v3] [--device cuda]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import pickle
|
||||||
|
import queue
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd
|
||||||
|
import torch
|
||||||
|
import webrtcvad
|
||||||
|
|
||||||
|
# Lazy imports for faster startup feedback
|
||||||
|
whisper_model = None
|
||||||
|
embedding_model = None
|
||||||
|
classifier_data = None
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
CLASSIFIER_PATH = SCRIPT_DIR / "classifier.pkl"
|
||||||
|
|
||||||
|
# Categories with regex patterns for matching
|
||||||
|
CATEGORIES = {
|
||||||
|
"pressure_below_15": [
|
||||||
|
r"(less\s+than|under|below)\s*15",
|
||||||
|
r"<\s*15",
|
||||||
|
r"15.*less",
|
||||||
|
],
|
||||||
|
"pressure_15_to_25": [
|
||||||
|
r"15\s*(to|through|-)\s*25",
|
||||||
|
r"between\s*15\s*(and|to)\s*25",
|
||||||
|
],
|
||||||
|
"pressure_25_to_35": [
|
||||||
|
r"25\s*(to|through|-)\s*35",
|
||||||
|
r"between\s*25\s*(and|to)\s*35",
|
||||||
|
],
|
||||||
|
"pressure_above_35": [
|
||||||
|
r"(greater\s+than|over|above|more\s+than)\s*35",
|
||||||
|
r">\s*35",
|
||||||
|
r"35.*more",
|
||||||
|
],
|
||||||
|
"drop_l": [
|
||||||
|
r"\bl\s*drop",
|
||||||
|
r"drop\s*l\b",
|
||||||
|
r"\bell\s*drop",
|
||||||
|
],
|
||||||
|
"drop_p": [
|
||||||
|
r"\bp\s*drop",
|
||||||
|
r"drop\s*p\b",
|
||||||
|
r"\bpee\s*drop",
|
||||||
|
],
|
||||||
|
"drop_d": [
|
||||||
|
r"\bd\s*drop",
|
||||||
|
r"drop\s*d\b",
|
||||||
|
r"\bdee\s*drop",
|
||||||
|
],
|
||||||
|
"both_eyes": [
|
||||||
|
r"both\s*eyes",
|
||||||
|
r"both\s*i",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
WAKE_PHRASES = [
|
||||||
|
r"ok[,.]?\s*note\s+this",
|
||||||
|
r"okay[,.]?\s*note\s+this",
|
||||||
|
r"o\.?k\.?\s*note\s+this",
|
||||||
|
# Common mis-transcriptions
|
||||||
|
r"ok[,.]?\s*notice",
|
||||||
|
r"okay[,.]?\s*notice",
|
||||||
|
r"ok[,.]?\s*noted",
|
||||||
|
r"okay[,.]?\s*noted",
|
||||||
|
r"ok[,.]?\s*no\s+this",
|
||||||
|
r"okay[,.]?\s*no\s+this",
|
||||||
|
r"ok[,.]?\s*not\s+this",
|
||||||
|
r"okay[,.]?\s*not\s+this",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONFIRM_YES = [
|
||||||
|
r"\byes\b", r"\byeah\b", r"\byep\b", r"\byup\b",
|
||||||
|
r"\bcorrect\b", r"\bright\b", r"\bconfirm\b",
|
||||||
|
r"\bsave\b", r"\bok\b", r"\bokay\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONFIRM_NO = [
|
||||||
|
r"\bno\b", r"\bnope\b", r"\bnah\b",
|
||||||
|
r"\bwrong\b", r"\bcancel\b", r"\bdelete\b",
|
||||||
|
r"\bdiscard\b", r"\bredo\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Spoken responses for each category
|
||||||
|
CATEGORY_RESPONSES = {
|
||||||
|
"pressure_below_15": "Eye pressure below 15",
|
||||||
|
"pressure_15_to_25": "Eye pressure 15 to 25",
|
||||||
|
"pressure_25_to_35": "Eye pressure 25 to 35",
|
||||||
|
"pressure_above_35": "Eye pressure above 35",
|
||||||
|
"drop_l": "L drop",
|
||||||
|
"drop_p": "P drop",
|
||||||
|
"drop_d": "D drop",
|
||||||
|
"both_eyes": "Both eyes",
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
FRAME_DURATION_MS = 30 # webrtcvad frame size
|
||||||
|
FRAME_SIZE = int(SAMPLE_RATE * FRAME_DURATION_MS / 1000)
|
||||||
|
SPEECH_PADDING_MS = 300 # padding around speech
|
||||||
|
NUM_PADDING_FRAMES = SPEECH_PADDING_MS // FRAME_DURATION_MS
|
||||||
|
|
||||||
|
|
||||||
|
def speak(text: str):
|
||||||
|
"""Speak text using available TTS."""
|
||||||
|
# Try espeak-ng, then espeak, then piper
|
||||||
|
for cmd in [
|
||||||
|
["espeak-ng", "-v", "en-us", "-s", "150", text],
|
||||||
|
["espeak", "-v", "en-us", "-s", "150", text],
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f" TTS error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback: use ffmpeg to generate speech-like notification
|
||||||
|
print(f" (TTS unavailable - install espeak-ng)")
|
||||||
|
|
||||||
|
|
||||||
|
def beep():
|
||||||
|
"""Play a short beep tone."""
|
||||||
|
try:
|
||||||
|
# Generate a 100ms 800Hz beep
|
||||||
|
subprocess.run(
|
||||||
|
["ffplay", "-nodisp", "-autoexit", "-f", "lavfi", "-i",
|
||||||
|
"sine=frequency=800:duration=0.1", "-loglevel", "quiet"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=2
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Silent fail
|
||||||
|
|
||||||
|
|
||||||
|
def get_corsair_device():
|
||||||
|
"""Find Corsair microphone device index."""
|
||||||
|
devices = sd.query_devices()
|
||||||
|
for i, dev in enumerate(devices):
|
||||||
|
if "corsair" in dev["name"].lower() and dev["max_input_channels"] > 0:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_whisper(model_name: str, device: str):
|
||||||
|
"""Load faster-whisper model."""
|
||||||
|
global whisper_model
|
||||||
|
if whisper_model is None:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
print(f"Loading Whisper model '{model_name}' on {device}...")
|
||||||
|
compute_type = "float16" if device == "cuda" else "int8"
|
||||||
|
whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
||||||
|
print(" Whisper loaded.")
|
||||||
|
return whisper_model
|
||||||
|
|
||||||
|
|
||||||
|
def load_embedding_model(device: str):
|
||||||
|
"""Load wav2vec2 model for embeddings."""
|
||||||
|
global embedding_model
|
||||||
|
if embedding_model is None:
|
||||||
|
from torchaudio.pipelines import WAV2VEC2_BASE
|
||||||
|
print(f"Loading wav2vec2 on {device}...")
|
||||||
|
bundle = WAV2VEC2_BASE
|
||||||
|
embedding_model = bundle.get_model().to(device)
|
||||||
|
embedding_model.eval()
|
||||||
|
print(" wav2vec2 loaded.")
|
||||||
|
return embedding_model
|
||||||
|
|
||||||
|
|
||||||
|
def load_classifier():
|
||||||
|
"""Load trained classifier if available."""
|
||||||
|
global classifier_data
|
||||||
|
if classifier_data is None and CLASSIFIER_PATH.exists():
|
||||||
|
print(f"Loading classifier from {CLASSIFIER_PATH}...")
|
||||||
|
with open(CLASSIFIER_PATH, 'rb') as f:
|
||||||
|
classifier_data = pickle.load(f)
|
||||||
|
print(f" Classifier loaded. Categories: {list(classifier_data['label_encoder'].classes_)}")
|
||||||
|
return classifier_data
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe(audio: np.ndarray, model) -> str:
|
||||||
|
"""Transcribe audio to text."""
|
||||||
|
segments, _ = model.transcribe(audio, language="en", beam_size=5)
|
||||||
|
return " ".join(seg.text for seg in segments).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_embedding(audio: np.ndarray, model, device: str) -> np.ndarray:
|
||||||
|
"""Extract embedding from audio using wav2vec2."""
|
||||||
|
with torch.no_grad():
|
||||||
|
# Convert to torch tensor
|
||||||
|
waveform = torch.from_numpy(audio).unsqueeze(0).to(device)
|
||||||
|
features, _ = model.extract_features(waveform)
|
||||||
|
# Use the last layer's features, averaged over time
|
||||||
|
embedding = features[-1].mean(dim=1).cpu().numpy()
|
||||||
|
return embedding.flatten()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_with_embedding(audio: np.ndarray, emb_model, classifier, device: str) -> tuple[str, float]:
|
||||||
|
"""Classify audio using embedding + trained classifier."""
|
||||||
|
embedding = extract_embedding(audio, emb_model, device)
|
||||||
|
embedding = embedding.reshape(1, -1)
|
||||||
|
|
||||||
|
probs = classifier['classifier'].predict_proba(embedding)[0]
|
||||||
|
pred_idx = probs.argmax()
|
||||||
|
confidence = probs[pred_idx]
|
||||||
|
category = classifier['label_encoder'].inverse_transform([pred_idx])[0]
|
||||||
|
|
||||||
|
return category, confidence
|
||||||
|
|
||||||
|
|
||||||
|
def check_wake_word(text: str) -> bool:
|
||||||
|
"""Check if text contains wake phrase."""
|
||||||
|
for pattern in WAKE_PHRASES:
|
||||||
|
if re.search(pattern, text, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_confirmation(text: str) -> str | None:
|
||||||
|
"""Check if text is a yes/no confirmation. Returns 'yes', 'no', or None."""
|
||||||
|
for pattern in CONFIRM_YES:
|
||||||
|
if re.search(pattern, text, re.IGNORECASE):
|
||||||
|
return "yes"
|
||||||
|
for pattern in CONFIRM_NO:
|
||||||
|
if re.search(pattern, text, re.IGNORECASE):
|
||||||
|
return "no"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def log_recording(log_file: str, category: str, timestamp: str):
|
||||||
|
"""Append confirmed recording to log file."""
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
|
||||||
|
file_exists = os.path.exists(log_file)
|
||||||
|
with open(log_file, 'a', newline='') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writerow(['timestamp', 'category'])
|
||||||
|
writer.writerow([timestamp, category])
|
||||||
|
|
||||||
|
|
||||||
|
def classify_category(text: str) -> str | None:
|
||||||
|
"""Classify text into a category. Returns None if no match."""
|
||||||
|
for category, patterns in CATEGORIES.items():
|
||||||
|
for pattern in patterns:
|
||||||
|
if re.search(pattern, text, re.IGNORECASE):
|
||||||
|
return category
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def classify_text(text: str) -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
Check for wake word and classify category from transcription.
|
||||||
|
Returns (category, text) or (None, None) if no wake word.
|
||||||
|
"""
|
||||||
|
if not check_wake_word(text):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
category = classify_category(text)
|
||||||
|
return category if category else "unknown", text
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceActivityDetector:
|
||||||
|
"""VAD-based speech segmentation."""
|
||||||
|
|
||||||
|
def __init__(self, aggressiveness: int = 2):
|
||||||
|
self.vad = webrtcvad.Vad(aggressiveness)
|
||||||
|
self.ring_buffer = collections.deque(maxlen=NUM_PADDING_FRAMES)
|
||||||
|
self.triggered = False
|
||||||
|
self.voiced_frames = []
|
||||||
|
|
||||||
|
def process_frame(self, frame: bytes) -> np.ndarray | None:
|
||||||
|
"""
|
||||||
|
Process a frame of audio.
|
||||||
|
Returns complete utterance when speech ends, None otherwise.
|
||||||
|
"""
|
||||||
|
is_speech = self.vad.is_speech(frame, SAMPLE_RATE)
|
||||||
|
|
||||||
|
if not self.triggered:
|
||||||
|
self.ring_buffer.append((frame, is_speech))
|
||||||
|
num_voiced = sum(1 for _, speech in self.ring_buffer if speech)
|
||||||
|
|
||||||
|
if num_voiced > 0.9 * self.ring_buffer.maxlen:
|
||||||
|
self.triggered = True
|
||||||
|
self.voiced_frames = [f for f, _ in self.ring_buffer]
|
||||||
|
self.ring_buffer.clear()
|
||||||
|
else:
|
||||||
|
self.voiced_frames.append(frame)
|
||||||
|
self.ring_buffer.append((frame, is_speech))
|
||||||
|
num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)
|
||||||
|
|
||||||
|
if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
|
||||||
|
self.triggered = False
|
||||||
|
audio_bytes = b"".join(self.voiced_frames)
|
||||||
|
self.voiced_frames = []
|
||||||
|
self.ring_buffer.clear()
|
||||||
|
|
||||||
|
# Convert to numpy array
|
||||||
|
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
return audio
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def audio_callback(indata, frames, time_info, status, audio_queue):
|
||||||
|
"""Callback for audio stream."""
|
||||||
|
if status:
|
||||||
|
print(f"Audio status: {status}", file=sys.stderr)
|
||||||
|
audio_queue.put(bytes(indata))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Voice-activated eye pressure classifier")
|
||||||
|
parser.add_argument("--model", default="base.en", help="Whisper model name")
|
||||||
|
parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"], help="Compute device")
|
||||||
|
parser.add_argument("--list-devices", action="store_true", help="List audio devices and exit")
|
||||||
|
parser.add_argument("--no-classifier", action="store_true", help="Disable embedding classifier")
|
||||||
|
parser.add_argument("--confidence-threshold", type=float, default=0.6,
|
||||||
|
help="Minimum confidence for embedding classifier (default: 0.6)")
|
||||||
|
parser.add_argument("--wake-timeout", type=float, default=5.0,
|
||||||
|
help="Seconds to wait for command after wake word (default: 5.0)")
|
||||||
|
parser.add_argument("--confirm", action="store_true",
|
||||||
|
help="Require yes/no confirmation before saving")
|
||||||
|
parser.add_argument("--log-file", type=str, default="recordings_log.csv",
|
||||||
|
help="File to log confirmed recordings (default: recordings_log.csv)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list_devices:
|
||||||
|
print(sd.query_devices())
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find microphone
|
||||||
|
mic_device = get_corsair_device()
|
||||||
|
if mic_device is None:
|
||||||
|
print("Corsair microphone not found. Available devices:")
|
||||||
|
print(sd.query_devices())
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Using microphone: {sd.query_devices(mic_device)['name']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Load models
|
||||||
|
whisper = load_whisper(args.model, args.device)
|
||||||
|
|
||||||
|
# Load embedding classifier if available
|
||||||
|
classifier = None
|
||||||
|
emb_model = None
|
||||||
|
if not args.no_classifier:
|
||||||
|
classifier = load_classifier()
|
||||||
|
if classifier:
|
||||||
|
emb_model = load_embedding_model(args.device)
|
||||||
|
else:
|
||||||
|
print(" No classifier found. Run train_classifier.py after collecting samples.")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Setup VAD and audio queue
|
||||||
|
vad = VoiceActivityDetector(aggressiveness=2)
|
||||||
|
audio_queue = queue.Queue()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Listening for: \"Ok, note this\"")
|
||||||
|
print("Then say your command (can pause between wake word and command)")
|
||||||
|
print("Commands: pressure <15 / 15-25 / 25-35 / >35, L/P/D drop, both eyes")
|
||||||
|
if classifier:
|
||||||
|
print(f"Using embedding classifier (confidence threshold: {args.confidence_threshold})")
|
||||||
|
else:
|
||||||
|
print("Using transcription-only mode")
|
||||||
|
if args.confirm:
|
||||||
|
print("Confirmation mode: say 'yes' to save, 'no' to discard")
|
||||||
|
print("Press Ctrl+C to stop")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# State machine: idle -> awaiting_command -> (awaiting_confirmation) -> idle
|
||||||
|
state = "idle"
|
||||||
|
state_time = 0
|
||||||
|
pending_category = None
|
||||||
|
|
||||||
|
def do_classification(utterance, text, use_embedding=True):
|
||||||
|
"""Classify utterance. Returns (category, method) or (None, None)."""
|
||||||
|
final_category = None
|
||||||
|
method_used = None
|
||||||
|
|
||||||
|
# Try embedding classifier first
|
||||||
|
if use_embedding and classifier and emb_model:
|
||||||
|
start = time.time()
|
||||||
|
emb_category, confidence = classify_with_embedding(
|
||||||
|
utterance, emb_model, classifier, args.device
|
||||||
|
)
|
||||||
|
emb_time = time.time() - start
|
||||||
|
|
||||||
|
print(f" Embedding: {emb_category} ({confidence:.1%}) [{emb_time:.2f}s]")
|
||||||
|
|
||||||
|
if confidence >= args.confidence_threshold:
|
||||||
|
final_category = emb_category
|
||||||
|
method_used = f"embedding ({confidence:.1%})"
|
||||||
|
else:
|
||||||
|
print(f" Low confidence, trying transcription")
|
||||||
|
|
||||||
|
# Fall back to transcription
|
||||||
|
if final_category is None:
|
||||||
|
text_category = classify_category(text)
|
||||||
|
if text_category:
|
||||||
|
final_category = text_category
|
||||||
|
method_used = "transcription"
|
||||||
|
|
||||||
|
return final_category, method_used
|
||||||
|
|
||||||
|
def announce_classification(category, method, ask_confirm=False):
|
||||||
|
"""Announce the classification result."""
|
||||||
|
response = CATEGORY_RESPONSES.get(category, category)
|
||||||
|
print()
|
||||||
|
print(f" >>> {response} <<< (via {method})")
|
||||||
|
print()
|
||||||
|
if ask_confirm:
|
||||||
|
speak(f"{response}, correct?")
|
||||||
|
else:
|
||||||
|
speak(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def save_recording(category):
|
||||||
|
"""Save the confirmed recording."""
|
||||||
|
from datetime import datetime
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
log_recording(args.log_file, category, timestamp)
|
||||||
|
print(f" Saved to {args.log_file}")
|
||||||
|
beep()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sd.RawInputStream(
|
||||||
|
samplerate=SAMPLE_RATE,
|
||||||
|
blocksize=FRAME_SIZE,
|
||||||
|
device=mic_device,
|
||||||
|
dtype="int16",
|
||||||
|
channels=1,
|
||||||
|
callback=lambda *a: audio_callback(*a, audio_queue),
|
||||||
|
):
|
||||||
|
while True:
|
||||||
|
frame = audio_queue.get()
|
||||||
|
|
||||||
|
# Check for timeouts
|
||||||
|
if state != "idle" and (time.time() - state_time) > args.wake_timeout:
|
||||||
|
print(" (timeout)")
|
||||||
|
print()
|
||||||
|
state = "idle"
|
||||||
|
pending_category = None
|
||||||
|
|
||||||
|
utterance = vad.process_frame(frame)
|
||||||
|
|
||||||
|
if utterance is not None and len(utterance) > SAMPLE_RATE * 0.5: # At least 0.5s
|
||||||
|
print("Processing speech...", end=" ", flush=True)
|
||||||
|
|
||||||
|
# Transcribe
|
||||||
|
start = time.time()
|
||||||
|
text = transcribe(utterance, whisper)
|
||||||
|
transcribe_time = time.time() - start
|
||||||
|
|
||||||
|
print(f"({transcribe_time:.2f}s)")
|
||||||
|
print(f" Heard: \"{text}\"")
|
||||||
|
|
||||||
|
# State: awaiting confirmation
|
||||||
|
if state == "awaiting_confirmation":
|
||||||
|
confirmation = check_confirmation(text)
|
||||||
|
if confirmation == "yes":
|
||||||
|
print(" Confirmed!")
|
||||||
|
save_recording(pending_category)
|
||||||
|
elif confirmation == "no":
|
||||||
|
print(" Discarded.")
|
||||||
|
beep()
|
||||||
|
else:
|
||||||
|
print(" Say 'yes' to save or 'no' to discard")
|
||||||
|
state_time = time.time() # Reset timeout
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
state = "idle"
|
||||||
|
pending_category = None
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# State: awaiting command
|
||||||
|
if state == "awaiting_command":
|
||||||
|
category, method = do_classification(utterance, text)
|
||||||
|
if category:
|
||||||
|
announce_classification(category, method, ask_confirm=args.confirm)
|
||||||
|
if args.confirm:
|
||||||
|
print(" Say 'yes' to save, 'no' to discard")
|
||||||
|
state = "awaiting_confirmation"
|
||||||
|
state_time = time.time()
|
||||||
|
pending_category = category
|
||||||
|
else:
|
||||||
|
save_recording(category)
|
||||||
|
state = "idle"
|
||||||
|
else:
|
||||||
|
print(" Could not classify. Try again.")
|
||||||
|
state_time = time.time() # Reset timeout
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# State: idle - check for wake word
|
||||||
|
has_wake = check_wake_word(text)
|
||||||
|
|
||||||
|
if not has_wake:
|
||||||
|
print(" (no wake word detected)")
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(" Wake word detected!")
|
||||||
|
|
||||||
|
# Try to classify in same utterance
|
||||||
|
category, method = do_classification(utterance, text)
|
||||||
|
if category:
|
||||||
|
announce_classification(category, method, ask_confirm=args.confirm)
|
||||||
|
if args.confirm:
|
||||||
|
print(" Say 'yes' to save, 'no' to discard")
|
||||||
|
state = "awaiting_confirmation"
|
||||||
|
state_time = time.time()
|
||||||
|
pending_category = category
|
||||||
|
else:
|
||||||
|
save_recording(category)
|
||||||
|
print()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No category found - wait for next utterance
|
||||||
|
print(" Listening for command...")
|
||||||
|
beep()
|
||||||
|
state = "awaiting_command"
|
||||||
|
state_time = time.time()
|
||||||
|
print()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6
clio-ai/real_time/main.py
Normal file
6
clio-ai/real_time/main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
def main():
|
||||||
|
print("Hello from real-time!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
clio-ai/real_time/pyproject.toml
Normal file
16
clio-ai/real_time/pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[project]
|
||||||
|
name = "real-time"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"faster-whisper>=1.2.1",
|
||||||
|
"numpy>=2.4.4",
|
||||||
|
"scikit-learn>=1.8.0",
|
||||||
|
"scipy>=1.17.1",
|
||||||
|
"sounddevice>=0.5.5",
|
||||||
|
"torch>=2.12.0",
|
||||||
|
"torchaudio>=2.11.0",
|
||||||
|
"webrtcvad>=2.0.10",
|
||||||
|
]
|
||||||
3
clio-ai/real_time/run_listener.sh
Executable file
3
clio-ai/real_time/run_listener.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec nix develop --command uv run python listener.py "$@"
|
||||||
71
clio-ai/real_time/test_corsair_mic.sh
Executable file
71
clio-ai/real_time/test_corsair_mic.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Test script for Corsair VOID ELITE Wireless microphone
|
||||||
|
# Uses PipeWire for audio capture
|
||||||
|
|
||||||
|
OUTPUT_FILE="test_recording.wav"
|
||||||
|
DURATION=5
|
||||||
|
|
||||||
|
echo "=== Corsair VOID ELITE Microphone Test ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get the Corsair source ID
|
||||||
|
CORSAIR_SOURCE=$(wpctl status | grep -i "corsair" | grep -i "mono" | grep -oE '[0-9]+\.' | head -1 | tr -d '.')
|
||||||
|
|
||||||
|
if [ -z "$CORSAIR_SOURCE" ]; then
|
||||||
|
echo "ERROR: Corsair microphone not found in PipeWire sources"
|
||||||
|
wpctl status | grep -A5 "Sources:"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found Corsair mic: Source ID $CORSAIR_SOURCE"
|
||||||
|
echo "Duration: ${DURATION} seconds"
|
||||||
|
echo ""
|
||||||
|
echo "Speak into your Corsair headset microphone..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Record using pw-record
|
||||||
|
pw-record --target "$CORSAIR_SOURCE" --rate 48000 --channels 1 --format s16 "$OUTPUT_FILE" &
|
||||||
|
PW_PID=$!
|
||||||
|
|
||||||
|
sleep "$DURATION"
|
||||||
|
kill "$PW_PID" 2>/dev/null
|
||||||
|
wait "$PW_PID" 2>/dev/null
|
||||||
|
|
||||||
|
if [ -f "$OUTPUT_FILE" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Recording complete ==="
|
||||||
|
echo "Saved to: $OUTPUT_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show file info
|
||||||
|
echo "File info:"
|
||||||
|
ffprobe -hide_banner "$OUTPUT_FILE" 2>&1 | grep -E "Duration|Stream"
|
||||||
|
|
||||||
|
# Check if there's actual audio (not silence)
|
||||||
|
echo ""
|
||||||
|
echo "Audio levels:"
|
||||||
|
ffmpeg -i "$OUTPUT_FILE" -af "volumedetect" -f null /dev/null 2>&1 | grep -E "max_volume|mean_volume"
|
||||||
|
|
||||||
|
MAX_VOL=$(ffmpeg -i "$OUTPUT_FILE" -af "volumedetect" -f null /dev/null 2>&1 | grep "max_volume" | sed 's/.*max_volume: \([-0-9.]*\).*/\1/')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
# Check if max volume is greater than -50 dB (audible)
|
||||||
|
if [ "$(echo "$MAX_VOL" | cut -d. -f1)" -gt -50 ] 2>/dev/null; then
|
||||||
|
echo "SUCCESS: Audio detected! Max volume: ${MAX_VOL} dB"
|
||||||
|
else
|
||||||
|
echo "WARNING: Very quiet or silent recording (${MAX_VOL} dB)"
|
||||||
|
echo ""
|
||||||
|
echo "Possible causes:"
|
||||||
|
echo " - Mic mute button on headset is ON (flip the switch!)"
|
||||||
|
echo " - Headset is not being worn / mic boom is retracted"
|
||||||
|
echo " - Volume too low in system settings"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To play back: ffplay -nodisp -autoexit $OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "=== Recording FAILED ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
191
clio-ai/real_time/train_classifier.py
Normal file
191
clio-ai/real_time/train_classifier.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Train an audio embedding classifier on recorded samples.
|
||||||
|
|
||||||
|
Uses wav2vec2 to extract embeddings, then trains a simple classifier.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run python train_classifier.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from scipy.io import wavfile
|
||||||
|
from torchaudio.pipelines import WAV2VEC2_BASE
|
||||||
|
|
||||||
|
RECORDINGS_DIR = Path(__file__).parent / "recordings"
|
||||||
|
MODEL_PATH = Path(__file__).parent / "classifier.pkl"
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
|
||||||
|
# Categories (excluding wake_word which is handled separately)
|
||||||
|
CATEGORIES = [
|
||||||
|
"pressure_below_15",
|
||||||
|
"pressure_15_to_25",
|
||||||
|
"pressure_25_to_35",
|
||||||
|
"pressure_above_35",
|
||||||
|
"drop_l",
|
||||||
|
"drop_p",
|
||||||
|
"drop_d",
|
||||||
|
"both_eyes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_audio(path: Path) -> torch.Tensor:
|
||||||
|
"""Load and preprocess audio file."""
|
||||||
|
sr, data = wavfile.read(path)
|
||||||
|
|
||||||
|
# Convert to float32
|
||||||
|
if data.dtype == np.int16:
|
||||||
|
data = data.astype(np.float32) / 32768.0
|
||||||
|
elif data.dtype == np.int32:
|
||||||
|
data = data.astype(np.float32) / 2147483648.0
|
||||||
|
elif data.dtype != np.float32:
|
||||||
|
data = data.astype(np.float32)
|
||||||
|
|
||||||
|
# Convert to mono if stereo
|
||||||
|
if len(data.shape) > 1:
|
||||||
|
data = data.mean(axis=1)
|
||||||
|
|
||||||
|
# Resample if needed
|
||||||
|
if sr != SAMPLE_RATE:
|
||||||
|
from scipy import signal
|
||||||
|
num_samples = int(len(data) * SAMPLE_RATE / sr)
|
||||||
|
data = signal.resample(data, num_samples)
|
||||||
|
|
||||||
|
# Convert to torch tensor with batch dimension
|
||||||
|
waveform = torch.from_numpy(data).unsqueeze(0)
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
|
def extract_embedding(waveform: torch.Tensor, model, device: str) -> np.ndarray:
|
||||||
|
"""Extract embedding from audio using wav2vec2."""
|
||||||
|
with torch.no_grad():
|
||||||
|
waveform = waveform.to(device)
|
||||||
|
features, _ = model.extract_features(waveform)
|
||||||
|
# Use the last layer's features, averaged over time
|
||||||
|
embedding = features[-1].mean(dim=1).cpu().numpy()
|
||||||
|
return embedding.flatten()
|
||||||
|
|
||||||
|
|
||||||
|
def load_samples():
|
||||||
|
"""Load all training samples."""
|
||||||
|
samples = []
|
||||||
|
labels = []
|
||||||
|
|
||||||
|
for category in CATEGORIES:
|
||||||
|
category_dir = RECORDINGS_DIR / category
|
||||||
|
if not category_dir.exists():
|
||||||
|
print(f"Warning: {category_dir} does not exist")
|
||||||
|
continue
|
||||||
|
|
||||||
|
wav_files = list(category_dir.glob("*.wav"))
|
||||||
|
print(f" {category}: {len(wav_files)} samples")
|
||||||
|
|
||||||
|
for wav_file in wav_files:
|
||||||
|
samples.append(wav_file)
|
||||||
|
labels.append(category)
|
||||||
|
|
||||||
|
return samples, labels
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=== Training Audio Classifier ===\n")
|
||||||
|
|
||||||
|
# Check for samples
|
||||||
|
if not RECORDINGS_DIR.exists():
|
||||||
|
print(f"Error: {RECORDINGS_DIR} does not exist")
|
||||||
|
print("Run ./collect_samples.sh first to record training samples")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Loading samples...")
|
||||||
|
sample_paths, labels = load_samples()
|
||||||
|
|
||||||
|
if len(sample_paths) == 0:
|
||||||
|
print("\nNo samples found. Run ./collect_samples.sh first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(set(labels)) < 2:
|
||||||
|
print(f"\nNeed samples from at least 2 categories. Found: {set(labels)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nTotal: {len(sample_paths)} samples across {len(set(labels))} categories")
|
||||||
|
|
||||||
|
# Check minimum samples per category
|
||||||
|
from collections import Counter
|
||||||
|
counts = Counter(labels)
|
||||||
|
min_samples = min(counts.values())
|
||||||
|
if min_samples < 3:
|
||||||
|
print(f"\nWarning: Some categories have < 3 samples. More samples = better accuracy.")
|
||||||
|
|
||||||
|
# Load model
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
print(f"\nLoading wav2vec2 on {device}...")
|
||||||
|
bundle = WAV2VEC2_BASE
|
||||||
|
model = bundle.get_model().to(device)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
# Extract embeddings
|
||||||
|
print("Extracting embeddings...")
|
||||||
|
embeddings = []
|
||||||
|
valid_labels = []
|
||||||
|
|
||||||
|
for i, (path, label) in enumerate(zip(sample_paths, labels)):
|
||||||
|
try:
|
||||||
|
waveform = load_audio(path)
|
||||||
|
embedding = extract_embedding(waveform, model, device)
|
||||||
|
embeddings.append(embedding)
|
||||||
|
valid_labels.append(label)
|
||||||
|
print(f" [{i+1}/{len(sample_paths)}] {path.name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i+1}/{len(sample_paths)}] {path.name} - ERROR: {e}")
|
||||||
|
|
||||||
|
if len(embeddings) < 2:
|
||||||
|
print("\nNot enough valid samples to train.")
|
||||||
|
return
|
||||||
|
|
||||||
|
X = np.array(embeddings)
|
||||||
|
y = np.array(valid_labels)
|
||||||
|
|
||||||
|
print(f"\nEmbedding shape: {X.shape}")
|
||||||
|
|
||||||
|
# Train classifier
|
||||||
|
print("\nTraining classifier...")
|
||||||
|
from sklearn.preprocessing import LabelEncoder
|
||||||
|
from sklearn.svm import SVC
|
||||||
|
from sklearn.model_selection import cross_val_score
|
||||||
|
|
||||||
|
label_encoder = LabelEncoder()
|
||||||
|
y_encoded = label_encoder.fit_transform(y)
|
||||||
|
|
||||||
|
# Use SVM with probability estimates
|
||||||
|
classifier = SVC(kernel='rbf', probability=True, C=1.0)
|
||||||
|
|
||||||
|
# Cross-validation if enough samples
|
||||||
|
if len(X) >= 4:
|
||||||
|
n_splits = min(5, len(X))
|
||||||
|
scores = cross_val_score(classifier, X, y_encoded, cv=n_splits)
|
||||||
|
print(f"Cross-validation accuracy: {scores.mean():.2%} (+/- {scores.std()*2:.2%})")
|
||||||
|
|
||||||
|
# Train on all data
|
||||||
|
classifier.fit(X, y_encoded)
|
||||||
|
|
||||||
|
# Save model
|
||||||
|
model_data = {
|
||||||
|
'classifier': classifier,
|
||||||
|
'label_encoder': label_encoder,
|
||||||
|
'categories': CATEGORIES,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(MODEL_PATH, 'wb') as f:
|
||||||
|
pickle.dump(model_data, f)
|
||||||
|
|
||||||
|
print(f"\nClassifier saved to: {MODEL_PATH}")
|
||||||
|
print(f"Categories: {list(label_encoder.classes_)}")
|
||||||
|
print("\nDone! The listener will now use this classifier.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1104
clio-ai/real_time/uv.lock
generated
Normal file
1104
clio-ai/real_time/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
10
clio-ai/transcription/.gitignore
vendored
Normal file
10
clio-ai/transcription/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
1
clio-ai/transcription/.python-version
Normal file
1
clio-ai/transcription/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.13
|
||||||
0
clio-ai/transcription/README.md
Normal file
0
clio-ai/transcription/README.md
Normal file
61
clio-ai/transcription/flake.lock
generated
Normal file
61
clio-ai/transcription/flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777954456,
|
||||||
|
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
46
clio-ai/transcription/flake.nix
Normal file
46
clio-ai/transcription/flake.nix
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
description = "Audio transcription with faster-whisper (local models only)";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
pythonEnv = pkgs.python313.withPackages (ps: with ps; [
|
||||||
|
pip
|
||||||
|
]);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
pythonEnv
|
||||||
|
uv
|
||||||
|
ffmpeg
|
||||||
|
zlib
|
||||||
|
stdenv.cc.cc.lib
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [
|
||||||
|
pkgs.zlib
|
||||||
|
pkgs.stdenv.cc.cc.lib
|
||||||
|
pkgs.ffmpeg
|
||||||
|
]}:$LD_LIBRARY_PATH"
|
||||||
|
|
||||||
|
# Ensure venv exists
|
||||||
|
if [ ! -d .venv ]; then
|
||||||
|
uv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Transcription environment ready."
|
||||||
|
echo "Run: uv run python transcribe.py /path/to/recordings"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
6
clio-ai/transcription/main.py
Normal file
6
clio-ai/transcription/main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
def main():
|
||||||
|
print("Hello from view-recordings!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
clio-ai/transcription/pyproject.toml
Normal file
10
clio-ai/transcription/pyproject.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[project]
|
||||||
|
name = "view-recordings"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"faster-whisper>=1.2.1",
|
||||||
|
"openai-whisper>=20250625",
|
||||||
|
]
|
||||||
187
clio-ai/transcription/transcribe.py
Normal file
187
clio-ai/transcription/transcribe.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Transcribe audio recordings using Whisper models via faster-whisper.
|
||||||
|
|
||||||
|
For Chromebook (CPU-only): Uses 'tiny' or 'base' models which run reasonably fast.
|
||||||
|
For GPU (3090): Can use 'large-v3' for best quality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(seconds: float) -> str:
|
||||||
|
"""Convert seconds to HH:MM:SS.mmm format."""
|
||||||
|
td = timedelta(seconds=seconds)
|
||||||
|
hours, remainder = divmod(td.seconds, 3600)
|
||||||
|
minutes, secs = divmod(remainder, 60)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{int(td.microseconds/1000):03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_file(model, audio_path: Path, output_dir: Path, verbose: bool = False):
|
||||||
|
"""Transcribe a single audio file and save results."""
|
||||||
|
print(f"\nTranscribing: {audio_path.name}")
|
||||||
|
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
str(audio_path),
|
||||||
|
beam_size=5,
|
||||||
|
vad_filter=True, # Filter out non-speech sections
|
||||||
|
vad_parameters=dict(
|
||||||
|
min_silence_duration_ms=500,
|
||||||
|
speech_pad_ms=200,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Detected language: {info.language} (probability: {info.language_probability:.2f})")
|
||||||
|
print(f" Duration: {info.duration:.1f}s")
|
||||||
|
|
||||||
|
# Collect all segments
|
||||||
|
results = []
|
||||||
|
full_text = []
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
results.append({
|
||||||
|
"start": segment.start,
|
||||||
|
"end": segment.end,
|
||||||
|
"text": segment.text.strip(),
|
||||||
|
})
|
||||||
|
full_text.append(segment.text.strip())
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" [{format_timestamp(segment.start)} -> {format_timestamp(segment.end)}] {segment.text.strip()}")
|
||||||
|
|
||||||
|
# Save outputs
|
||||||
|
stem = audio_path.stem
|
||||||
|
|
||||||
|
# Save JSON with timestamps
|
||||||
|
json_path = output_dir / f"{stem}.json"
|
||||||
|
with open(json_path, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"source_file": str(audio_path),
|
||||||
|
"language": info.language,
|
||||||
|
"language_probability": info.language_probability,
|
||||||
|
"duration": info.duration,
|
||||||
|
"segments": results,
|
||||||
|
}, f, indent=2)
|
||||||
|
|
||||||
|
# Save plain text
|
||||||
|
txt_path = output_dir / f"{stem}.txt"
|
||||||
|
with open(txt_path, "w") as f:
|
||||||
|
f.write("\n".join(full_text))
|
||||||
|
|
||||||
|
print(f" Saved: {json_path.name}, {txt_path.name}")
|
||||||
|
print(f" Found {len(results)} speech segments")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Transcribe audio files using Whisper")
|
||||||
|
parser.add_argument(
|
||||||
|
"input_dir",
|
||||||
|
type=Path,
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Directory containing audio files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Output directory for transcriptions (default: input_dir/transcriptions)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-m", "--model",
|
||||||
|
default=os.getenv("WHISPER_MODEL", "large-v3"),
|
||||||
|
choices=["tiny", "base", "small", "medium", "large-v3"],
|
||||||
|
help="Whisper model size (default: large-v3, or WHISPER_MODEL env var)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
default="auto",
|
||||||
|
choices=["auto", "cpu", "cuda"],
|
||||||
|
help="Device to use (auto detects GPU)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Print each segment as it's transcribed",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Transcribe a single file instead of directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Import here to show helpful error if not installed
|
||||||
|
try:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
except ImportError:
|
||||||
|
print("Error: faster-whisper not installed. Install with:")
|
||||||
|
print(" uv add faster-whisper")
|
||||||
|
print(" # or: pip install faster-whisper")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Determine device
|
||||||
|
if args.device == "auto":
|
||||||
|
import torch
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
compute_type = "float16" if device == "cuda" else "int8"
|
||||||
|
elif args.device == "cuda":
|
||||||
|
device = "cuda"
|
||||||
|
compute_type = "float16"
|
||||||
|
else:
|
||||||
|
device = "cpu"
|
||||||
|
compute_type = "int8"
|
||||||
|
|
||||||
|
print(f"Using device: {device}, compute type: {compute_type}")
|
||||||
|
print(f"Loading model: {args.model}")
|
||||||
|
|
||||||
|
model = WhisperModel(args.model, device=device, compute_type=compute_type)
|
||||||
|
|
||||||
|
# Handle single file or directory
|
||||||
|
if args.file:
|
||||||
|
audio_files = [args.file]
|
||||||
|
output_dir = args.output_dir or args.file.parent / "transcriptions"
|
||||||
|
elif args.input_dir:
|
||||||
|
input_dir = args.input_dir
|
||||||
|
if not input_dir.exists():
|
||||||
|
print(f"Error: Input directory does not exist: {input_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
audio_files = sorted(
|
||||||
|
p for p in input_dir.iterdir()
|
||||||
|
if p.suffix.lower() in {".ogg", ".mp3", ".wav", ".m4a", ".flac", ".webm"}
|
||||||
|
)
|
||||||
|
output_dir = args.output_dir or input_dir / "transcriptions"
|
||||||
|
else:
|
||||||
|
print("Error: Must specify either input_dir or --file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not audio_files:
|
||||||
|
print("No audio files found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Found {len(audio_files)} audio file(s)")
|
||||||
|
print(f"Output directory: {output_dir}")
|
||||||
|
|
||||||
|
for audio_path in audio_files:
|
||||||
|
try:
|
||||||
|
transcribe_file(model, audio_path, output_dir, verbose=args.verbose)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error transcribing {audio_path.name}: {e}")
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1146
clio-ai/transcription/uv.lock
generated
Normal file
1146
clio-ai/transcription/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
1
clio-android/.gitignore
vendored
Normal file
1
clio-android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
android_studio_install_path
|
||||||
61
clio-android/app/build.gradle.kts
Normal file
61
clio-android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.phonerecorder"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.phonerecorder"
|
||||||
|
minSdk = 29
|
||||||
|
targetSdk = 33
|
||||||
|
versionCode = 2
|
||||||
|
versionName = "1.1.0"
|
||||||
|
|
||||||
|
// Build timestamp for version display
|
||||||
|
buildConfigField("long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
implementation("com.google.android.material:material:1.10.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
|
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||||
|
|
||||||
|
// Encryption
|
||||||
|
implementation("com.goterl:lazysodium-android:5.1.0@aar")
|
||||||
|
implementation("net.java.dev.jna:jna:5.14.0@aar")
|
||||||
|
|
||||||
|
// Background sync
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
|
||||||
|
// HTTP client
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
}
|
||||||
7
clio-android/app/proguard-rules.pro
vendored
Normal file
7
clio-android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# By default, the flags in this file are appended to flags specified
|
||||||
|
# in /sdk/tools/proguard/proguard-android.txt
|
||||||
|
|
||||||
|
# Keep service and receiver classes
|
||||||
|
-keep class com.example.phonerecorder.RecordingService { *; }
|
||||||
|
-keep class com.example.phonerecorder.BootReceiver { *; }
|
||||||
74
clio-android/app/src/main/AndroidManifest.xml
Normal file
74
clio-android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Audio recording -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<!-- Foreground service -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
|
<!-- Notifications (Android 13+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Auto-start on boot -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<!-- Keep CPU awake during recording -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- Request to ignore battery optimizations -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!-- Network for sync -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.PhoneRecorder"
|
||||||
|
tools:targetApi="33">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".RecordingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:enabled="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.example.phonerecorder
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BootReceiver"
|
||||||
|
private const val PREFS_NAME = "phone_recorder_prefs"
|
||||||
|
private const val PREF_AUTO_START = "auto_start_enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||||
|
Log.i(TAG, "Boot completed received")
|
||||||
|
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val autoStartEnabled = prefs.getBoolean(PREF_AUTO_START, false)
|
||||||
|
|
||||||
|
if (autoStartEnabled) {
|
||||||
|
Log.i(TAG, "Auto-start enabled, starting recording service")
|
||||||
|
val serviceIntent = Intent(context, RecordingService::class.java)
|
||||||
|
ContextCompat.startForegroundService(context, serviceIntent)
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Auto-start disabled, not starting service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,540 @@
|
||||||
|
package com.example.phonerecorder
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.Spinner
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import com.example.phonerecorder.crypto.KeyManager
|
||||||
|
import com.example.phonerecorder.sync.NetworkHelper
|
||||||
|
import com.example.phonerecorder.sync.SyncManager
|
||||||
|
import com.example.phonerecorder.sync.SyncMode
|
||||||
|
import com.example.phonerecorder.sync.SyncSettings
|
||||||
|
import com.example.phonerecorder.sync.SyncStatus
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "phone_recorder_prefs"
|
||||||
|
private const val PREF_AUTO_START = "auto_start_enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var statusIndicator: android.view.View
|
||||||
|
private lateinit var statusText: TextView
|
||||||
|
private lateinit var elapsedTimeText: TextView
|
||||||
|
private lateinit var currentFileText: TextView
|
||||||
|
private lateinit var storageLocationText: TextView
|
||||||
|
private lateinit var storageInfoText: TextView
|
||||||
|
private lateinit var batteryInfoText: TextView
|
||||||
|
private lateinit var toggleButton: MaterialButton
|
||||||
|
private lateinit var autoStartSwitch: SwitchMaterial
|
||||||
|
private lateinit var batteryOptimizationHint: TextView
|
||||||
|
private lateinit var syncModeSpinner: Spinner
|
||||||
|
private lateinit var keyStatusText: TextView
|
||||||
|
private lateinit var importKeyButton: MaterialButton
|
||||||
|
private lateinit var syncStatusText: TextView
|
||||||
|
private lateinit var networkInfoText: TextView
|
||||||
|
private lateinit var configureLanButton: MaterialButton
|
||||||
|
private lateinit var versionText: TextView
|
||||||
|
|
||||||
|
private lateinit var storageHelper: StorageHelper
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private lateinit var keyManager: KeyManager
|
||||||
|
private lateinit var syncManager: SyncManager
|
||||||
|
private lateinit var networkHelper: NetworkHelper
|
||||||
|
private lateinit var syncSettings: SyncSettings
|
||||||
|
|
||||||
|
private var recordingService: RecordingService? = null
|
||||||
|
private var isBound = false
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
val binder = service as RecordingService.RecordingBinder
|
||||||
|
recordingService = binder.getService()
|
||||||
|
isBound = true
|
||||||
|
updateUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
recordingService = null
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val recordingReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
RecordingService.ACTION_RECORDING_STARTED,
|
||||||
|
RecordingService.ACTION_RECORDING_STOPPED,
|
||||||
|
RecordingService.ACTION_RECORDING_UPDATE -> updateUI()
|
||||||
|
RecordingService.ACTION_LOW_STORAGE -> {
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
R.string.storage_warning,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
updateUI()
|
||||||
|
updateSyncStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val permissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { permissions ->
|
||||||
|
val allGranted = permissions.all { it.value }
|
||||||
|
if (allGranted) {
|
||||||
|
startRecordingService()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
storageHelper = StorageHelper(this)
|
||||||
|
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
keyManager = KeyManager(this)
|
||||||
|
syncManager = SyncManager.getInstance(this)
|
||||||
|
networkHelper = NetworkHelper(this)
|
||||||
|
syncSettings = SyncSettings(this)
|
||||||
|
|
||||||
|
initViews()
|
||||||
|
setupListeners()
|
||||||
|
setupSyncUI()
|
||||||
|
checkBatteryOptimization()
|
||||||
|
|
||||||
|
// Start periodic sync based on settings
|
||||||
|
syncManager.startPeriodicSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
bindRecordingService()
|
||||||
|
registerReceivers()
|
||||||
|
updateUI()
|
||||||
|
updateNetworkInfo()
|
||||||
|
updateSyncStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
unregisterReceivers()
|
||||||
|
unbindRecordingService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
statusIndicator = findViewById(R.id.statusIndicator)
|
||||||
|
statusText = findViewById(R.id.statusText)
|
||||||
|
elapsedTimeText = findViewById(R.id.elapsedTimeText)
|
||||||
|
currentFileText = findViewById(R.id.currentFileText)
|
||||||
|
storageLocationText = findViewById(R.id.storageLocationText)
|
||||||
|
storageInfoText = findViewById(R.id.storageInfoText)
|
||||||
|
batteryInfoText = findViewById(R.id.batteryInfoText)
|
||||||
|
toggleButton = findViewById(R.id.toggleButton)
|
||||||
|
autoStartSwitch = findViewById(R.id.autoStartSwitch)
|
||||||
|
batteryOptimizationHint = findViewById(R.id.batteryOptimizationHint)
|
||||||
|
|
||||||
|
// Initialize auto-start switch state
|
||||||
|
autoStartSwitch.isChecked = prefs.getBoolean(PREF_AUTO_START, false)
|
||||||
|
updateAutoStartText()
|
||||||
|
|
||||||
|
// Sync UI elements
|
||||||
|
syncModeSpinner = findViewById(R.id.syncModeSpinner)
|
||||||
|
keyStatusText = findViewById(R.id.keyStatusText)
|
||||||
|
importKeyButton = findViewById(R.id.importKeyButton)
|
||||||
|
syncStatusText = findViewById(R.id.syncStatusText)
|
||||||
|
networkInfoText = findViewById(R.id.networkInfoText)
|
||||||
|
configureLanButton = findViewById(R.id.configureLanButton)
|
||||||
|
versionText = findViewById(R.id.versionText)
|
||||||
|
|
||||||
|
// Set version info
|
||||||
|
val buildDate = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US)
|
||||||
|
.format(java.util.Date(BuildConfig.BUILD_TIMESTAMP))
|
||||||
|
versionText.text = "v${BuildConfig.VERSION_NAME} (build $buildDate)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
toggleButton.setOnClickListener {
|
||||||
|
if (recordingService?.isRecording() == true) {
|
||||||
|
stopRecordingService()
|
||||||
|
} else {
|
||||||
|
checkPermissionsAndStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStartSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean(PREF_AUTO_START, isChecked).apply()
|
||||||
|
updateAutoStartText()
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryOptimizationHint.setOnClickListener {
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
importKeyButton.setOnClickListener {
|
||||||
|
showImportKeyDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
configureLanButton.setOnClickListener {
|
||||||
|
showLanConfigDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSyncUI() {
|
||||||
|
// Setup sync mode spinner
|
||||||
|
val syncModes = arrayOf(
|
||||||
|
getString(R.string.sync_mode_never),
|
||||||
|
getString(R.string.sync_mode_lan_only),
|
||||||
|
getString(R.string.sync_mode_wifi_only),
|
||||||
|
getString(R.string.sync_mode_any_network)
|
||||||
|
)
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, syncModes)
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
syncModeSpinner.adapter = adapter
|
||||||
|
|
||||||
|
// Set current selection
|
||||||
|
syncModeSpinner.setSelection(syncManager.getSyncMode().ordinal)
|
||||||
|
|
||||||
|
// Handle selection changes
|
||||||
|
syncModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
val mode = SyncMode.entries[position]
|
||||||
|
syncManager.setSyncMode(mode)
|
||||||
|
updateSyncStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateKeyStatus()
|
||||||
|
updateSyncStatus()
|
||||||
|
updateNetworkInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNetworkInfo() {
|
||||||
|
val ipAddress = networkHelper.getCurrentIpAddress()
|
||||||
|
networkInfoText.text = when {
|
||||||
|
ipAddress != null && networkHelper.isOnWifi() ->
|
||||||
|
getString(R.string.network_wifi, ipAddress)
|
||||||
|
networkHelper.isNetworkAvailable() ->
|
||||||
|
getString(R.string.network_mobile)
|
||||||
|
else ->
|
||||||
|
getString(R.string.network_not_connected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLanConfigDialog() {
|
||||||
|
val currentIp = networkHelper.getCurrentIpAddress() ?: "Not connected"
|
||||||
|
val currentServerUrl = syncSettings.lanServerUrl
|
||||||
|
val currentPrefix = syncSettings.lanSubnetPrefix
|
||||||
|
val currentApiKey = syncSettings.apiKey
|
||||||
|
|
||||||
|
// Create a layout with fields
|
||||||
|
val layout = android.widget.LinearLayout(this).apply {
|
||||||
|
orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
setPadding(48, 24, 48, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverLabel = TextView(this).apply {
|
||||||
|
text = "LAN Server URL:"
|
||||||
|
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||||
|
}
|
||||||
|
layout.addView(serverLabel)
|
||||||
|
|
||||||
|
val serverEdit = EditText(this).apply {
|
||||||
|
hint = "http://10.0.0.45:8000"
|
||||||
|
setText(currentServerUrl)
|
||||||
|
isSingleLine = true
|
||||||
|
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
|
||||||
|
}
|
||||||
|
layout.addView(serverEdit)
|
||||||
|
|
||||||
|
val prefixLabel = TextView(this).apply {
|
||||||
|
text = "\nLAN Subnet Prefix:"
|
||||||
|
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||||
|
}
|
||||||
|
layout.addView(prefixLabel)
|
||||||
|
|
||||||
|
val prefixEdit = EditText(this).apply {
|
||||||
|
hint = getString(R.string.lan_subnet_hint)
|
||||||
|
setText(currentPrefix)
|
||||||
|
isSingleLine = true
|
||||||
|
}
|
||||||
|
layout.addView(prefixEdit)
|
||||||
|
|
||||||
|
val apiKeyLabel = TextView(this).apply {
|
||||||
|
text = "\nAPI Key (for upload auth):"
|
||||||
|
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||||
|
}
|
||||||
|
layout.addView(apiKeyLabel)
|
||||||
|
|
||||||
|
val apiKeyEdit = EditText(this).apply {
|
||||||
|
hint = "Leave empty if not required"
|
||||||
|
setText(currentApiKey)
|
||||||
|
isSingleLine = true
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
layout.addView(apiKeyEdit)
|
||||||
|
|
||||||
|
val infoText = TextView(this).apply {
|
||||||
|
text = "\nYour IP: $currentIp"
|
||||||
|
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||||
|
textSize = 12f
|
||||||
|
}
|
||||||
|
layout.addView(infoText)
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.lan_config_title)
|
||||||
|
.setView(layout)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
// Save server URL
|
||||||
|
val serverUrl = serverEdit.text.toString().trim()
|
||||||
|
if (serverUrl.isNotEmpty()) {
|
||||||
|
syncSettings.lanServerUrl = serverUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save subnet prefix
|
||||||
|
var prefix = prefixEdit.text.toString().trim()
|
||||||
|
if (prefix.isNotEmpty() && !prefix.endsWith(".")) {
|
||||||
|
prefix = "$prefix."
|
||||||
|
}
|
||||||
|
if (prefix.isNotEmpty()) {
|
||||||
|
syncSettings.lanSubnetPrefix = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save API key
|
||||||
|
syncSettings.apiKey = apiKeyEdit.text.toString().trim()
|
||||||
|
|
||||||
|
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||||
|
updateNetworkInfo()
|
||||||
|
updateSyncStatus()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showImportKeyDialog() {
|
||||||
|
val editText = EditText(this).apply {
|
||||||
|
hint = getString(R.string.import_key_hint)
|
||||||
|
isSingleLine = false
|
||||||
|
minLines = 2
|
||||||
|
maxLines = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.import_key_title)
|
||||||
|
.setView(editText)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
val key = editText.text.toString().trim()
|
||||||
|
if (keyManager.setPublicKey(key)) {
|
||||||
|
Toast.makeText(this, R.string.import_key_success, Toast.LENGTH_SHORT).show()
|
||||||
|
updateKeyStatus()
|
||||||
|
updateSyncStatus()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.import_key_error, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateKeyStatus() {
|
||||||
|
keyStatusText.text = if (keyManager.hasPublicKey()) {
|
||||||
|
getString(R.string.key_status_configured)
|
||||||
|
} else {
|
||||||
|
getString(R.string.key_status_not_configured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSyncStatus() {
|
||||||
|
val status = syncManager.getSyncStatus(keyManager.hasPublicKey())
|
||||||
|
syncStatusText.text = when (status) {
|
||||||
|
is SyncStatus.Disabled -> getString(R.string.sync_status_disabled)
|
||||||
|
is SyncStatus.NoKey -> getString(R.string.sync_status_no_key)
|
||||||
|
is SyncStatus.NotOnWifi -> getString(R.string.sync_status_not_on_wifi)
|
||||||
|
is SyncStatus.NotOnLan -> getString(R.string.sync_status_not_on_lan)
|
||||||
|
is SyncStatus.NoNetwork -> getString(R.string.sync_status_no_network)
|
||||||
|
is SyncStatus.Ready -> getString(R.string.sync_status_pending, status.pending)
|
||||||
|
is SyncStatus.Synced -> getString(R.string.sync_status_synced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAutoStartText() {
|
||||||
|
autoStartSwitch.text = if (autoStartSwitch.isChecked) {
|
||||||
|
getString(R.string.auto_start_enabled)
|
||||||
|
} else {
|
||||||
|
getString(R.string.auto_start_disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPermissionsAndStart() {
|
||||||
|
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notGranted = permissions.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notGranted.isEmpty()) {
|
||||||
|
startRecordingService()
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(notGranted.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRecordingService() {
|
||||||
|
val intent = Intent(this, RecordingService::class.java)
|
||||||
|
ContextCompat.startForegroundService(this, intent)
|
||||||
|
bindRecordingService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRecordingService() {
|
||||||
|
recordingService?.stopRecording()
|
||||||
|
stopService(Intent(this, RecordingService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindRecordingService() {
|
||||||
|
val intent = Intent(this, RecordingService::class.java)
|
||||||
|
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unbindRecordingService() {
|
||||||
|
if (isBound) {
|
||||||
|
unbindService(serviceConnection)
|
||||||
|
isBound = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerReceivers() {
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(RecordingService.ACTION_RECORDING_STARTED)
|
||||||
|
addAction(RecordingService.ACTION_RECORDING_STOPPED)
|
||||||
|
addAction(RecordingService.ACTION_RECORDING_UPDATE)
|
||||||
|
addAction(RecordingService.ACTION_LOW_STORAGE)
|
||||||
|
}
|
||||||
|
LocalBroadcastManager.getInstance(this).registerReceiver(recordingReceiver, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterReceivers() {
|
||||||
|
LocalBroadcastManager.getInstance(this).unregisterReceiver(recordingReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUI() {
|
||||||
|
val isRecording = recordingService?.isRecording() == true
|
||||||
|
|
||||||
|
// Status indicator
|
||||||
|
statusIndicator.setBackgroundResource(
|
||||||
|
if (isRecording) R.drawable.status_circle_active else R.drawable.status_circle
|
||||||
|
)
|
||||||
|
statusText.text = getString(
|
||||||
|
if (isRecording) R.string.status_recording else R.string.status_stopped
|
||||||
|
)
|
||||||
|
|
||||||
|
// Elapsed time
|
||||||
|
val elapsed = recordingService?.getElapsedTime() ?: 0L
|
||||||
|
elapsedTimeText.text = formatElapsedTime(elapsed)
|
||||||
|
|
||||||
|
// Current file
|
||||||
|
val filename = recordingService?.getCurrentFilename()
|
||||||
|
currentFileText.text = if (filename != null) {
|
||||||
|
getString(R.string.current_file, filename)
|
||||||
|
} else {
|
||||||
|
getString(R.string.current_file, getString(R.string.no_file))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage info
|
||||||
|
val storageInfo = storageHelper.getStorageInfo()
|
||||||
|
storageLocationText.text = getString(
|
||||||
|
if (storageInfo.isSDCard) R.string.storage_location_sd else R.string.storage_location_internal
|
||||||
|
)
|
||||||
|
storageInfoText.text = getString(
|
||||||
|
R.string.storage_info,
|
||||||
|
storageHelper.formatBytes(storageInfo.usedBytes),
|
||||||
|
storageHelper.formatBytes(storageInfo.availableBytes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Battery info
|
||||||
|
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||||
|
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||||
|
batteryInfoText.text = getString(R.string.battery_info, batteryLevel)
|
||||||
|
|
||||||
|
// Toggle button
|
||||||
|
toggleButton.text = getString(
|
||||||
|
if (isRecording) R.string.stop_recording else R.string.start_recording
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatElapsedTime(millis: Long): String {
|
||||||
|
val seconds = (millis / 1000) % 60
|
||||||
|
val minutes = (millis / (1000 * 60)) % 60
|
||||||
|
val hours = millis / (1000 * 60 * 60)
|
||||||
|
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBatteryOptimization() {
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
showBatteryOptimizationDialog()
|
||||||
|
} else {
|
||||||
|
batteryOptimizationHint.visibility = android.view.View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBatteryOptimizationDialog() {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(R.string.battery_optimization_title)
|
||||||
|
.setMessage(R.string.battery_optimization_message)
|
||||||
|
.setPositiveButton(R.string.battery_optimization_button) { _, _ ->
|
||||||
|
openBatteryOptimizationSettings()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBatteryOptimizationSettings() {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:$packageName")
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fall back to general battery settings
|
||||||
|
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
package com.example.phonerecorder
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import com.example.phonerecorder.crypto.CryptoHelper
|
||||||
|
import com.example.phonerecorder.crypto.KeyManager
|
||||||
|
import com.example.phonerecorder.sync.SyncManager
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class RecordingService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RecordingService"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val CHANNEL_ID = "recording_channel"
|
||||||
|
|
||||||
|
// Segment rotation: 2 hours in milliseconds
|
||||||
|
private const val SEGMENT_DURATION_MS = 2 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
// Storage check interval: 5 minutes
|
||||||
|
private const val STORAGE_CHECK_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
// Notification update interval: 1 second
|
||||||
|
private const val NOTIFICATION_UPDATE_INTERVAL_MS = 1000L
|
||||||
|
|
||||||
|
// Broadcast actions
|
||||||
|
const val ACTION_RECORDING_STARTED = "com.example.phonerecorder.RECORDING_STARTED"
|
||||||
|
const val ACTION_RECORDING_STOPPED = "com.example.phonerecorder.RECORDING_STOPPED"
|
||||||
|
const val ACTION_RECORDING_UPDATE = "com.example.phonerecorder.RECORDING_UPDATE"
|
||||||
|
const val ACTION_LOW_STORAGE = "com.example.phonerecorder.LOW_STORAGE"
|
||||||
|
|
||||||
|
// Intent extras
|
||||||
|
const val EXTRA_FILENAME = "filename"
|
||||||
|
const val EXTRA_ELAPSED_TIME = "elapsed_time"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binder = RecordingBinder()
|
||||||
|
private var mediaRecorder: MediaRecorder? = null
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private lateinit var storageHelper: StorageHelper
|
||||||
|
private lateinit var notificationManager: NotificationManager
|
||||||
|
private lateinit var keyManager: KeyManager
|
||||||
|
private lateinit var cryptoHelper: CryptoHelper
|
||||||
|
private var syncManager: SyncManager? = null
|
||||||
|
|
||||||
|
private var isRecording = false
|
||||||
|
private var currentTempFile: File? = null
|
||||||
|
private var currentFile: File? = null
|
||||||
|
private var segmentStartTime: Long = 0
|
||||||
|
private var totalElapsedTime: Long = 0
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private val segmentRotationRunnable = Runnable { rotateSegment() }
|
||||||
|
private val storageCheckRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
checkStorage()
|
||||||
|
if (isRecording) {
|
||||||
|
handler.postDelayed(this, STORAGE_CHECK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val notificationUpdateRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
updateNotification()
|
||||||
|
broadcastUpdate()
|
||||||
|
if (isRecording) {
|
||||||
|
handler.postDelayed(this, NOTIFICATION_UPDATE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class RecordingBinder : Binder() {
|
||||||
|
fun getService(): RecordingService = this@RecordingService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
storageHelper = StorageHelper(this)
|
||||||
|
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
keyManager = KeyManager(this)
|
||||||
|
cryptoHelper = CryptoHelper(keyManager)
|
||||||
|
syncManager = SyncManager.getInstance(this)
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = binder
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (!isRecording) {
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopRecording()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRecording(): Boolean = isRecording
|
||||||
|
|
||||||
|
fun getCurrentFilename(): String? = currentFile?.name
|
||||||
|
|
||||||
|
fun getElapsedTime(): Long {
|
||||||
|
return if (isRecording) {
|
||||||
|
totalElapsedTime + (System.currentTimeMillis() - segmentStartTime)
|
||||||
|
} else {
|
||||||
|
totalElapsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRecording() {
|
||||||
|
if (isRecording) return
|
||||||
|
|
||||||
|
if (storageHelper.isStorageLow()) {
|
||||||
|
broadcastLowStorage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
acquireWakeLock()
|
||||||
|
startNewSegment()
|
||||||
|
isRecording = true
|
||||||
|
totalElapsedTime = 0
|
||||||
|
|
||||||
|
// Schedule segment rotation
|
||||||
|
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
|
||||||
|
|
||||||
|
// Start storage checks
|
||||||
|
handler.postDelayed(storageCheckRunnable, STORAGE_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
|
// Start notification updates
|
||||||
|
handler.post(notificationUpdateRunnable)
|
||||||
|
|
||||||
|
// Start foreground service
|
||||||
|
startForeground(NOTIFICATION_ID, createNotification())
|
||||||
|
|
||||||
|
broadcastRecordingStarted()
|
||||||
|
Log.i(TAG, "Recording started: ${currentFile?.name}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start recording", e)
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopRecording() {
|
||||||
|
if (!isRecording) return
|
||||||
|
|
||||||
|
isRecording = false
|
||||||
|
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
|
||||||
|
|
||||||
|
handler.removeCallbacks(segmentRotationRunnable)
|
||||||
|
handler.removeCallbacks(storageCheckRunnable)
|
||||||
|
handler.removeCallbacks(notificationUpdateRunnable)
|
||||||
|
|
||||||
|
releaseMediaRecorder()
|
||||||
|
releaseWakeLock()
|
||||||
|
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
broadcastRecordingStopped()
|
||||||
|
|
||||||
|
Log.i(TAG, "Recording stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNewSegment() {
|
||||||
|
releaseMediaRecorder()
|
||||||
|
|
||||||
|
// Record to temp file first (will be encrypted after recording stops)
|
||||||
|
currentTempFile = storageHelper.getNewTempRecordingFile()
|
||||||
|
segmentStartTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaRecorder(this)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
MediaRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder?.apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.OGG)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||||
|
setAudioEncodingBitRate(64000)
|
||||||
|
setAudioSamplingRate(48000)
|
||||||
|
setAudioChannels(1)
|
||||||
|
setOutputFile(currentTempFile?.absolutePath)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update currentFile to show the expected encrypted filename
|
||||||
|
currentFile = currentTempFile?.let { storageHelper.getEncryptedFileForTemp(it) }
|
||||||
|
|
||||||
|
Log.i(TAG, "New segment started: ${currentTempFile?.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotateSegment() {
|
||||||
|
if (!isRecording) return
|
||||||
|
|
||||||
|
Log.i(TAG, "Rotating segment...")
|
||||||
|
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
|
||||||
|
|
||||||
|
try {
|
||||||
|
startNewSegment()
|
||||||
|
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to rotate segment", e)
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkStorage() {
|
||||||
|
if (storageHelper.isStorageLow()) {
|
||||||
|
Log.w(TAG, "Low storage detected, stopping recording")
|
||||||
|
stopRecording()
|
||||||
|
broadcastLowStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseMediaRecorder() {
|
||||||
|
val tempFile = currentTempFile
|
||||||
|
try {
|
||||||
|
mediaRecorder?.apply {
|
||||||
|
stop()
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error releasing MediaRecorder", e)
|
||||||
|
}
|
||||||
|
mediaRecorder = null
|
||||||
|
|
||||||
|
// Encrypt the completed recording
|
||||||
|
if (tempFile != null && tempFile.exists()) {
|
||||||
|
encryptCompletedRecording(tempFile)
|
||||||
|
}
|
||||||
|
currentTempFile = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a completed recording and deletes the temp file.
|
||||||
|
* If no public key is available, the recording is NOT stored (security requirement).
|
||||||
|
*/
|
||||||
|
private fun encryptCompletedRecording(tempFile: File) {
|
||||||
|
if (!cryptoHelper.hasPublicKey()) {
|
||||||
|
Log.w(TAG, "No public key configured - deleting unencrypted recording for security")
|
||||||
|
tempFile.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val encryptedFile = storageHelper.getEncryptedFileForTemp(tempFile)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val success = cryptoHelper.encryptFile(tempFile, encryptedFile)
|
||||||
|
if (success) {
|
||||||
|
// Delete temp file after successful encryption
|
||||||
|
tempFile.delete()
|
||||||
|
currentFile = encryptedFile
|
||||||
|
Log.i(TAG, "Recording encrypted: ${encryptedFile.name}")
|
||||||
|
|
||||||
|
// Trigger immediate sync
|
||||||
|
syncManager?.scheduleImmediateSync()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to encrypt recording - deleting temp file for security")
|
||||||
|
tempFile.delete()
|
||||||
|
encryptedFile.delete() // Clean up partial encrypted file
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error during encryption - deleting files for security", e)
|
||||||
|
tempFile.delete()
|
||||||
|
encryptedFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"PhoneRecorder::RecordingWakeLock"
|
||||||
|
).apply {
|
||||||
|
acquire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_description)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotification(): Notification {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val elapsedFormatted = formatElapsedTime(getElapsedTime())
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(getString(R.string.notification_text, elapsedFormatted))
|
||||||
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification() {
|
||||||
|
if (isRecording) {
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatElapsedTime(millis: Long): String {
|
||||||
|
val seconds = (millis / 1000) % 60
|
||||||
|
val minutes = (millis / (1000 * 60)) % 60
|
||||||
|
val hours = millis / (1000 * 60 * 60)
|
||||||
|
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastRecordingStarted() {
|
||||||
|
val intent = Intent(ACTION_RECORDING_STARTED).apply {
|
||||||
|
putExtra(EXTRA_FILENAME, currentFile?.name)
|
||||||
|
}
|
||||||
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastRecordingStopped() {
|
||||||
|
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_RECORDING_STOPPED))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastUpdate() {
|
||||||
|
val intent = Intent(ACTION_RECORDING_UPDATE).apply {
|
||||||
|
putExtra(EXTRA_FILENAME, currentFile?.name)
|
||||||
|
putExtra(EXTRA_ELAPSED_TIME, getElapsedTime())
|
||||||
|
}
|
||||||
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastLowStorage() {
|
||||||
|
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_LOW_STORAGE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
package com.example.phonerecorder
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.StatFs
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class StorageHelper(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val RECORDINGS_FOLDER = "recordings"
|
||||||
|
private const val TEMP_FOLDER = "temp"
|
||||||
|
private const val LOW_STORAGE_THRESHOLD_MB = 500L
|
||||||
|
private const val BYTES_PER_MB = 1024L * 1024L
|
||||||
|
private const val BYTES_PER_GB = 1024L * 1024L * 1024L
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StorageInfo(
|
||||||
|
val usedBytes: Long,
|
||||||
|
val availableBytes: Long,
|
||||||
|
// this should be a storage type, not an isSDCard
|
||||||
|
val isSDCard: Boolean,
|
||||||
|
val recordingsDir: File
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the best available storage location.
|
||||||
|
* Prefers SD card, falls back to internal storage.
|
||||||
|
*/
|
||||||
|
fun getRecordingsDirectory(): File {
|
||||||
|
val externalDirs = context.getExternalFilesDirs(null)
|
||||||
|
|
||||||
|
// Find SD card (second external storage, if available)
|
||||||
|
val sdCardDir = externalDirs.getOrNull(1)
|
||||||
|
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
|
||||||
|
return ensureRecordingsFolder(sdCardDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to primary external storage
|
||||||
|
val primaryDir = externalDirs.getOrNull(0)
|
||||||
|
if (primaryDir != null && isStorageAvailable(primaryDir)) {
|
||||||
|
return ensureRecordingsFolder(primaryDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: internal files directory
|
||||||
|
return ensureRecordingsFolder(context.filesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we're using SD card storage.
|
||||||
|
*/
|
||||||
|
fun isUsingSDCard(): Boolean {
|
||||||
|
val externalDirs = context.getExternalFilesDirs(null)
|
||||||
|
val sdCardDir = externalDirs.getOrNull(1)
|
||||||
|
return sdCardDir != null && isStorageAvailable(sdCardDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets storage information for the recordings directory.
|
||||||
|
*/
|
||||||
|
fun getStorageInfo(): StorageInfo {
|
||||||
|
val recordingsDir = getRecordingsDirectory()
|
||||||
|
val parentDir = recordingsDir.parentFile ?: recordingsDir
|
||||||
|
|
||||||
|
val statFs = StatFs(parentDir.absolutePath)
|
||||||
|
val availableBytes = statFs.availableBytes
|
||||||
|
val usedBytes = calculateUsedStorage(recordingsDir)
|
||||||
|
|
||||||
|
return StorageInfo(
|
||||||
|
usedBytes = usedBytes,
|
||||||
|
availableBytes = availableBytes,
|
||||||
|
isSDCard = isUsingSDCard(),
|
||||||
|
recordingsDir = recordingsDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if storage is below the low threshold.
|
||||||
|
*/
|
||||||
|
fun isStorageLow(): Boolean {
|
||||||
|
val info = getStorageInfo()
|
||||||
|
return info.availableBytes < LOW_STORAGE_THRESHOLD_MB * BYTES_PER_MB
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the temp directory for unencrypted recordings during recording.
|
||||||
|
*/
|
||||||
|
fun getTempDirectory(): File {
|
||||||
|
val baseDir = getBaseStorageDirectory()
|
||||||
|
return ensureFolder(baseDir, TEMP_FOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base storage directory (parent of recordings and temp).
|
||||||
|
*/
|
||||||
|
private fun getBaseStorageDirectory(): File {
|
||||||
|
val externalDirs = context.getExternalFilesDirs(null)
|
||||||
|
|
||||||
|
// Find SD card (second external storage, if available)
|
||||||
|
val sdCardDir = externalDirs.getOrNull(1)
|
||||||
|
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
|
||||||
|
return sdCardDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to primary external storage
|
||||||
|
val primaryDir = externalDirs.getOrNull(0)
|
||||||
|
if (primaryDir != null && isStorageAvailable(primaryDir)) {
|
||||||
|
return primaryDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: internal files directory
|
||||||
|
return context.filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new recording filename with current timestamp (for encrypted files).
|
||||||
|
*/
|
||||||
|
fun generateFilename(): String {
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||||
|
val timestamp = dateFormat.format(Date())
|
||||||
|
return "recording_$timestamp.ogg.enc"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a temp filename (unencrypted, used during recording).
|
||||||
|
*/
|
||||||
|
fun generateTempFilename(): String {
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||||
|
val timestamp = dateFormat.format(Date())
|
||||||
|
return "recording_$timestamp.ogg"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the full path for a new temp recording file (unencrypted during recording).
|
||||||
|
*/
|
||||||
|
fun getNewTempRecordingFile(): File {
|
||||||
|
val dir = getTempDirectory()
|
||||||
|
return File(dir, generateTempFilename())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the full path for a new encrypted recording file.
|
||||||
|
*/
|
||||||
|
fun getNewRecordingFile(): File {
|
||||||
|
val dir = getRecordingsDirectory()
|
||||||
|
return File(dir, generateFilename())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the encrypted file path corresponding to a temp file.
|
||||||
|
*/
|
||||||
|
fun getEncryptedFileForTemp(tempFile: File): File {
|
||||||
|
val dir = getRecordingsDirectory()
|
||||||
|
val encryptedName = tempFile.name + ".enc"
|
||||||
|
return File(dir, encryptedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all unsynced encrypted recording files.
|
||||||
|
*/
|
||||||
|
fun listUnsyncedRecordings(syncedFiles: Set<String>): List<File> {
|
||||||
|
val dir = getRecordingsDirectory()
|
||||||
|
return dir.listFiles { file -> file.name.endsWith(".ogg.enc") }
|
||||||
|
?.filter { !syncedFiles.contains(it.name) }
|
||||||
|
?.sortedBy { it.lastModified() }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats bytes as human-readable string (e.g., "2.5 GB").
|
||||||
|
*/
|
||||||
|
fun formatBytes(bytes: Long): String {
|
||||||
|
return when {
|
||||||
|
bytes >= BYTES_PER_GB -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / BYTES_PER_GB)
|
||||||
|
bytes >= BYTES_PER_MB -> String.format(Locale.US, "%.1f MB", bytes.toDouble() / BYTES_PER_MB)
|
||||||
|
else -> String.format(Locale.US, "%d KB", bytes / 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all recording files sorted by date (newest first).
|
||||||
|
* Includes both encrypted (.ogg.enc) and unencrypted (.ogg) files.
|
||||||
|
*/
|
||||||
|
fun listRecordings(): List<File> {
|
||||||
|
val dir = getRecordingsDirectory()
|
||||||
|
return dir.listFiles { file ->
|
||||||
|
file.name.endsWith(".ogg") || file.name.endsWith(".ogg.enc")
|
||||||
|
}
|
||||||
|
?.sortedByDescending { it.lastModified() }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isStorageAvailable(dir: File): Boolean {
|
||||||
|
return try {
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
dir.canWrite()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureRecordingsFolder(baseDir: File): File {
|
||||||
|
return ensureFolder(baseDir, RECORDINGS_FOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFolder(baseDir: File, folderName: String): File {
|
||||||
|
val dir = File(baseDir, folderName)
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateUsedStorage(dir: File): Long {
|
||||||
|
if (!dir.exists()) return 0L
|
||||||
|
return dir.listFiles()?.sumOf { it.length() } ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
package com.example.phonerecorder.api
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class RecordingsApi {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RecordingsApi"
|
||||||
|
private const val CONNECT_TIMEOUT_SECONDS = 30L
|
||||||
|
private const val READ_TIMEOUT_SECONDS = 120L
|
||||||
|
private const val WRITE_TIMEOUT_SECONDS = 120L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the server is healthy and reachable.
|
||||||
|
*
|
||||||
|
* @param serverUrl The base server URL
|
||||||
|
* @return true if server responds with status "ok"
|
||||||
|
*/
|
||||||
|
fun checkHealth(serverUrl: String): Boolean {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$serverUrl/health")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body?.string()
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
json.optString("status") == "ok"
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Health check failed: ${response.code}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Health check error", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the server's public key for encryption.
|
||||||
|
*
|
||||||
|
* @param serverUrl The base server URL
|
||||||
|
* @return Base64-encoded public key, or null on failure
|
||||||
|
*/
|
||||||
|
fun getPublicKey(serverUrl: String): String? {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$serverUrl/api/v1/public-key")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body?.string()
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
json.optString("public_key", null)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Get public key failed: ${response.code}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Get public key error", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an encrypted recording file to the server.
|
||||||
|
* Verifies the server received the correct data by comparing SHA-256 hashes.
|
||||||
|
*
|
||||||
|
* @param serverUrl The base server URL
|
||||||
|
* @param file The encrypted file to upload
|
||||||
|
* @param apiKey Optional API key for authentication
|
||||||
|
* @return UploadResult indicating success or failure with message
|
||||||
|
*/
|
||||||
|
fun uploadRecording(serverUrl: String, file: File, apiKey: String? = null): UploadResult {
|
||||||
|
// Compute hash of file before upload for verification
|
||||||
|
val localHash = computeFileHash(file)
|
||||||
|
if (localHash == null) {
|
||||||
|
Log.e(TAG, "Failed to compute hash for: ${file.name}")
|
||||||
|
return UploadResult(false, "Failed to compute file hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaType = "application/octet-stream".toMediaType()
|
||||||
|
val requestBody = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart(
|
||||||
|
"file",
|
||||||
|
file.name,
|
||||||
|
file.asRequestBody(mediaType)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url("$serverUrl/api/v1/recordings/upload")
|
||||||
|
.post(requestBody)
|
||||||
|
|
||||||
|
if (!apiKey.isNullOrEmpty()) {
|
||||||
|
requestBuilder.addHeader("X-API-Key", apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = requestBuilder.build()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
// Verify server received correct data
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
val serverHash = json.optString("received_hash", "")
|
||||||
|
val verified = json.optBoolean("verified", false)
|
||||||
|
|
||||||
|
if (serverHash.isNotEmpty() && serverHash == localHash && verified) {
|
||||||
|
Log.i(TAG, "Upload verified: ${file.name} (hash: ${localHash.take(8)}...)")
|
||||||
|
UploadResult(true, "Upload verified", verified = true)
|
||||||
|
} else if (serverHash.isEmpty()) {
|
||||||
|
// Legacy server without hash verification
|
||||||
|
Log.w(TAG, "Upload successful but unverified (no hash): ${file.name}")
|
||||||
|
UploadResult(true, "Upload successful (unverified)", verified = false)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Hash mismatch! Local: $localHash, Server: $serverHash")
|
||||||
|
UploadResult(false, "Data corruption detected - hash mismatch")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val errorMessage = try {
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
json.optString("detail", "Upload failed: ${response.code}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Upload failed: ${response.code}"
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Upload failed: $errorMessage")
|
||||||
|
UploadResult(false, errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Upload error", e)
|
||||||
|
UploadResult(false, "Network error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes SHA-256 hash of a file.
|
||||||
|
*/
|
||||||
|
private fun computeFileHash(file: File): String? {
|
||||||
|
return try {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
FileInputStream(file).use { fis ->
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (fis.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digest.digest().joinToString("") { "%02x".format(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Hash computation failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists recordings on the server, optionally filtered by date.
|
||||||
|
*
|
||||||
|
* @param serverUrl The base server URL
|
||||||
|
* @param date Optional date filter in YYYY-MM-DD format
|
||||||
|
* @return List of recording filenames, or null on failure
|
||||||
|
*/
|
||||||
|
fun listRecordings(serverUrl: String, date: String? = null): List<String>? {
|
||||||
|
val url = if (date != null) {
|
||||||
|
"$serverUrl/api/v1/recordings?date=$date"
|
||||||
|
} else {
|
||||||
|
"$serverUrl/api/v1/recordings"
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body?.string()
|
||||||
|
val json = JSONObject(body ?: "{}")
|
||||||
|
val recordings = json.optJSONArray("recordings")
|
||||||
|
if (recordings != null) {
|
||||||
|
(0 until recordings.length()).map { recordings.getString(it) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "List recordings failed: ${response.code}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "List recordings error", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UploadResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val verified: Boolean = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.example.phonerecorder.crypto
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.goterl.lazysodium.LazySodiumAndroid
|
||||||
|
import com.goterl.lazysodium.SodiumAndroid
|
||||||
|
import com.goterl.lazysodium.interfaces.Box
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
class CryptoHelper(private val keyManager: KeyManager) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CryptoHelper"
|
||||||
|
|
||||||
|
// File format constants
|
||||||
|
private val MAGIC_BYTES = byteArrayOf('S'.code.toByte(), 'R'.code.toByte(), 'E'.code.toByte(), 'C'.code.toByte())
|
||||||
|
private const val VERSION: Byte = 1
|
||||||
|
private const val CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming encryption
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lazySodium: LazySodiumAndroid = LazySodiumAndroid(SodiumAndroid())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a file using libsodium sealed box encryption.
|
||||||
|
*
|
||||||
|
* File format:
|
||||||
|
* [4 bytes: "SREC" magic]
|
||||||
|
* [1 byte: version (1)]
|
||||||
|
* [4 bytes: chunk count (big-endian)]
|
||||||
|
* For each chunk:
|
||||||
|
* [4 bytes: encrypted length (big-endian)]
|
||||||
|
* [N bytes: sealed box encrypted data]
|
||||||
|
*
|
||||||
|
* @param inputFile The unencrypted source file
|
||||||
|
* @param outputFile The destination for the encrypted file
|
||||||
|
* @return true if encryption succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
fun encryptFile(inputFile: File, outputFile: File): Boolean {
|
||||||
|
val publicKey = keyManager.getPublicKey()
|
||||||
|
if (publicKey == null) {
|
||||||
|
Log.e(TAG, "No public key available for encryption")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FileInputStream(inputFile).use { input ->
|
||||||
|
FileOutputStream(outputFile).use { output ->
|
||||||
|
// Calculate number of chunks
|
||||||
|
val fileSize = inputFile.length()
|
||||||
|
val chunkCount = ((fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
output.write(MAGIC_BYTES)
|
||||||
|
output.write(VERSION.toInt())
|
||||||
|
output.write(intToBytes(chunkCount))
|
||||||
|
|
||||||
|
// Encrypt and write each chunk
|
||||||
|
val buffer = ByteArray(CHUNK_SIZE)
|
||||||
|
var bytesRead: Int
|
||||||
|
|
||||||
|
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
val chunk = if (bytesRead < CHUNK_SIZE) {
|
||||||
|
buffer.copyOf(bytesRead)
|
||||||
|
} else {
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
val encrypted = encryptChunk(chunk, publicKey)
|
||||||
|
if (encrypted == null) {
|
||||||
|
Log.e(TAG, "Failed to encrypt chunk")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write encrypted chunk length and data
|
||||||
|
output.write(intToBytes(encrypted.size))
|
||||||
|
output.write(encrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Successfully encrypted file: ${inputFile.name} -> ${outputFile.name}")
|
||||||
|
return true
|
||||||
|
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "IO error during encryption", e)
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Unexpected error during encryption", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a single chunk using sealed box encryption.
|
||||||
|
* Sealed box provides anonymous sender encryption using X25519 + XSalsa20-Poly1305.
|
||||||
|
*/
|
||||||
|
private fun encryptChunk(data: ByteArray, publicKey: ByteArray): ByteArray? {
|
||||||
|
return try {
|
||||||
|
// Sealed box overhead: crypto_box_SEALBYTES (48 bytes)
|
||||||
|
val ciphertext = ByteArray(data.size + Box.SEALBYTES)
|
||||||
|
val success = lazySodium.cryptoBoxSeal(ciphertext, data, data.size.toLong(), publicKey)
|
||||||
|
if (success) ciphertext else null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Encryption failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun intToBytes(value: Int): ByteArray {
|
||||||
|
return ByteBuffer.allocate(4)
|
||||||
|
.order(ByteOrder.BIG_ENDIAN)
|
||||||
|
.putInt(value)
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a public key is available for encryption.
|
||||||
|
*/
|
||||||
|
fun hasPublicKey(): Boolean = keyManager.getPublicKey() != null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.example.phonerecorder.crypto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import com.goterl.lazysodium.interfaces.Box
|
||||||
|
|
||||||
|
class KeyManager(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "KeyManager"
|
||||||
|
private const val PREFS_NAME = "crypto_prefs"
|
||||||
|
private const val KEY_PUBLIC_KEY = "server_public_key"
|
||||||
|
private const val PUBLIC_KEY_LENGTH = Box.PUBLICKEYBYTES // 32 bytes for X25519
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the server's public key from a Base64-encoded string.
|
||||||
|
*
|
||||||
|
* @param base64Key The Base64-encoded public key
|
||||||
|
* @return true if the key was valid and stored, false otherwise
|
||||||
|
*/
|
||||||
|
fun setPublicKey(base64Key: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val keyBytes = Base64.decode(base64Key.trim(), Base64.DEFAULT)
|
||||||
|
if (keyBytes.size != PUBLIC_KEY_LENGTH) {
|
||||||
|
Log.e(TAG, "Invalid public key length: ${keyBytes.size}, expected $PUBLIC_KEY_LENGTH")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_PUBLIC_KEY, base64Key.trim()).apply()
|
||||||
|
Log.i(TAG, "Server public key stored successfully")
|
||||||
|
true
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.e(TAG, "Invalid Base64 encoding for public key", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the stored server public key as raw bytes.
|
||||||
|
*
|
||||||
|
* @return The public key bytes, or null if not set
|
||||||
|
*/
|
||||||
|
fun getPublicKey(): ByteArray? {
|
||||||
|
val base64Key = prefs.getString(KEY_PUBLIC_KEY, null) ?: return null
|
||||||
|
return try {
|
||||||
|
val keyBytes = Base64.decode(base64Key, Base64.DEFAULT)
|
||||||
|
if (keyBytes.size == PUBLIC_KEY_LENGTH) keyBytes else null
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Log.e(TAG, "Failed to decode stored public key", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored public key as a Base64 string for display.
|
||||||
|
*/
|
||||||
|
fun getPublicKeyBase64(): String? {
|
||||||
|
return prefs.getString(KEY_PUBLIC_KEY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a public key has been configured.
|
||||||
|
*/
|
||||||
|
fun hasPublicKey(): Boolean = getPublicKey() != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the stored public key.
|
||||||
|
*/
|
||||||
|
fun clearPublicKey() {
|
||||||
|
prefs.edit().remove(KEY_PUBLIC_KEY).apply()
|
||||||
|
Log.i(TAG, "Server public key cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.example.phonerecorder.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
|
class NetworkHelper(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NetworkHelper"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectivityManager: ConnectivityManager =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
private val syncSettings = SyncSettings(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if any network is available.
|
||||||
|
*/
|
||||||
|
fun isNetworkAvailable(): Boolean {
|
||||||
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if currently connected to WiFi.
|
||||||
|
*/
|
||||||
|
fun isOnWifi(): Boolean {
|
||||||
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if currently on the configured LAN subnet.
|
||||||
|
* The subnet prefix is configurable in SyncSettings (default: 10.0.0.)
|
||||||
|
*/
|
||||||
|
fun isOnLan(): Boolean {
|
||||||
|
if (!isOnWifi()) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
val ipAddress = getLocalIpAddress()
|
||||||
|
val lanPrefix = syncSettings.lanSubnetPrefix
|
||||||
|
val isLan = ipAddress?.startsWith(lanPrefix) == true
|
||||||
|
Log.d(TAG, "Local IP: $ipAddress, LAN prefix: $lanPrefix, isOnLan: $isLan")
|
||||||
|
return isLan
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error checking LAN status", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current local IP address (for display/debugging).
|
||||||
|
*/
|
||||||
|
fun getCurrentIpAddress(): String? = getLocalIpAddress()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the local IPv4 address.
|
||||||
|
*/
|
||||||
|
private fun getLocalIpAddress(): String? {
|
||||||
|
try {
|
||||||
|
val interfaces = NetworkInterface.getNetworkInterfaces()
|
||||||
|
while (interfaces.hasMoreElements()) {
|
||||||
|
val networkInterface = interfaces.nextElement()
|
||||||
|
if (networkInterface.isLoopback || !networkInterface.isUp) continue
|
||||||
|
|
||||||
|
val addresses = networkInterface.inetAddresses
|
||||||
|
while (addresses.hasMoreElements()) {
|
||||||
|
val address = addresses.nextElement()
|
||||||
|
if (address is Inet4Address && !address.isLoopbackAddress) {
|
||||||
|
return address.hostAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting local IP address", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if sync should proceed based on current network state and sync mode.
|
||||||
|
*/
|
||||||
|
fun shouldSync(syncMode: SyncMode): Boolean {
|
||||||
|
return when (syncMode) {
|
||||||
|
SyncMode.NEVER -> false
|
||||||
|
SyncMode.LAN_ONLY -> isOnLan()
|
||||||
|
SyncMode.WIFI_ONLY -> isOnWifi()
|
||||||
|
SyncMode.ANY_NETWORK -> isNetworkAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
package com.example.phonerecorder.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.example.phonerecorder.StorageHelper
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SyncManager private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncManager"
|
||||||
|
private const val PERIODIC_SYNC_INTERVAL_MINUTES = 15L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: SyncManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): SyncManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: SyncManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val workManager = WorkManager.getInstance(context)
|
||||||
|
private val syncSettings = SyncSettings(context)
|
||||||
|
private val syncTracker = SyncTracker(context)
|
||||||
|
private val storageHelper = StorageHelper(context)
|
||||||
|
private val networkHelper = NetworkHelper(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or updates the periodic sync schedule based on current settings.
|
||||||
|
*/
|
||||||
|
fun startPeriodicSync() {
|
||||||
|
val syncMode = syncSettings.syncMode
|
||||||
|
if (syncMode == SyncMode.NEVER) {
|
||||||
|
Log.i(TAG, "Sync disabled, cancelling periodic work")
|
||||||
|
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val constraints = buildConstraints(syncMode)
|
||||||
|
|
||||||
|
val periodicWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||||
|
PERIODIC_SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
SyncWorker.WORK_NAME_PERIODIC,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicWorkRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "Periodic sync scheduled for mode: $syncMode")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the periodic sync.
|
||||||
|
*/
|
||||||
|
fun stopPeriodicSync() {
|
||||||
|
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
|
||||||
|
Log.i(TAG, "Periodic sync stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an immediate sync attempt.
|
||||||
|
*/
|
||||||
|
fun scheduleImmediateSync() {
|
||||||
|
val syncMode = syncSettings.syncMode
|
||||||
|
if (syncMode == SyncMode.NEVER) {
|
||||||
|
Log.i(TAG, "Sync disabled, skipping immediate sync")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val constraints = buildConstraints(syncMode)
|
||||||
|
|
||||||
|
val immediateWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniqueWork(
|
||||||
|
SyncWorker.WORK_NAME_IMMEDIATE,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
immediateWorkRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "Immediate sync scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the count of files pending sync.
|
||||||
|
*/
|
||||||
|
fun getPendingSyncCount(): Int {
|
||||||
|
val syncedFiles = syncTracker.getSyncedFiles()
|
||||||
|
val allFiles = storageHelper.listUnsyncedRecordings(emptySet())
|
||||||
|
return allFiles.count { !syncedFiles.contains(it.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the detailed sync status based on current conditions.
|
||||||
|
* @param hasKey Whether the server public key is configured
|
||||||
|
*/
|
||||||
|
fun getSyncStatus(hasKey: Boolean): SyncStatus {
|
||||||
|
val syncMode = syncSettings.syncMode
|
||||||
|
|
||||||
|
// Check sync mode first
|
||||||
|
if (syncMode == SyncMode.NEVER) {
|
||||||
|
return SyncStatus.Disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check key
|
||||||
|
if (!hasKey) {
|
||||||
|
return SyncStatus.NoKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check network conditions based on mode
|
||||||
|
when (syncMode) {
|
||||||
|
SyncMode.LAN_ONLY -> {
|
||||||
|
if (!networkHelper.isOnWifi()) {
|
||||||
|
return SyncStatus.NotOnWifi
|
||||||
|
}
|
||||||
|
if (!networkHelper.isOnLan()) {
|
||||||
|
return SyncStatus.NotOnLan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncMode.WIFI_ONLY -> {
|
||||||
|
if (!networkHelper.isOnWifi()) {
|
||||||
|
return SyncStatus.NotOnWifi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncMode.ANY_NETWORK -> {
|
||||||
|
if (!networkHelper.isNetworkAvailable()) {
|
||||||
|
return SyncStatus.NoNetwork
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncMode.NEVER -> { /* Already handled above */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network conditions are met, check pending count
|
||||||
|
val pending = getPendingSyncCount()
|
||||||
|
return if (pending > 0) {
|
||||||
|
SyncStatus.Ready(pending)
|
||||||
|
} else {
|
||||||
|
SyncStatus.Synced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current sync mode.
|
||||||
|
*/
|
||||||
|
fun getSyncMode(): SyncMode = syncSettings.syncMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the sync mode and restarts periodic sync if needed.
|
||||||
|
*/
|
||||||
|
fun setSyncMode(mode: SyncMode) {
|
||||||
|
syncSettings.syncMode = mode
|
||||||
|
startPeriodicSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildConstraints(syncMode: SyncMode): Constraints {
|
||||||
|
val builder = Constraints.Builder()
|
||||||
|
|
||||||
|
when (syncMode) {
|
||||||
|
SyncMode.NEVER -> {
|
||||||
|
// Should not reach here, but set impossible constraint
|
||||||
|
builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
SyncMode.LAN_ONLY, SyncMode.WIFI_ONLY -> {
|
||||||
|
// Require unmetered (WiFi) connection
|
||||||
|
builder.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
}
|
||||||
|
SyncMode.ANY_NETWORK -> {
|
||||||
|
// Any connected network
|
||||||
|
builder.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.example.phonerecorder.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
enum class SyncMode {
|
||||||
|
NEVER, // Never sync
|
||||||
|
LAN_ONLY, // Only sync when on LAN (10.0.0.x subnet)
|
||||||
|
WIFI_ONLY, // Only sync when on WiFi (any network)
|
||||||
|
ANY_NETWORK // Sync on any network (including mobile data)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SyncStatus {
|
||||||
|
object Disabled : SyncStatus()
|
||||||
|
object NoKey : SyncStatus()
|
||||||
|
object NotOnWifi : SyncStatus()
|
||||||
|
object NotOnLan : SyncStatus()
|
||||||
|
object NoNetwork : SyncStatus()
|
||||||
|
data class Ready(val pending: Int) : SyncStatus()
|
||||||
|
object Synced : SyncStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncSettings(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "sync_settings"
|
||||||
|
private const val KEY_SYNC_MODE = "sync_mode"
|
||||||
|
private const val KEY_LAN_SERVER_URL = "lan_server_url"
|
||||||
|
private const val KEY_REMOTE_SERVER_URL = "remote_server_url"
|
||||||
|
private const val KEY_LAN_SUBNET_PREFIX = "lan_subnet_prefix"
|
||||||
|
private const val KEY_API_KEY = "api_key"
|
||||||
|
|
||||||
|
const val DEFAULT_LAN_SERVER_URL = "http://10.0.0.45:8000"
|
||||||
|
const val DEFAULT_REMOTE_SERVER_URL = "https://recordings.hallocks.xyz"
|
||||||
|
const val DEFAULT_LAN_SUBNET_PREFIX = "10.0.0." // Change this to match your network
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
var syncMode: SyncMode
|
||||||
|
get() {
|
||||||
|
val ordinal = prefs.getInt(KEY_SYNC_MODE, SyncMode.NEVER.ordinal)
|
||||||
|
return SyncMode.entries.getOrElse(ordinal) { SyncMode.NEVER }
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putInt(KEY_SYNC_MODE, value.ordinal).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lanServerUrl: String
|
||||||
|
get() = prefs.getString(KEY_LAN_SERVER_URL, DEFAULT_LAN_SERVER_URL) ?: DEFAULT_LAN_SERVER_URL
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putString(KEY_LAN_SERVER_URL, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteServerUrl: String
|
||||||
|
get() = prefs.getString(KEY_REMOTE_SERVER_URL, DEFAULT_REMOTE_SERVER_URL) ?: DEFAULT_REMOTE_SERVER_URL
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putString(KEY_REMOTE_SERVER_URL, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subnet prefix used to detect LAN (e.g., "192.168.1." or "10.0.0.").
|
||||||
|
* Your phone must be on this subnet for LAN mode to work.
|
||||||
|
*/
|
||||||
|
var lanSubnetPrefix: String
|
||||||
|
get() = prefs.getString(KEY_LAN_SUBNET_PREFIX, DEFAULT_LAN_SUBNET_PREFIX) ?: DEFAULT_LAN_SUBNET_PREFIX
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putString(KEY_LAN_SUBNET_PREFIX, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key for upload authentication.
|
||||||
|
*/
|
||||||
|
var apiKey: String
|
||||||
|
get() = prefs.getString(KEY_API_KEY, "") ?: ""
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putString(KEY_API_KEY, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate server URL based on whether we're on LAN.
|
||||||
|
*/
|
||||||
|
fun getServerUrl(isOnLan: Boolean): String {
|
||||||
|
return if (isOnLan) lanServerUrl else remoteServerUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.example.phonerecorder.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
class SyncTracker(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "sync_tracker"
|
||||||
|
private const val KEY_SYNCED_FILES = "synced_files"
|
||||||
|
private const val SEPARATOR = "|"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of filenames that have been successfully synced.
|
||||||
|
*/
|
||||||
|
fun getSyncedFiles(): Set<String> {
|
||||||
|
val stored = prefs.getString(KEY_SYNCED_FILES, "") ?: ""
|
||||||
|
return if (stored.isEmpty()) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
stored.split(SEPARATOR).toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a file as successfully synced.
|
||||||
|
*/
|
||||||
|
fun markSynced(filename: String) {
|
||||||
|
val current = getSyncedFiles().toMutableSet()
|
||||||
|
current.add(filename)
|
||||||
|
saveSyncedFiles(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks multiple files as successfully synced.
|
||||||
|
*/
|
||||||
|
fun markSynced(filenames: Collection<String>) {
|
||||||
|
val current = getSyncedFiles().toMutableSet()
|
||||||
|
current.addAll(filenames)
|
||||||
|
saveSyncedFiles(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific file has been synced.
|
||||||
|
*/
|
||||||
|
fun isSynced(filename: String): Boolean {
|
||||||
|
return getSyncedFiles().contains(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the count of files pending sync.
|
||||||
|
*/
|
||||||
|
fun getPendingCount(allFiles: List<String>): Int {
|
||||||
|
val synced = getSyncedFiles()
|
||||||
|
return allFiles.count { !synced.contains(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the sync history for a specific file.
|
||||||
|
*/
|
||||||
|
fun clearSyncStatus(filename: String) {
|
||||||
|
val current = getSyncedFiles().toMutableSet()
|
||||||
|
current.remove(filename)
|
||||||
|
saveSyncedFiles(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all sync history.
|
||||||
|
*/
|
||||||
|
fun clearAll() {
|
||||||
|
prefs.edit().remove(KEY_SYNCED_FILES).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSyncedFiles(files: Set<String>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_SYNCED_FILES, files.joinToString(SEPARATOR))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.example.phonerecorder.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.example.phonerecorder.StorageHelper
|
||||||
|
import com.example.phonerecorder.api.RecordingsApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class SyncWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncWorker"
|
||||||
|
const val WORK_NAME_PERIODIC = "sync_worker_periodic"
|
||||||
|
const val WORK_NAME_IMMEDIATE = "sync_worker_immediate"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncSettings = SyncSettings(applicationContext)
|
||||||
|
private val networkHelper = NetworkHelper(applicationContext)
|
||||||
|
private val syncTracker = SyncTracker(applicationContext)
|
||||||
|
private val storageHelper = StorageHelper(applicationContext)
|
||||||
|
private val recordingsApi = RecordingsApi()
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
|
Log.i(TAG, "Starting sync work")
|
||||||
|
|
||||||
|
// Check if sync is enabled and conditions are met
|
||||||
|
val syncMode = syncSettings.syncMode
|
||||||
|
if (syncMode == SyncMode.NEVER) {
|
||||||
|
Log.i(TAG, "Sync disabled")
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!networkHelper.shouldSync(syncMode)) {
|
||||||
|
Log.i(TAG, "Network conditions not met for sync mode: $syncMode")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine server URL based on sync mode
|
||||||
|
// LAN_ONLY mode uses LAN URL, other modes use remote URL
|
||||||
|
val serverUrl = if (syncMode == SyncMode.LAN_ONLY) {
|
||||||
|
syncSettings.lanServerUrl
|
||||||
|
} else {
|
||||||
|
syncSettings.remoteServerUrl
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Using server: $serverUrl (mode: $syncMode)")
|
||||||
|
|
||||||
|
// Check server health
|
||||||
|
if (!recordingsApi.checkHealth(serverUrl)) {
|
||||||
|
Log.w(TAG, "Server health check failed")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unsynced files
|
||||||
|
val syncedFiles = syncTracker.getSyncedFiles()
|
||||||
|
val unsyncedFiles = storageHelper.listUnsyncedRecordings(syncedFiles)
|
||||||
|
|
||||||
|
if (unsyncedFiles.isEmpty()) {
|
||||||
|
Log.i(TAG, "No files to sync")
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Found ${unsyncedFiles.size} files to sync")
|
||||||
|
|
||||||
|
var successCount = 0
|
||||||
|
var failCount = 0
|
||||||
|
|
||||||
|
for (file in unsyncedFiles) {
|
||||||
|
// Re-check network conditions before each upload
|
||||||
|
if (!networkHelper.shouldSync(syncMode)) {
|
||||||
|
Log.w(TAG, "Network conditions changed during sync")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = recordingsApi.uploadRecording(serverUrl, file, syncSettings.apiKey)
|
||||||
|
if (result.success && result.verified) {
|
||||||
|
// Only mark as synced if server verified the hash
|
||||||
|
syncTracker.markSynced(file.name)
|
||||||
|
successCount++
|
||||||
|
Log.i(TAG, "Synced and verified: ${file.name}")
|
||||||
|
} else if (result.success && !result.verified) {
|
||||||
|
// Upload succeeded but no verification - don't mark as synced, retry later
|
||||||
|
Log.w(TAG, "Upload succeeded but unverified, will retry: ${file.name}")
|
||||||
|
failCount++
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
Log.w(TAG, "Failed to sync ${file.name}: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Sync completed: $successCount succeeded, $failCount failed")
|
||||||
|
|
||||||
|
if (failCount > 0 && successCount == 0) {
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- Microphone icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,28c-5.5,0 -10,4.5 -10,10v20c0,5.5 4.5,10 10,10s10,-4.5 10,-10v-20c0,-5.5 -4.5,-10 -10,-10z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M74,58v-8c0,-11 -9,-20 -20,-20s-20,9 -20,20v8h6v-8c0,-7.7 6.3,-14 14,-14s14,6.3 14,14v8h6z"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M51,72v8h6v-8c9.4,-1.2 16.7,-9.2 16.7,-18.7h-6c0,7.5 -6.1,13.7 -13.7,13.7s-13.7,-6.1 -13.7,-13.7h-6c0,9.5 7.3,17.5 16.7,18.7z"/>
|
||||||
|
</vector>
|
||||||
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal file
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/recording_stopped" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/recording_active" />
|
||||||
|
</shape>
|
||||||
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal file
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="24dp"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<!-- Status Indicator -->
|
||||||
|
<View
|
||||||
|
android:id="@+id/statusIndicator"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:background="@drawable/status_circle"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:text="@string/status_stopped"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/statusIndicator"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/statusIndicator"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/statusIndicator" />
|
||||||
|
|
||||||
|
<!-- Elapsed Time -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/elapsedTimeText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="00:00:00"
|
||||||
|
android:textSize="48sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
|
||||||
|
|
||||||
|
<!-- Current File -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/currentFileText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:ellipsize="middle"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Current file: recording_2024-01-15_09-30-00.ogg"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/elapsedTimeText" />
|
||||||
|
|
||||||
|
<!-- Storage Location -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/storageLocationText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Storage: SD Card"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/currentFileText" />
|
||||||
|
|
||||||
|
<!-- Storage Info -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/storageInfoText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Used: 2.5 GB / Available: 28.3 GB"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/storageLocationText" />
|
||||||
|
|
||||||
|
<!-- Battery Info -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/batteryInfoText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Battery: 85%"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/storageInfoText" />
|
||||||
|
|
||||||
|
<!-- Start/Stop Button -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/toggleButton"
|
||||||
|
android:layout_width="200dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:text="@string/start_recording"
|
||||||
|
android:textSize="18sp"
|
||||||
|
app:cornerRadius="32dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/autoStartSwitch"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/batteryInfoText"
|
||||||
|
app:layout_constraintVertical_bias="0.5" />
|
||||||
|
|
||||||
|
<!-- Auto-start Switch -->
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/autoStartSwitch"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/auto_start_disabled"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/toggleButton" />
|
||||||
|
|
||||||
|
<!-- Sync Settings Section -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/syncSettingsLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:text="@string/sync_settings_label"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/autoStartSwitch" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/syncModeSpinner"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/syncSettingsLabel" />
|
||||||
|
|
||||||
|
<!-- Network Info -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/networkInfoText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Network: WiFi (192.168.1.42)"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/configureLanButton"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/configureLanButton" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/configureLanButton"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/configure_lan"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner" />
|
||||||
|
|
||||||
|
<!-- Key Status -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/keyStatusText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Key: Not configured"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/importKeyButton"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/configureLanButton"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/importKeyButton" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/importKeyButton"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/import_key_button"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/configureLanButton" />
|
||||||
|
|
||||||
|
<!-- Sync Status -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/syncStatusText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="Sync: 3 pending"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/importKeyButton" />
|
||||||
|
|
||||||
|
<!-- Battery Optimization Hint -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/batteryOptimizationHint"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/battery_optimization_hint"
|
||||||
|
android:textColor="@color/warning"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/syncStatusText" />
|
||||||
|
|
||||||
|
<!-- Version Info -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/versionText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:textSize="11sp"
|
||||||
|
tools:text="v1.1.0 (build 2026-05-11)"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/batteryOptimizationHint"
|
||||||
|
app:layout_constraintVertical_bias="1.0" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
9
clio-android/app/src/main/res/values/colors.xml
Normal file
9
clio-android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="primary">#D32F2F</color>
|
||||||
|
<color name="primary_dark">#B71C1C</color>
|
||||||
|
<color name="accent">#FF5252</color>
|
||||||
|
<color name="recording_active">#4CAF50</color>
|
||||||
|
<color name="recording_stopped">#9E9E9E</color>
|
||||||
|
<color name="warning">#FF9800</color>
|
||||||
|
</resources>
|
||||||
58
clio-android/app/src/main/res/values/strings.xml
Normal file
58
clio-android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Phone Recorder</string>
|
||||||
|
<string name="status_recording">Recording</string>
|
||||||
|
<string name="status_stopped">Stopped</string>
|
||||||
|
<string name="start_recording">Start Recording</string>
|
||||||
|
<string name="stop_recording">Stop Recording</string>
|
||||||
|
<string name="storage_info">Storage: %1$s used / %2$s available</string>
|
||||||
|
<string name="battery_info">Battery: %1$d%%</string>
|
||||||
|
<string name="current_file">Current file: %1$s</string>
|
||||||
|
<string name="no_file">No file</string>
|
||||||
|
<string name="notification_channel_name">Recording</string>
|
||||||
|
<string name="notification_channel_description">Shows when recording is active</string>
|
||||||
|
<string name="notification_title">Recording in progress</string>
|
||||||
|
<string name="notification_text">Elapsed: %1$s</string>
|
||||||
|
<string name="storage_warning">Low storage! Recording stopped.</string>
|
||||||
|
<string name="storage_location_sd">Storage: SD Card</string>
|
||||||
|
<string name="storage_location_internal">Storage: Internal (SD card not available)</string>
|
||||||
|
<string name="auto_start_enabled">Auto-start on boot: Enabled</string>
|
||||||
|
<string name="auto_start_disabled">Auto-start on boot: Disabled</string>
|
||||||
|
<string name="battery_optimization_title">Battery Optimization</string>
|
||||||
|
<string name="battery_optimization_message">For reliable background recording, disable battery optimization for this app.</string>
|
||||||
|
<string name="battery_optimization_button">Open Settings</string>
|
||||||
|
<string name="battery_optimization_hint">Tip: Disable battery optimization for best reliability</string>
|
||||||
|
<string name="permission_required">Microphone permission is required for recording</string>
|
||||||
|
|
||||||
|
<!-- Sync settings -->
|
||||||
|
<string name="sync_settings_label">Sync Mode</string>
|
||||||
|
<string name="sync_mode_never">Never</string>
|
||||||
|
<string name="sync_mode_lan_only">LAN Only</string>
|
||||||
|
<string name="sync_mode_wifi_only">WiFi Only</string>
|
||||||
|
<string name="sync_mode_any_network">Any Network</string>
|
||||||
|
<string name="sync_status_pending">Sync: %1$d pending</string>
|
||||||
|
<string name="sync_status_disabled">Sync: Disabled</string>
|
||||||
|
<string name="sync_status_no_key">Sync: No server key configured</string>
|
||||||
|
<string name="sync_status_not_on_wifi">Sync: Not on WiFi</string>
|
||||||
|
<string name="sync_status_not_on_lan">Sync: Not on configured LAN</string>
|
||||||
|
<string name="sync_status_no_network">Sync: No network connection</string>
|
||||||
|
<string name="sync_status_synced">Sync: All synced</string>
|
||||||
|
<string name="import_key_button">Import Server Key</string>
|
||||||
|
<string name="import_key_title">Import Server Public Key</string>
|
||||||
|
<string name="import_key_hint">Paste Base64 public key</string>
|
||||||
|
<string name="import_key_success">Server key imported successfully</string>
|
||||||
|
<string name="import_key_error">Invalid key format</string>
|
||||||
|
<string name="key_status_configured">Key: Configured</string>
|
||||||
|
<string name="key_status_not_configured">Key: Not configured</string>
|
||||||
|
|
||||||
|
<!-- Network info -->
|
||||||
|
<string name="network_not_connected">Network: Not connected</string>
|
||||||
|
<string name="network_wifi">Network: WiFi (%1$s)</string>
|
||||||
|
<string name="network_mobile">Network: Mobile data</string>
|
||||||
|
<string name="lan_subnet_label">LAN Subnet</string>
|
||||||
|
<string name="lan_subnet_hint">e.g., 192.168.1. or 10.0.0.</string>
|
||||||
|
<string name="configure_lan">Configure</string>
|
||||||
|
<string name="lan_config_title">LAN Subnet Configuration</string>
|
||||||
|
<string name="lan_config_message">Enter the IP prefix for your home network.\n\nYour current IP: %1$s\n\nCommon examples:\n• 192.168.1.\n• 192.168.0.\n• 10.0.0.</string>
|
||||||
|
<string name="lan_config_saved">LAN subnet saved: %1$s</string>
|
||||||
|
</resources>
|
||||||
11
clio-android/app/src/main/res/values/themes.xml
Normal file
11
clio-android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.PhoneRecorder" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/primary_dark</item>
|
||||||
|
<item name="colorOnPrimary">@android:color/white</item>
|
||||||
|
<item name="colorSecondary">@color/accent</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/accent</item>
|
||||||
|
<item name="colorOnSecondary">@android:color/white</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-files-path
|
||||||
|
name="recordings"
|
||||||
|
path="recordings/" />
|
||||||
|
</paths>
|
||||||
4
clio-android/build.gradle.kts
Normal file
4
clio-android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.2.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||||
|
}
|
||||||
136
clio-android/description.md
Normal file
136
clio-android/description.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# Phone Recorder - Architecture Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Android app that records audio continuously, encrypts recordings immediately (never stored unencrypted), and syncs them to a server over LAN or internet.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Phone (has: server public key) Server (has: private key)
|
||||||
|
───────────────────────────── ────────────────────────
|
||||||
|
Record audio → temp.ogg
|
||||||
|
Encrypt with public key →
|
||||||
|
recording_*.ogg.enc
|
||||||
|
│
|
||||||
|
└──── HTTP POST ────────→ Receive encrypted file
|
||||||
|
Decrypt with private key
|
||||||
|
Store as .ogg in recordings/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sealed box encryption**: X25519 key exchange + XSalsa20-Poly1305
|
||||||
|
- **Forward secrecy**: Each encryption uses ephemeral keys
|
||||||
|
- **Phone never stores unencrypted**: Temp file deleted immediately after encryption
|
||||||
|
- **If no public key configured**: Recordings are deleted (security-first design)
|
||||||
|
|
||||||
|
## File Format (SREC)
|
||||||
|
|
||||||
|
Encrypted files use a chunked format for streaming large files:
|
||||||
|
|
||||||
|
```
|
||||||
|
[4 bytes: "SREC" magic]
|
||||||
|
[1 byte: version (1)]
|
||||||
|
[4 bytes: chunk count, big-endian]
|
||||||
|
For each chunk (64KB max):
|
||||||
|
[4 bytes: encrypted length, big-endian]
|
||||||
|
[N bytes: sealed box ciphertext]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android App Structure
|
||||||
|
|
||||||
|
### Main Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `MainActivity.kt` | UI, settings, sync controls |
|
||||||
|
| `RecordingService.kt` | Foreground service for recording, encryption on stop |
|
||||||
|
| `StorageHelper.kt` | File paths, temp vs recordings directories |
|
||||||
|
|
||||||
|
### Crypto Package (`crypto/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `CryptoHelper.kt` | SREC format encryption using lazysodium |
|
||||||
|
| `KeyManager.kt` | Store/retrieve server public key (SharedPreferences) |
|
||||||
|
|
||||||
|
### Sync Package (`sync/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `SyncSettings.kt` | Sync mode enum, server URLs, LAN subnet prefix |
|
||||||
|
| `NetworkHelper.kt` | Detect WiFi, LAN (by IP prefix), network availability |
|
||||||
|
| `SyncTracker.kt` | Track which files synced successfully (SharedPreferences) |
|
||||||
|
| `SyncWorker.kt` | WorkManager background job for uploads |
|
||||||
|
| `SyncManager.kt` | Schedule periodic (15min) and immediate syncs |
|
||||||
|
|
||||||
|
### API Package (`api/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `RecordingsApi.kt` | OkHttp client, upload with SHA-256 verification |
|
||||||
|
|
||||||
|
## Sync Modes
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| Never | No sync, files stay on phone |
|
||||||
|
| LAN Only | Sync only when IP matches configured subnet (e.g., `10.0.0.`) |
|
||||||
|
| WiFi Only | Sync on any WiFi network |
|
||||||
|
| Any Network | Sync on WiFi or mobile data |
|
||||||
|
|
||||||
|
## Server Structure (Python/FastAPI)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `main.py` | FastAPI endpoints |
|
||||||
|
| `crypto.py` | PyNaCl decryption, keypair management |
|
||||||
|
| `config.py` | Paths, ports |
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Returns `{"status": "ok"}` |
|
||||||
|
| `/api/v1/public-key` | GET | Returns Base64 public key |
|
||||||
|
| `/api/v1/recordings/upload` | POST | Upload encrypted file, returns hash for verification |
|
||||||
|
| `/api/v1/recordings` | GET | List decrypted recordings |
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. **Recording starts**: `RecordingService` writes to `temp/recording_TIMESTAMP.ogg`
|
||||||
|
2. **Recording stops**:
|
||||||
|
- `CryptoHelper.encryptFile()` reads temp, writes `recordings/recording_TIMESTAMP.ogg.enc`
|
||||||
|
- Temp file deleted
|
||||||
|
- `SyncManager.scheduleImmediateSync()` triggered
|
||||||
|
3. **Sync runs**:
|
||||||
|
- `SyncWorker` checks network conditions match `SyncMode`
|
||||||
|
- Computes SHA-256 of encrypted file
|
||||||
|
- Uploads to server
|
||||||
|
- Server decrypts, returns hash
|
||||||
|
- If hashes match, marks file as synced
|
||||||
|
4. **Server storage**: `recordings/YYYY/MM/DD/recording_TIMESTAMP.ogg`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
### Android
|
||||||
|
- `lazysodium-android` - libsodium bindings
|
||||||
|
- `androidx.work` - Background job scheduling
|
||||||
|
- `okhttp3` - HTTP client
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- `fastapi` + `uvicorn` - Web framework
|
||||||
|
- `pynacl` - libsodium bindings
|
||||||
|
- `aiofiles` - Async file I/O
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Android (in-app settings)
|
||||||
|
- Sync mode (Never/LAN/WiFi/Any)
|
||||||
|
- LAN subnet prefix (default: `10.0.0.`)
|
||||||
|
- Server URLs (LAN and remote)
|
||||||
|
- Server public key (Base64)
|
||||||
|
|
||||||
|
### Server (config.py or env vars)
|
||||||
|
- `HOST` - Bind address (default: 0.0.0.0)
|
||||||
|
- `PORT` - Listen port (default: 8000)
|
||||||
|
- `BASE_DIR` - Base directory for keys and recordings
|
||||||
61
clio-android/flake.lock
generated
Normal file
61
clio-android/flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776877367,
|
||||||
|
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
52
clio-android/flake.nix
Normal file
52
clio-android/flake.nix
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
description = "Phone Recorder Android app development environment";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
config = {
|
||||||
|
allowUnfree = true;
|
||||||
|
android_sdk.accept_license = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
androidComposition = pkgs.androidenv.composeAndroidPackages {
|
||||||
|
buildToolsVersions = [ "34.0.0" ];
|
||||||
|
platformVersions = [ "34" "33" ];
|
||||||
|
abiVersions = [ "arm64-v8a" "x86_64" ];
|
||||||
|
includeEmulator = false;
|
||||||
|
includeSystemImages = false;
|
||||||
|
includeSources = false;
|
||||||
|
includeNDK = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
androidSdk = androidComposition.androidsdk;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
android-studio
|
||||||
|
android-tools
|
||||||
|
jdk17
|
||||||
|
androidSdk
|
||||||
|
];
|
||||||
|
|
||||||
|
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
|
||||||
|
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "Phone Recorder development environment"
|
||||||
|
echo "Run 'android-studio' to launch Android Studio"
|
||||||
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
4
clio-android/gradle.properties
Normal file
4
clio-android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
251
clio-android/gradlew
vendored
Executable file
251
clio-android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
clio-android/gradlew.bat
vendored
Normal file
94
clio-android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
14
clio-android/notes.txt
Normal file
14
clio-android/notes.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
2. Connect your phone (USB debugging enabled) and verify connection:
|
||||||
|
./android_studio_install_path/platform-tools/adb devices
|
||||||
|
|
||||||
|
3. Install the APK:
|
||||||
|
./android_studio_install_path/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
Or you can combine build + install in one step:
|
||||||
|
./gradlew installDebug
|
||||||
|
|
||||||
|
This requires your phone to be connected with USB debugging enabled in Developer Options.
|
||||||
|
|
||||||
|
❯ can you add it to the flake?
|
||||||
18
clio-android/settings.gradle.kts
Normal file
18
clio-android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "PhoneRecorder"
|
||||||
|
include(":app")
|
||||||
117
clio-android/todo.md
Normal file
117
clio-android/todo.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Phone Recorder - Future Ideas / TODO
|
||||||
|
|
||||||
|
## Questions to Address
|
||||||
|
|
||||||
|
### Key Rotation
|
||||||
|
- What happens when server keypair changes?
|
||||||
|
- Current behavior: Old encrypted files can't be decrypted with new key
|
||||||
|
- Options:
|
||||||
|
1. Keep old private key archived for decrypting old files
|
||||||
|
2. Sync all files before rotating keys
|
||||||
|
3. Store key version/ID in encrypted file header
|
||||||
|
|
||||||
|
### Manual Decryption
|
||||||
|
- Document steps to decrypt files copied via USB from phone
|
||||||
|
- Need: Python script using server's private key
|
||||||
|
- Consider: CLI tool in server/ directory
|
||||||
|
|
||||||
|
### No-Key Behavior
|
||||||
|
- Current: Deletes recordings if no public key configured (security-first)
|
||||||
|
- Alternatives to consider:
|
||||||
|
1. Store unencrypted with warning (less secure)
|
||||||
|
2. Queue recordings until key configured (could fill storage)
|
||||||
|
3. Current behavior is probably correct for security model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Possible Features
|
||||||
|
|
||||||
|
### High Priority (if needed)
|
||||||
|
- [ ] QR code for key import (avoid email/typing)
|
||||||
|
- [ ] Server URL configuration in app UI (currently requires code change for non-defaults)
|
||||||
|
- [ ] Manual sync trigger button
|
||||||
|
- [ ] View sync history/status in app
|
||||||
|
- [ ] **Display last successful sync time on phone** (e.g., "Last sync: 2 hours ago")
|
||||||
|
- [ ] **Server status page** - HTML dashboard showing:
|
||||||
|
- When phone last synced
|
||||||
|
- Total recordings count
|
||||||
|
- Recent uploads
|
||||||
|
- Disk space usage
|
||||||
|
- Could be at `/status` or `/dashboard`
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- [ ] Configurable recording quality (bitrate, sample rate)
|
||||||
|
- [ ] Configurable segment duration (currently 2 hours)
|
||||||
|
- [ ] Delete synced files automatically (with configurable retention)
|
||||||
|
- [ ] Server: Authentication for upload endpoint
|
||||||
|
- [ ] Server: HTTPS with Let's Encrypt
|
||||||
|
|
||||||
|
### Audio Analysis & Intelligence
|
||||||
|
- [ ] **Speech detection bar** - Visual indicator showing when speech is detected vs silence
|
||||||
|
- Display as additional bar in recording detail view alongside waveform
|
||||||
|
- Data comes from batch processing script
|
||||||
|
- [ ] **Sleeping detection** - Detect periods of sleep/silence for overnight recordings
|
||||||
|
- Could help skip to interesting parts
|
||||||
|
- Visual indicator in timeline
|
||||||
|
- [ ] **Auto voice recognition / Speaker identification**
|
||||||
|
- Identify who is talking (requires training on voice samples)
|
||||||
|
- Label segments by speaker
|
||||||
|
- Not text-based (transcripts are unreliable)
|
||||||
|
- [ ] **Auto highlight generation**
|
||||||
|
- Audio-based detection (not text search since transcripts are bad)
|
||||||
|
- Example: detect phrase patterns like "Ok, note taken" acoustically
|
||||||
|
- Auto-create highlights with tags like "note_taken"
|
||||||
|
- Use audio fingerprinting or keyword spotting models
|
||||||
|
|
||||||
|
### Viewer UX
|
||||||
|
- [ ] **Background audio playback** - Keep playing current audio while browsing other recordings
|
||||||
|
- Mini player that persists across views
|
||||||
|
- Allows reviewing recordings list while listening
|
||||||
|
- [ ] **Highlight duration** - Currently highlights mark only a point; consider adding end time/duration
|
||||||
|
- [ ] **Auto-scroll timeline to recordings** - If recordings are only at end of day, auto-scroll so user doesn't have to scroll down to see them
|
||||||
|
|
||||||
|
### Low Priority / Nice to Have
|
||||||
|
- [ ] Multiple server destinations
|
||||||
|
- [ ] Compression before encryption
|
||||||
|
- [ ] Resume interrupted uploads
|
||||||
|
- [ ] Server: Web UI to browse/play recordings
|
||||||
|
- [ ] Server: Transcription integration
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- [ ] Systemd service file for server
|
||||||
|
- [ ] Docker container for server
|
||||||
|
- [ ] NixOS module for server
|
||||||
|
- [ ] Backup strategy for server private key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **No streaming encryption**: Entire temp file must complete before encryption
|
||||||
|
2. **No partial sync**: If upload fails, whole file retries
|
||||||
|
3. **Single server**: Can only sync to one destination at a time
|
||||||
|
4. **No authentication**: Anyone who can reach server can upload (relies on encryption for confidentiality)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
### Unit Tests (server/)
|
||||||
|
```bash
|
||||||
|
python test_crypto.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests (server/)
|
||||||
|
```bash
|
||||||
|
# Start server first
|
||||||
|
python main.py &
|
||||||
|
python test_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
- [ ] Record short clip, verify .ogg.enc created
|
||||||
|
- [ ] Verify temp file deleted after encryption
|
||||||
|
- [ ] Check sync triggers after recording stops
|
||||||
|
- [ ] Verify hash verification (corrupt a file, see it retry)
|
||||||
|
- [ ] Test LAN detection with different subnets
|
||||||
|
- [ ] Test sync mode transitions
|
||||||
10
clio-api/.gitignore
vendored
Normal file
10
clio-api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
1
clio-api/.python-version
Normal file
1
clio-api/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.13
|
||||||
46
clio-api/nixos/Caddyfile
Normal file
46
clio-api/nixos/Caddyfile
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Caddyfile snippet for phone-recorder
|
||||||
|
# Add this to your global /etc/caddy/Caddyfile
|
||||||
|
#
|
||||||
|
# Caddy will automatically obtain and renew HTTPS certificates
|
||||||
|
|
||||||
|
recordings.hallocks.xyz {
|
||||||
|
# Proxy everything to FastAPI
|
||||||
|
reverse_proxy localhost:8000
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging for fail2ban
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/recordings.log {
|
||||||
|
roll_size 10mb
|
||||||
|
roll_keep 5
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Other services (for reference when setting them back up):
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# todo.hallocks.xyz {
|
||||||
|
# reverse_proxy localhost:3456 # Vikunja
|
||||||
|
# log {
|
||||||
|
# output file /var/log/caddy/todo.log
|
||||||
|
# format json
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# passwords.hallocks.xyz {
|
||||||
|
# reverse_proxy localhost:8080 # Vaultwarden
|
||||||
|
# log {
|
||||||
|
# output file /var/log/caddy/passwords.log
|
||||||
|
# format json
|
||||||
|
# }
|
||||||
|
# }
|
||||||
182
clio-api/nixos/phone-recorder.nix
Normal file
182
clio-api/nixos/phone-recorder.nix
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# NixOS module for phone-recorder server
|
||||||
|
# Add to your configuration.nix imports or use as a standalone module
|
||||||
|
#
|
||||||
|
# Usage in configuration.nix:
|
||||||
|
# imports = [ ./phone-recorder.nix ];
|
||||||
|
# services.phone-recorder.enable = true;
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.phone-recorder;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.phone-recorder = {
|
||||||
|
enable = lib.mkEnableOption "Phone Recorder server";
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8000;
|
||||||
|
description = "Port for the API server";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/phone-recorder";
|
||||||
|
description = "Directory for recordings and database";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "phone-recorder";
|
||||||
|
description = "User to run the service as";
|
||||||
|
};
|
||||||
|
|
||||||
|
domain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "recordings.hallocks.xyz";
|
||||||
|
description = "Domain for HTTPS access";
|
||||||
|
};
|
||||||
|
|
||||||
|
enableCaddy = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable Caddy reverse proxy with automatic HTTPS";
|
||||||
|
};
|
||||||
|
|
||||||
|
enableFail2ban = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable fail2ban protection";
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = /home/thallock/phone-recorder;
|
||||||
|
description = "Path to the server source code";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Create user and group
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.user;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.user} = {};
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
systemd.services.phone-recorder = {
|
||||||
|
description = "Phone Recorder API Server";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
HOST = "127.0.0.1"; # Only listen locally, Caddy handles external
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
BASE_DIR = toString cfg.sourceDir;
|
||||||
|
RECORDINGS_DIR = "${cfg.dataDir}/recordings";
|
||||||
|
DATABASE_PATH = "${cfg.dataDir}/recordings.db";
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.user;
|
||||||
|
WorkingDirectory = cfg.sourceDir;
|
||||||
|
ExecStart = "${pkgs.uv}/bin/uv run python main.py";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
PrivateTmp = true;
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
};
|
||||||
|
|
||||||
|
preStart = ''
|
||||||
|
mkdir -p ${cfg.dataDir}/recordings
|
||||||
|
mkdir -p ${cfg.dataDir}/keys
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Caddy reverse proxy
|
||||||
|
services.caddy = lib.mkIf cfg.enableCaddy {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts = {
|
||||||
|
"${cfg.domain}" = {
|
||||||
|
extraConfig = ''
|
||||||
|
# API endpoints
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
handle /health {
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static frontend (if you have one)
|
||||||
|
handle {
|
||||||
|
root * /var/www/recordings-viewer
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limiting for uploads
|
||||||
|
@upload path /api/v1/recordings/upload
|
||||||
|
handle @upload {
|
||||||
|
rate_limit {remote.ip} 10r/m
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/recordings.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Open firewall
|
||||||
|
networking.firewall.allowedTCPPorts = [ 80 443 ]
|
||||||
|
++ lib.optional (!cfg.enableCaddy) cfg.port;
|
||||||
|
|
||||||
|
# Fail2ban
|
||||||
|
services.fail2ban = lib.mkIf cfg.enableFail2ban {
|
||||||
|
enable = true;
|
||||||
|
maxretry = 5;
|
||||||
|
bantime = "1h";
|
||||||
|
|
||||||
|
jails = {
|
||||||
|
caddy-auth = lib.mkIf cfg.enableCaddy ''
|
||||||
|
enabled = true
|
||||||
|
filter = caddy-auth
|
||||||
|
logpath = /var/log/caddy/recordings.log
|
||||||
|
maxretry = 5
|
||||||
|
bantime = 3600
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Fail2ban filter for Caddy
|
||||||
|
environment.etc."fail2ban/filter.d/caddy-auth.conf" = lib.mkIf (cfg.enableFail2ban && cfg.enableCaddy) {
|
||||||
|
text = ''
|
||||||
|
[Definition]
|
||||||
|
failregex = ^.*"remote_ip":"<HOST>".*"status":40[13].*$
|
||||||
|
ignoreregex =
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
33
clio-api/nixos/phone-recorder.service
Normal file
33
clio-api/nixos/phone-recorder.service
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Systemd service file for phone-recorder
|
||||||
|
# Copy to /etc/systemd/system/phone-recorder.service
|
||||||
|
# Then: systemctl daemon-reload && systemctl enable --now phone-recorder
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Phone Recorder API Server
|
||||||
|
After=network.target local-fs.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=thallock
|
||||||
|
Group=thallock
|
||||||
|
WorkingDirectory=/work/hosting/recordings
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
Environment="HOST=0.0.0.0"
|
||||||
|
Environment="PORT=8000"
|
||||||
|
Environment="BASE_DIR=/work/hosting/recordings"
|
||||||
|
Environment="RECORDINGS_DIR=/media/external/Hosting/Recordings"
|
||||||
|
Environment="DATABASE_PATH=/work/hosting/recordings/data/recordings.db"
|
||||||
|
Environment="KEYS_DIR=/work/hosting/recordings/data/keys"
|
||||||
|
|
||||||
|
ExecStart=/home/thallock/.local/bin/uv run python main.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
126
clio-infra/ARCHITECTURE.md
Normal file
126
clio-infra/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Clio - Architecture Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Multi-device audio recording system with end-to-end encryption. Records continuously on phone and portable devices, encrypts immediately, syncs to central server, transcribes with GPU, and provides a web interface for search and highlights.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Repository | Purpose | Deployment Target |
|
||||||
|
|------------|---------|-------------------|
|
||||||
|
| clio-api | FastAPI server, recordings DB, highlights | Server (10.0.0.45) |
|
||||||
|
| clio-android | Android recording app | Phone (manual APK) |
|
||||||
|
| clio-ui | React viewer frontend | Server (10.0.0.45) |
|
||||||
|
| clio-ai | Whisper transcription, wake word detection | Desktop (10.0.0.130) |
|
||||||
|
| clio-infra | Deployment configs, docs, NixOS modules | All |
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Phone (has: server public key) Server (has: private key)
|
||||||
|
───────────────────────────── ────────────────────────
|
||||||
|
Record audio → temp.ogg
|
||||||
|
Encrypt with public key →
|
||||||
|
recording_*.ogg.enc
|
||||||
|
│
|
||||||
|
└──── HTTP POST ────────→ Receive encrypted file
|
||||||
|
Decrypt with private key
|
||||||
|
Store as .ogg in recordings/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sealed box encryption**: X25519 key exchange + XSalsa20-Poly1305
|
||||||
|
- **Forward secrecy**: Each encryption uses ephemeral keys
|
||||||
|
- **Phone never stores unencrypted**: Temp file deleted immediately after encryption
|
||||||
|
- **If no public key configured**: Recordings are deleted (security-first design)
|
||||||
|
|
||||||
|
## File Format (SREC)
|
||||||
|
|
||||||
|
Encrypted files use a chunked format for streaming large files:
|
||||||
|
|
||||||
|
```
|
||||||
|
[4 bytes: "SREC" magic]
|
||||||
|
[1 byte: version (1)]
|
||||||
|
[4 bytes: chunk count, big-endian]
|
||||||
|
For each chunk (64KB max):
|
||||||
|
[4 bytes: encrypted length, big-endian]
|
||||||
|
[N bytes: sealed box ciphertext]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Recording
|
||||||
|
Phone/Pi → temp.ogg → encrypt → recording_*.ogg.enc
|
||||||
|
|
||||||
|
2. Sync
|
||||||
|
Phone/Pi → HTTP POST → Server decrypts → recordings/YYYY/MM/DD/*.ogg
|
||||||
|
|
||||||
|
3. Transcription (async, on desktop)
|
||||||
|
Desktop pulls recordings → Whisper → JSON segments → POST to server
|
||||||
|
|
||||||
|
4. Viewing
|
||||||
|
Browser → clio-ui → clio-api → recordings + transcripts + highlights
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints (clio-api)
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
| `/api/v1/public-key` | GET | Server public key (Base64) |
|
||||||
|
| `/api/v1/recordings/upload` | POST | Upload encrypted file |
|
||||||
|
| `/api/v1/recordings` | GET | List recordings with transcripts |
|
||||||
|
| `/api/v1/recordings/{id}` | GET | Recording details |
|
||||||
|
| `/api/v1/recordings/{id}/audio` | GET | Stream audio file |
|
||||||
|
| `/api/v1/import/transcription` | POST | Import transcription JSON |
|
||||||
|
| `/api/v1/highlights` | GET/POST | Manage highlights |
|
||||||
|
| `/api/v1/sources` | GET | List recording sources |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**recordings**: id, source_name, audio_path, duration, language, recorded_at, transcribed_at
|
||||||
|
**segments**: id, recording_id, start_time, end_time, text, confidence
|
||||||
|
**highlights**: id, recording_id, start_seconds, end_seconds, tag, note, created_at
|
||||||
|
|
||||||
|
## Sync Modes (Android)
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| Never | No sync, files stay on phone |
|
||||||
|
| LAN Only | Sync only when IP matches subnet (e.g., `10.0.0.`) |
|
||||||
|
| WiFi Only | Sync on any WiFi network |
|
||||||
|
| Any Network | Sync on WiFi or mobile data |
|
||||||
|
|
||||||
|
## Hosts & Paths
|
||||||
|
|
||||||
|
### Server (10.0.0.45)
|
||||||
|
- Code: `/work/hosting/recordings`
|
||||||
|
- Audio: `/media/external/Hosting/Recordings`
|
||||||
|
- Database: `/work/hosting/recordings/data/recordings.db`
|
||||||
|
- Frontend: `/var/www/recordings-viewer`
|
||||||
|
- Domain: `recordings.hallocks.xyz`
|
||||||
|
|
||||||
|
### Desktop (10.0.0.130)
|
||||||
|
- Transcription scripts
|
||||||
|
- Real-time wake word detection
|
||||||
|
- GPU: RTX 3090
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### clio-api (Python)
|
||||||
|
- fastapi, uvicorn
|
||||||
|
- pynacl (libsodium)
|
||||||
|
- aiofiles
|
||||||
|
- sqlite3
|
||||||
|
|
||||||
|
### clio-android (Kotlin)
|
||||||
|
- lazysodium-android
|
||||||
|
- androidx.work
|
||||||
|
- okhttp3
|
||||||
|
|
||||||
|
### clio-ai (Python)
|
||||||
|
- faster-whisper
|
||||||
|
- torch, torchaudio
|
||||||
|
- sounddevice
|
||||||
|
- webrtcvad
|
||||||
|
- scikit-learn
|
||||||
153
clio-infra/TODO.md
Normal file
153
clio-infra/TODO.md
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
# Clio - Consolidated TODO
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TARGET ARCHITECTURE │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ Phone │───────▶│ Server (10.0.0.45) │ │
|
||||||
|
│ │ (Android)│ push │ - Storage hub (recordings) │ │
|
||||||
|
│ └──────────┘ │ - clio-api (FastAPI) │ │
|
||||||
|
│ │ - clio-ui (React frontend) │ │
|
||||||
|
│ ┌──────────┐ │ - Database (highlights, transcripts) │ │
|
||||||
|
│ │ Pi │───────▶│ - Forgejo (code.hallocks.xyz) │ │
|
||||||
|
│ │(portable)│ push └──────────────────────────────────────────────┘ │
|
||||||
|
│ └──────────┘ │ │
|
||||||
|
│ │ pulls recordings │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ Desktop (10.0.0.130) │ │
|
||||||
|
│ │ - GPU: RTX 3090 │ │
|
||||||
|
│ │ - clio-ai/transcription (Whisper) │ │
|
||||||
|
│ │ - clio-ai/real_time (wake word detection) │ │
|
||||||
|
│ │ - Off at night (non-critical) │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Immediate Tasks
|
||||||
|
|
||||||
|
### Infrastructure Setup
|
||||||
|
- [x] Import Pi recorder code (systemd setup) → clio-pi/
|
||||||
|
- [ ] Migrate ~100s GB of recordings from Pi to server storage
|
||||||
|
- [ ] Pi: Add sync script to push recordings to server
|
||||||
|
- [ ] Pi: Auto-detect audio device (currently hardcoded to hw:1)
|
||||||
|
- [ ] Set up Forgejo repos for all clio-* projects
|
||||||
|
- [ ] Configure Forgejo Actions for server deployments (10.0.0.45)
|
||||||
|
- [ ] Create backup strategy for viewer, highlights, and database
|
||||||
|
|
||||||
|
### Deployment Pipeline
|
||||||
|
- [ ] clio-api: Forgejo Actions → deploy to 10.0.0.45
|
||||||
|
- [ ] clio-ui: Forgejo Actions → deploy to 10.0.0.45:/var/www/recordings-viewer
|
||||||
|
- [ ] clio-ai: Manual deploy to desktop (10.0.0.130) - not via Actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Backlog
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
- [ ] QR code for key import (avoid email/typing)
|
||||||
|
- [ ] Server URL configuration in app UI
|
||||||
|
- [ ] Manual sync trigger button
|
||||||
|
- [ ] View sync history/status in app
|
||||||
|
- [ ] Display last successful sync time on phone
|
||||||
|
- [ ] Server status page/dashboard:
|
||||||
|
- When phone/pi last synced
|
||||||
|
- Total recordings count
|
||||||
|
- Recent uploads
|
||||||
|
- Disk space usage
|
||||||
|
|
||||||
|
### Wake Word / Voice Recognition (clio-ai)
|
||||||
|
- [ ] Manage wake words in UI (currently hardcoded)
|
||||||
|
- [ ] Centralize voice recognition training data
|
||||||
|
- [ ] Real-time detection improvements (currently sucks even on desktop)
|
||||||
|
- [ ] Future: Real-time detection on phone/pi (low priority)
|
||||||
|
|
||||||
|
### Audio Analysis & Intelligence
|
||||||
|
- [ ] Speech detection bar in recording detail view
|
||||||
|
- [ ] Sleeping/silence detection for overnight recordings
|
||||||
|
- [ ] Auto voice recognition / speaker identification
|
||||||
|
- [ ] Auto highlight generation (audio-based, not text search)
|
||||||
|
|
||||||
|
### Viewer UX
|
||||||
|
- [ ] Background audio playback (mini player across views)
|
||||||
|
- [ ] Highlight duration (currently point-only, add end time)
|
||||||
|
- [ ] Auto-scroll timeline to recordings
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- [ ] Configurable recording quality (bitrate, sample rate)
|
||||||
|
- [ ] Configurable segment duration (currently 2 hours)
|
||||||
|
- [ ] Delete synced files automatically (with retention)
|
||||||
|
- [ ] Server authentication for upload endpoint
|
||||||
|
- [ ] Server HTTPS with Let's Encrypt (partially done via Caddy)
|
||||||
|
|
||||||
|
### Low Priority / Nice to Have
|
||||||
|
- [ ] Multiple server destinations
|
||||||
|
- [ ] Compression before encryption
|
||||||
|
- [ ] Resume interrupted uploads
|
||||||
|
- [ ] Docker container for server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions to Resolve
|
||||||
|
|
||||||
|
### Key Rotation
|
||||||
|
- What happens when server keypair changes?
|
||||||
|
- Options:
|
||||||
|
1. Keep old private key archived
|
||||||
|
2. Sync all files before rotating
|
||||||
|
3. Store key version in encrypted file header
|
||||||
|
|
||||||
|
### Manual Decryption
|
||||||
|
- Document steps to decrypt files copied via USB
|
||||||
|
- CLI tool needed
|
||||||
|
|
||||||
|
### Desktop Worker Architecture
|
||||||
|
- How does desktop pull recordings from server?
|
||||||
|
- How does it push transcriptions back?
|
||||||
|
- Queue system needed?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **No streaming encryption**: Entire temp file must complete before encryption
|
||||||
|
2. **No partial sync**: If upload fails, whole file retries
|
||||||
|
3. **Single server**: Can only sync to one destination at a time
|
||||||
|
4. **No authentication**: Relies on encryption for confidentiality
|
||||||
|
5. **Desktop off at night**: Real-time detection unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/work/projects/clio/
|
||||||
|
├── clio-api/ # FastAPI server (recordings, highlights, transcripts)
|
||||||
|
├── clio-android/ # Android recording app
|
||||||
|
├── clio-ui/ # React frontend (viewer)
|
||||||
|
├── clio-ai/ # ML/AI components
|
||||||
|
│ ├── real_time/ # Wake word detection, voice classifier
|
||||||
|
│ └── transcription/ # Whisper transcription scripts
|
||||||
|
├── clio-pi/ # Raspberry Pi recorder
|
||||||
|
│ ├── bin/ # Recording scripts
|
||||||
|
│ ├── systemd/ # Service units
|
||||||
|
│ └── setup.sh # Pi setup script
|
||||||
|
└── clio-infra/ # Deployment, configs, docs
|
||||||
|
├── nixos/ # NixOS modules
|
||||||
|
├── scripts/ # Deploy scripts
|
||||||
|
└── TODO.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
| Host | IP | Role |
|
||||||
|
|------|-----|------|
|
||||||
|
| Server | 10.0.0.45 | Storage, API, UI, Forgejo |
|
||||||
|
| Desktop | 10.0.0.130 | GPU transcription, real-time detection |
|
||||||
|
| Phone | (mobile) | Recording source |
|
||||||
|
| Pi | (TBD) | Portable recording source |
|
||||||
46
clio-infra/nixos/Caddyfile
Normal file
46
clio-infra/nixos/Caddyfile
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Caddyfile snippet for phone-recorder
|
||||||
|
# Add this to your global /etc/caddy/Caddyfile
|
||||||
|
#
|
||||||
|
# Caddy will automatically obtain and renew HTTPS certificates
|
||||||
|
|
||||||
|
recordings.hallocks.xyz {
|
||||||
|
# Proxy everything to FastAPI
|
||||||
|
reverse_proxy localhost:8000
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging for fail2ban
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/recordings.log {
|
||||||
|
roll_size 10mb
|
||||||
|
roll_keep 5
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Other services (for reference when setting them back up):
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# todo.hallocks.xyz {
|
||||||
|
# reverse_proxy localhost:3456 # Vikunja
|
||||||
|
# log {
|
||||||
|
# output file /var/log/caddy/todo.log
|
||||||
|
# format json
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# passwords.hallocks.xyz {
|
||||||
|
# reverse_proxy localhost:8080 # Vaultwarden
|
||||||
|
# log {
|
||||||
|
# output file /var/log/caddy/passwords.log
|
||||||
|
# format json
|
||||||
|
# }
|
||||||
|
# }
|
||||||
182
clio-infra/nixos/phone-recorder.nix
Normal file
182
clio-infra/nixos/phone-recorder.nix
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# NixOS module for phone-recorder server
|
||||||
|
# Add to your configuration.nix imports or use as a standalone module
|
||||||
|
#
|
||||||
|
# Usage in configuration.nix:
|
||||||
|
# imports = [ ./phone-recorder.nix ];
|
||||||
|
# services.phone-recorder.enable = true;
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.phone-recorder;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.phone-recorder = {
|
||||||
|
enable = lib.mkEnableOption "Phone Recorder server";
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8000;
|
||||||
|
description = "Port for the API server";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/phone-recorder";
|
||||||
|
description = "Directory for recordings and database";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "phone-recorder";
|
||||||
|
description = "User to run the service as";
|
||||||
|
};
|
||||||
|
|
||||||
|
domain = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "recordings.hallocks.xyz";
|
||||||
|
description = "Domain for HTTPS access";
|
||||||
|
};
|
||||||
|
|
||||||
|
enableCaddy = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable Caddy reverse proxy with automatic HTTPS";
|
||||||
|
};
|
||||||
|
|
||||||
|
enableFail2ban = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable fail2ban protection";
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = /home/thallock/phone-recorder;
|
||||||
|
description = "Path to the server source code";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Create user and group
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.user;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.user} = {};
|
||||||
|
|
||||||
|
# Systemd service
|
||||||
|
systemd.services.phone-recorder = {
|
||||||
|
description = "Phone Recorder API Server";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
HOST = "127.0.0.1"; # Only listen locally, Caddy handles external
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
BASE_DIR = toString cfg.sourceDir;
|
||||||
|
RECORDINGS_DIR = "${cfg.dataDir}/recordings";
|
||||||
|
DATABASE_PATH = "${cfg.dataDir}/recordings.db";
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.user;
|
||||||
|
WorkingDirectory = cfg.sourceDir;
|
||||||
|
ExecStart = "${pkgs.uv}/bin/uv run python main.py";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = "read-only";
|
||||||
|
PrivateTmp = true;
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
};
|
||||||
|
|
||||||
|
preStart = ''
|
||||||
|
mkdir -p ${cfg.dataDir}/recordings
|
||||||
|
mkdir -p ${cfg.dataDir}/keys
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Caddy reverse proxy
|
||||||
|
services.caddy = lib.mkIf cfg.enableCaddy {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts = {
|
||||||
|
"${cfg.domain}" = {
|
||||||
|
extraConfig = ''
|
||||||
|
# API endpoints
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
handle /health {
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static frontend (if you have one)
|
||||||
|
handle {
|
||||||
|
root * /var/www/recordings-viewer
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limiting for uploads
|
||||||
|
@upload path /api/v1/recordings/upload
|
||||||
|
handle @upload {
|
||||||
|
rate_limit {remote.ip} 10r/m
|
||||||
|
reverse_proxy localhost:${toString cfg.port}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/recordings.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Open firewall
|
||||||
|
networking.firewall.allowedTCPPorts = [ 80 443 ]
|
||||||
|
++ lib.optional (!cfg.enableCaddy) cfg.port;
|
||||||
|
|
||||||
|
# Fail2ban
|
||||||
|
services.fail2ban = lib.mkIf cfg.enableFail2ban {
|
||||||
|
enable = true;
|
||||||
|
maxretry = 5;
|
||||||
|
bantime = "1h";
|
||||||
|
|
||||||
|
jails = {
|
||||||
|
caddy-auth = lib.mkIf cfg.enableCaddy ''
|
||||||
|
enabled = true
|
||||||
|
filter = caddy-auth
|
||||||
|
logpath = /var/log/caddy/recordings.log
|
||||||
|
maxretry = 5
|
||||||
|
bantime = 3600
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Fail2ban filter for Caddy
|
||||||
|
environment.etc."fail2ban/filter.d/caddy-auth.conf" = lib.mkIf (cfg.enableFail2ban && cfg.enableCaddy) {
|
||||||
|
text = ''
|
||||||
|
[Definition]
|
||||||
|
failregex = ^.*"remote_ip":"<HOST>".*"status":40[13].*$
|
||||||
|
ignoreregex =
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
33
clio-infra/nixos/phone-recorder.service
Normal file
33
clio-infra/nixos/phone-recorder.service
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Systemd service file for phone-recorder
|
||||||
|
# Copy to /etc/systemd/system/phone-recorder.service
|
||||||
|
# Then: systemctl daemon-reload && systemctl enable --now phone-recorder
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Phone Recorder API Server
|
||||||
|
After=network.target local-fs.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=thallock
|
||||||
|
Group=thallock
|
||||||
|
WorkingDirectory=/work/hosting/recordings
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
Environment="HOST=0.0.0.0"
|
||||||
|
Environment="PORT=8000"
|
||||||
|
Environment="BASE_DIR=/work/hosting/recordings"
|
||||||
|
Environment="RECORDINGS_DIR=/media/external/Hosting/Recordings"
|
||||||
|
Environment="DATABASE_PATH=/work/hosting/recordings/data/recordings.db"
|
||||||
|
Environment="KEYS_DIR=/work/hosting/recordings/data/keys"
|
||||||
|
|
||||||
|
ExecStart=/home/thallock/.local/bin/uv run python main.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
74
clio-infra/scripts/deploy-api-server.sh
Executable file
74
clio-infra/scripts/deploy-api-server.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Deployment script for phone-recorder server
|
||||||
|
# Target: thallock@10.0.0.45 (SSH port 2252)
|
||||||
|
#
|
||||||
|
# Paths on server:
|
||||||
|
# Code: /work/hosting/recordings
|
||||||
|
# Data (db): /work/hosting/recordings/data
|
||||||
|
# Audio: /media/external/Hosting/Recordings
|
||||||
|
|
||||||
|
SERVER_HOST="10.0.0.45"
|
||||||
|
SERVER_USER="thallock"
|
||||||
|
SERVER_PORT="2252"
|
||||||
|
CODE_DIR="/work/hosting/recordings"
|
||||||
|
DATA_DIR="/work/hosting/recordings/data"
|
||||||
|
AUDIO_DIR="/media/external/Hosting/Recordings"
|
||||||
|
|
||||||
|
SSH_OPTS="-p ${SERVER_PORT}"
|
||||||
|
SSH_CMD="ssh ${SSH_OPTS} ${SERVER_USER}@${SERVER_HOST}"
|
||||||
|
RSYNC_SSH="ssh -p ${SERVER_PORT}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== Deploying phone-recorder server to ${SERVER_USER}@${SERVER_HOST} ==="
|
||||||
|
|
||||||
|
# Sync server code (excluding local data)
|
||||||
|
echo "Syncing server code..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude '.venv' \
|
||||||
|
--exclude '__pycache__' \
|
||||||
|
--exclude '*.pyc' \
|
||||||
|
--exclude 'recordings/' \
|
||||||
|
--exclude 'recordings.db' \
|
||||||
|
--exclude 'keys/' \
|
||||||
|
--exclude 'data/' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'nixos/' \
|
||||||
|
-e "${RSYNC_SSH}" \
|
||||||
|
"${SCRIPT_DIR}/" \
|
||||||
|
"${SERVER_USER}@${SERVER_HOST}:${CODE_DIR}/"
|
||||||
|
|
||||||
|
echo "Setting up on remote server..."
|
||||||
|
${SSH_CMD} bash << REMOTE_SCRIPT
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p ${DATA_DIR}/keys
|
||||||
|
mkdir -p ${AUDIO_DIR}
|
||||||
|
|
||||||
|
cd ${CODE_DIR}
|
||||||
|
|
||||||
|
# Sync dependencies
|
||||||
|
echo "Syncing Python dependencies..."
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Server code deployed ==="
|
||||||
|
echo "Code: ${CODE_DIR}"
|
||||||
|
echo "Data: ${DATA_DIR}"
|
||||||
|
echo "Audio: ${AUDIO_DIR}"
|
||||||
|
echo ""
|
||||||
|
echo "To install/update systemd service:"
|
||||||
|
echo " sudo cp ${CODE_DIR}/nixos/phone-recorder.service /etc/systemd/system/"
|
||||||
|
echo " sudo systemctl daemon-reload"
|
||||||
|
echo " sudo systemctl enable --now phone-recorder"
|
||||||
|
echo ""
|
||||||
|
echo "To check status:"
|
||||||
|
echo " sudo systemctl status phone-recorder"
|
||||||
|
echo " curl http://localhost:8000/health"
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment complete ==="
|
||||||
15
clio-infra/scripts/deploy-api.sh
Executable file
15
clio-infra/scripts/deploy-api.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/run/current-system/sw/bin/fish
|
||||||
|
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude '.venv' --exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
--exclude 'recordings/' --exclude 'recordings.db' --exclude 'keys/' \
|
||||||
|
--exclude 'data/' --exclude '.git' \
|
||||||
|
-e 'ssh -p 2252' \
|
||||||
|
/work/projects/phone_recorder/claude/server/ \
|
||||||
|
thallock@10.0.0.45:/work/hosting/recordings/
|
||||||
|
|
||||||
|
# Deploy React frontend
|
||||||
|
rsync -avz --delete \
|
||||||
|
-e 'ssh -p 2252' \
|
||||||
|
/work/projects/view_recordings/designer_export/ \
|
||||||
|
thallock@10.0.0.45:/work/hosting/recordings-viewer/
|
||||||
78
clio-infra/scripts/deploy.sh
Executable file
78
clio-infra/scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Clio deployment script
|
||||||
|
# Deploys clio-api and clio-ui to server (10.0.0.45)
|
||||||
|
#
|
||||||
|
# Usage: ./deploy.sh [api|ui|all]
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CLIO_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||||
|
|
||||||
|
SERVER_HOST="10.0.0.45"
|
||||||
|
SERVER_USER="thallock"
|
||||||
|
SERVER_PORT="2252"
|
||||||
|
|
||||||
|
# Server paths
|
||||||
|
API_CODE_DIR="/work/hosting/recordings"
|
||||||
|
UI_DIR="/work/hosting/recordings-viewer"
|
||||||
|
|
||||||
|
SSH_OPTS="-p ${SERVER_PORT}"
|
||||||
|
SSH_CMD="ssh ${SSH_OPTS} ${SERVER_USER}@${SERVER_HOST}"
|
||||||
|
RSYNC_SSH="ssh -p ${SERVER_PORT}"
|
||||||
|
|
||||||
|
deploy_api() {
|
||||||
|
echo "=== Deploying clio-api ==="
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude '.venv' \
|
||||||
|
--exclude '__pycache__' \
|
||||||
|
--exclude '*.pyc' \
|
||||||
|
--exclude 'recordings/' \
|
||||||
|
--exclude 'recordings.db' \
|
||||||
|
--exclude 'keys/' \
|
||||||
|
--exclude 'data/' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'nixos/' \
|
||||||
|
-e "${RSYNC_SSH}" \
|
||||||
|
"${CLIO_ROOT}/clio-api/" \
|
||||||
|
"${SERVER_USER}@${SERVER_HOST}:${API_CODE_DIR}/"
|
||||||
|
|
||||||
|
echo "Running uv sync on server..."
|
||||||
|
${SSH_CMD} "cd ${API_CODE_DIR} && uv sync"
|
||||||
|
echo "clio-api deployed."
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_ui() {
|
||||||
|
echo "=== Deploying clio-ui ==="
|
||||||
|
rsync -avz --delete \
|
||||||
|
-e "${RSYNC_SSH}" \
|
||||||
|
"${CLIO_ROOT}/clio-ui/" \
|
||||||
|
"${SERVER_USER}@${SERVER_HOST}:${UI_DIR}/"
|
||||||
|
echo "clio-ui deployed."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-all}" in
|
||||||
|
api)
|
||||||
|
deploy_api
|
||||||
|
;;
|
||||||
|
ui)
|
||||||
|
deploy_ui
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
deploy_api
|
||||||
|
deploy_ui
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [api|ui|all]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "To restart the API service:"
|
||||||
|
echo " ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} 'sudo systemctl restart phone-recorder'"
|
||||||
|
echo ""
|
||||||
|
echo "To check status:"
|
||||||
|
echo " curl https://recordings.hallocks.xyz/health"
|
||||||
102
clio-pi/README.md
Normal file
102
clio-pi/README.md
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Clio Pi
|
||||||
|
|
||||||
|
Raspberry Pi recording station for the Clio audio system.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Continuous audio recording with ffmpeg (opus codec, 2hr segments)
|
||||||
|
- Systemd service for auto-start on boot
|
||||||
|
- HTML status page showing recording coverage
|
||||||
|
- NTP time sync via chrony
|
||||||
|
- SSH key-only access (hardened)
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
1. Flash Raspberry Pi OS Lite to SD card using Raspberry Pi Imager
|
||||||
|
2. Configure in Imager: hostname, user/pass, WiFi, enable SSH
|
||||||
|
3. Boot and SSH into Pi
|
||||||
|
4. Copy this directory to the Pi:
|
||||||
|
```bash
|
||||||
|
rsync -avz /work/projects/clio/clio-pi/ pi@<pi-ip>:~/clio-pi/
|
||||||
|
```
|
||||||
|
5. Run setup:
|
||||||
|
```bash
|
||||||
|
ssh pi@<pi-ip>
|
||||||
|
cd ~/clio-pi
|
||||||
|
sudo ./setup.sh
|
||||||
|
```
|
||||||
|
6. Add your SSH key before rebooting:
|
||||||
|
```bash
|
||||||
|
ssh-copy-id pi@<pi-ip>
|
||||||
|
```
|
||||||
|
7. Start recording:
|
||||||
|
```bash
|
||||||
|
sudo systemctl start record.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
clio-pi/
|
||||||
|
├── setup.sh # Main setup script (run with sudo)
|
||||||
|
├── bin/
|
||||||
|
│ ├── record.sh # Recording loop (ffmpeg)
|
||||||
|
│ └── gen_recording_status.py # Status page generator
|
||||||
|
├── systemd/
|
||||||
|
│ └── record.service # Systemd service unit
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `~/bin/record.sh` environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `AUDIO_DEVICE` | `hw:1` | ALSA device (run `arecord -l` to list) |
|
||||||
|
| `SEGMENT_SECONDS` | `7200` | Segment length (2 hours) |
|
||||||
|
| `RECORDINGS_DIR` | `~/recordings` | Output directory |
|
||||||
|
| `SOURCE_NAME` | `stationary` | Filename prefix |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `record.service` | Recording daemon |
|
||||||
|
| `chrony` | NTP time sync |
|
||||||
|
| `nginx` | Status page web server |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recording service
|
||||||
|
sudo systemctl start record.service
|
||||||
|
sudo systemctl stop record.service
|
||||||
|
sudo systemctl status record.service
|
||||||
|
journalctl -u record.service -f
|
||||||
|
|
||||||
|
# Check time sync
|
||||||
|
chronyc tracking
|
||||||
|
timedatectl
|
||||||
|
|
||||||
|
# View status page
|
||||||
|
curl http://localhost/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Syncing Recordings to Server
|
||||||
|
|
||||||
|
TODO: Add rsync/sync script to push recordings to server (10.0.0.45)
|
||||||
|
|
||||||
|
## Audio Device Setup
|
||||||
|
|
||||||
|
List available devices:
|
||||||
|
```bash
|
||||||
|
arecord -l
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
```
|
||||||
|
card 1: Device [USB Audio Device], device 0: USB Audio [USB Audio]
|
||||||
|
```
|
||||||
|
|
||||||
|
This would be `hw:1,0` or just `hw:1`.
|
||||||
352
clio-pi/bin/gen_recording_status.py
Executable file
352
clio-pi/bin/gen_recording_status.py
Executable file
|
|
@ -0,0 +1,352 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
FNAME_RE = re.compile(r"^stationary-(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.opus$")
|
||||||
|
|
||||||
|
def parse_start(path: Path, tz) -> Optional[dt.datetime]:
|
||||||
|
m = FNAME_RE.match(path.name)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
date_str, time_str = m.groups()
|
||||||
|
start = dt.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H-%M-%S")
|
||||||
|
return start.replace(tzinfo=tz)
|
||||||
|
|
||||||
|
def get_duration_seconds(path: Path) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
from mutagen.oggopus import OggOpus # optional
|
||||||
|
audio = OggOpus(path)
|
||||||
|
return float(getattr(audio.info, "length", 0.0)) or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def human_delta(delta: dt.timedelta) -> str:
|
||||||
|
secs = int(abs(delta).total_seconds())
|
||||||
|
d, rem = divmod(secs, 86400)
|
||||||
|
h, rem = divmod(rem, 3600)
|
||||||
|
m, s = divmod(rem, 60)
|
||||||
|
parts = []
|
||||||
|
if d: parts.append(f"{d}d")
|
||||||
|
if h: parts.append(f"{h}h")
|
||||||
|
if m: parts.append(f"{m}m")
|
||||||
|
if not parts: parts.append(f"{s}s")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
def merge_intervals(intervals: List[Tuple[dt.datetime, dt.datetime]]) -> List[Tuple[dt.datetime, dt.datetime]]:
|
||||||
|
if not intervals: return []
|
||||||
|
intervals.sort(key=lambda x: x[0])
|
||||||
|
merged = [intervals[0]]
|
||||||
|
for s, e in intervals[1:]:
|
||||||
|
ls, le = merged[-1]
|
||||||
|
if s <= le:
|
||||||
|
merged[-1] = (ls, max(le, e))
|
||||||
|
else:
|
||||||
|
merged.append((s, e))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def day_bounds(d: dt.date, tz):
|
||||||
|
start = dt.datetime(d.year, d.month, d.day, tzinfo=tz)
|
||||||
|
return start, start + dt.timedelta(days=1)
|
||||||
|
|
||||||
|
def intersect(a: Tuple[dt.datetime, dt.datetime], b: Tuple[dt.datetime, dt.datetime]) -> Optional[Tuple[dt.datetime, dt.datetime]]:
|
||||||
|
s1, e1 = a; s2, e2 = b
|
||||||
|
s = max(s1, s2); e = min(e1, e2)
|
||||||
|
return (s, e) if s < e else None
|
||||||
|
|
||||||
|
def pct(x, total):
|
||||||
|
return 0 if total <= 0 else max(0, min(100, (x/total)*100))
|
||||||
|
|
||||||
|
def fmt_ampm(t: dt.datetime) -> str:
|
||||||
|
s = t.strftime("%I:%M%p") # 01:05PM
|
||||||
|
return s.lstrip("0")
|
||||||
|
|
||||||
|
def build(args):
|
||||||
|
tz = dt.datetime.now().astimezone().tzinfo
|
||||||
|
rec_dir = Path(args.dir).expanduser().resolve()
|
||||||
|
files = [p for p in rec_dir.glob("*.opus") if p.is_file()]
|
||||||
|
files.sort()
|
||||||
|
|
||||||
|
# Gather metadata
|
||||||
|
items = [] # (path, start_dt, size_bytes, duration_opt)
|
||||||
|
for p in files:
|
||||||
|
s = parse_start(p, tz)
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
dur = get_duration_seconds(p) if args.read_durations else None
|
||||||
|
try:
|
||||||
|
size = p.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
items.append((p, s, size, dur))
|
||||||
|
items.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
starts = [s for _, s, _, _ in items]
|
||||||
|
deltas = [(b - a).total_seconds() for a, b in zip(starts, starts[1:])]
|
||||||
|
|
||||||
|
# Duration policy (defaults: NO inference, assume 2h)
|
||||||
|
if args.infer:
|
||||||
|
sane = [d for d in deltas if args.infer_min_sec <= d <= args.infer_max_sec]
|
||||||
|
inferred_default = (statistics.median(sane) if sane else args.assume_duration_sec or args.infer_default_sec)
|
||||||
|
inferred_default = max(args.infer_min_sec, min(args.infer_max_sec, inferred_default))
|
||||||
|
else:
|
||||||
|
inferred_default = args.assume_duration_sec or args.infer_default_sec
|
||||||
|
|
||||||
|
# Build intervals
|
||||||
|
intervals: List[Tuple[dt.datetime, dt.datetime]] = []
|
||||||
|
for idx, (p, s, _size, dur) in enumerate(items):
|
||||||
|
if dur is None or dur <= 0:
|
||||||
|
dur_use = float(inferred_default)
|
||||||
|
else:
|
||||||
|
dur_use = dur
|
||||||
|
if idx < len(items)-1 and args.guard_gap_sec > 0:
|
||||||
|
to_next = (items[idx+1][1] - s).total_seconds()
|
||||||
|
dur_use = min(dur_use, max(1.0, to_next - args.guard_gap_sec))
|
||||||
|
e = s + dt.timedelta(seconds=max(1.0, dur_use))
|
||||||
|
intervals.append((s, e))
|
||||||
|
|
||||||
|
gen_time = dt.datetime.now(tz)
|
||||||
|
last_start = starts[-1] if starts else None
|
||||||
|
|
||||||
|
# FS usage for recordings partition
|
||||||
|
try:
|
||||||
|
total_b, used_b, free_b = shutil.disk_usage(str(rec_dir))
|
||||||
|
except Exception:
|
||||||
|
total_b = used_b = free_b = 0
|
||||||
|
|
||||||
|
# Window
|
||||||
|
today = gen_time.date()
|
||||||
|
start_date = today - dt.timedelta(days=args.days - 1)
|
||||||
|
|
||||||
|
big_gap = dt.timedelta(minutes=args.gap_yellow_min)
|
||||||
|
green_gap = dt.timedelta(minutes=args.gap_green_min)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
window_total_bytes = 0
|
||||||
|
for i in range(args.days):
|
||||||
|
d = start_date + dt.timedelta(days=i)
|
||||||
|
d_start, d_end = day_bounds(d, tz)
|
||||||
|
|
||||||
|
# Per-day file count and bytes (count files that START this day)
|
||||||
|
day_files = [(p, s, size) for (p, s, size, _du) in [(p, s, size, du) for (p, s, size, du) in items] if d_start <= s < d_end]
|
||||||
|
day_bytes = sum(size for (_p, _s, size) in day_files)
|
||||||
|
window_total_bytes += day_bytes
|
||||||
|
|
||||||
|
# Intervals overlapping this day
|
||||||
|
day_intervals = []
|
||||||
|
for (s, e) in intervals:
|
||||||
|
inter = intersect((s, e), (d_start, d_end))
|
||||||
|
if inter:
|
||||||
|
day_intervals.append(inter)
|
||||||
|
|
||||||
|
merged = merge_intervals(day_intervals)
|
||||||
|
covered = sum((e - s).total_seconds() for s, e in merged)
|
||||||
|
day_seconds = 86400.0
|
||||||
|
coverage_pct = pct(covered, day_seconds)
|
||||||
|
|
||||||
|
# Max gap includes edges
|
||||||
|
if not merged:
|
||||||
|
max_gap = dt.timedelta(days=1)
|
||||||
|
else:
|
||||||
|
gaps = [merged[0][0] - d_start]
|
||||||
|
gaps += [b[0] - a[1] for a, b in zip(merged, merged[1:])]
|
||||||
|
gaps.append(d_end - merged[-1][1])
|
||||||
|
max_gap = max(gaps) if gaps else dt.timedelta(0)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
count = len(day_files)
|
||||||
|
if count == 0:
|
||||||
|
status = "red"
|
||||||
|
elif max_gap <= green_gap and coverage_pct >= args.green_cover_pct:
|
||||||
|
status = "green"
|
||||||
|
elif max_gap >= big_gap or coverage_pct < args.yellow_cover_pct:
|
||||||
|
status = "yellow"
|
||||||
|
else:
|
||||||
|
status = "yellow"
|
||||||
|
|
||||||
|
# Mini timeline segments
|
||||||
|
segs = []
|
||||||
|
for s, e in merged:
|
||||||
|
left = pct((s - d_start).total_seconds(), day_seconds)
|
||||||
|
width = pct((e - s).total_seconds(), day_seconds)
|
||||||
|
if width > 0:
|
||||||
|
segs.append((left, width))
|
||||||
|
|
||||||
|
# Details list: show ALL spans; AM/PM times
|
||||||
|
details_txt = ", ".join(f"{fmt_ampm(s)}–{fmt_ampm(e)}" for s, e in merged)
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"date": d.isoformat(),
|
||||||
|
"count": count,
|
||||||
|
"bytes": day_bytes,
|
||||||
|
"max_gap": max_gap,
|
||||||
|
"coverage_pct": coverage_pct,
|
||||||
|
"status": status,
|
||||||
|
"segments": segs,
|
||||||
|
"details": details_txt or "—",
|
||||||
|
})
|
||||||
|
|
||||||
|
# HTML
|
||||||
|
def hhmm(td: dt.timedelta) -> str:
|
||||||
|
total = int(td.total_seconds())
|
||||||
|
h = total // 3600
|
||||||
|
m = (total % 3600) // 60
|
||||||
|
return f"{h:02d}:{m:02d}"
|
||||||
|
|
||||||
|
def fmt_bytes(n: int) -> str:
|
||||||
|
units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
||||||
|
x = float(n)
|
||||||
|
for u in units:
|
||||||
|
if x < 1024.0 or u == units[-1]:
|
||||||
|
return f"{int(x)} {u}" if u == "B" else f"{x:.1f} {u}"
|
||||||
|
x /= 1024.0
|
||||||
|
|
||||||
|
|
||||||
|
title = args.title
|
||||||
|
last_str = "—"
|
||||||
|
if last_start:
|
||||||
|
last_str = f"{last_start.strftime('%Y-%m-%d %I:%M:%S %p %Z')} ({human_delta(gen_time - last_start)} ago)"
|
||||||
|
|
||||||
|
# FS usage strings
|
||||||
|
total_str = fmt_bytes(total_b)
|
||||||
|
used_str = fmt_bytes(used_b)
|
||||||
|
free_str = fmt_bytes(free_b)
|
||||||
|
window_bytes_str = fmt_bytes(window_total_bytes)
|
||||||
|
|
||||||
|
html_out = f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="300">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{html.escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--green:#30a46c; --yellow:#f5d90a; --red:#e5484d;
|
||||||
|
--bg-green:#e9f7f0; --bg-yellow:#fff8cc; --bg-red:#fdeaea;
|
||||||
|
}}
|
||||||
|
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 1.5rem; }}
|
||||||
|
h1 {{ margin: 0 0 .4rem 0; }}
|
||||||
|
.muted {{ color: #666; font-size: .9rem; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; max-width: 1200px; }}
|
||||||
|
th, td {{ padding: .5rem .6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: middle; }}
|
||||||
|
tr:hover td {{ background: #fafafa; }}
|
||||||
|
.right {{ text-align: right; }}
|
||||||
|
.row-green td {{ background: var(--bg-green); }}
|
||||||
|
.row-yellow td {{ background: var(--bg-yellow); }}
|
||||||
|
.row-red td {{ background: var(--bg-red); }}
|
||||||
|
.status-dot {{ display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle;margin-right:.4rem;}}
|
||||||
|
.green {{ background: var(--green); }}
|
||||||
|
.yellow {{ background: var(--yellow); }}
|
||||||
|
.red {{ background: var(--red); }}
|
||||||
|
.bar {{ position: relative; height: 12px; background: #eee; border-radius: 6px; overflow: hidden; }}
|
||||||
|
.bar .seg {{ position: absolute; top:0; bottom:0; background: var(--green); opacity:.9; }}
|
||||||
|
.cover-label {{ font-size:.85rem; white-space:nowrap; }}
|
||||||
|
.nowrap {{ white-space:nowrap; }}
|
||||||
|
.hdr {{ margin-top:.6rem; margin-bottom:.8rem; }}
|
||||||
|
.kvs span {{ margin-right: 1rem; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{html.escape(title)}</h1>
|
||||||
|
<div class="muted">Generated at {gen_time.strftime('%Y-%m-%d %I:%M:%S %p %Z')}</div>
|
||||||
|
<div class="hdr kvs muted">
|
||||||
|
<span><strong>Last recording started:</strong> {html.escape(last_str)}</span>
|
||||||
|
<span><strong>Window:</strong> {args.days} days</span>
|
||||||
|
<span><strong>Dir:</strong> {html.escape(str(rec_dir))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hdr kvs">
|
||||||
|
<span><strong>Data in window:</strong> {window_bytes_str}</span>
|
||||||
|
<span><strong>Filesystem usage (recordings partition):</strong> used {used_str} • free {free_str} • total {total_str}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="right"># Recs</th>
|
||||||
|
<th class="right">Data</th>
|
||||||
|
<th class="right">Max gap</th>
|
||||||
|
<th>Coverage</th>
|
||||||
|
<th>Spans</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
"""
|
||||||
|
for r in reversed(rows):
|
||||||
|
cls = f"row-{r['status']}"
|
||||||
|
cov = int(round(r["coverage_pct"]))
|
||||||
|
seg_divs = "".join(
|
||||||
|
f'<div class="seg" style="left:{left:.3f}%;width:{width:.3f}%"></div>'
|
||||||
|
for left, width in r["segments"]
|
||||||
|
)
|
||||||
|
data_str = fmt_bytes(r["bytes"])
|
||||||
|
html_out += f""" <tr class="{cls}">
|
||||||
|
<td class="nowrap">{r['date']}</td>
|
||||||
|
<td class="right">{r['count']}</td>
|
||||||
|
<td class="right">{data_str}</td>
|
||||||
|
<td class="right">{hhmm(r['max_gap'])}</td>
|
||||||
|
<td>
|
||||||
|
<div class="bar" title="{cov}% covered">{seg_divs}</div>
|
||||||
|
<div class="cover-label">{cov}%</div>
|
||||||
|
</td>
|
||||||
|
<td><div class="muted">{html.escape(r['details'])}</div></td>
|
||||||
|
<td><span class="status-dot {r['status']}"></span>{r['status']}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
html_out += """ </tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Legend: <span class="status-dot green"></span> green = nearly continuous • <span class="status-dot yellow"></span> yellow = gaps or low coverage • <span class="status-dot red"></span> red = no recordings</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
out_path = Path(args.out).resolve()
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = out_path.with_suffix(out_path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html_out)
|
||||||
|
os.replace(tmp, out_path)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Generate recordings status HTML")
|
||||||
|
p.add_argument("--dir", default=str(Path.home() / "recordings"),
|
||||||
|
help="Directory containing .opus files (default: ~/recordings)")
|
||||||
|
p.add_argument("--out", default="/var/www/html/index.html",
|
||||||
|
help="HTML output path (default: /var/www/html/index.html)")
|
||||||
|
# Defaults tuned so cron needs no flags:
|
||||||
|
p.add_argument("--days", type=int, default=365, help="How many days to show (default: 365)")
|
||||||
|
p.add_argument("--gap-yellow-min", type=int, default=120, help="Gap (minutes) that marks a day as yellow (default: 120)")
|
||||||
|
p.add_argument("--gap-green-min", type=int, default=15, help="Max gap (minutes) to still be green (default: 15)")
|
||||||
|
p.add_argument("--read-durations", action="store_true",
|
||||||
|
help="Use mutagen to read exact file durations (optional; default off).")
|
||||||
|
p.add_argument("--green-cover-pct", type=float, default=90.0,
|
||||||
|
help="Min coverage%% for green (default: 90)")
|
||||||
|
p.add_argument("--yellow-cover-pct", type=float, default=10.0,
|
||||||
|
help="Coverage%% below which day is yellow (default: 10)")
|
||||||
|
# Duration policy: default is NO inference, assume 2h
|
||||||
|
p.add_argument("--assume-duration-sec", type=int, default=7200,
|
||||||
|
help="Assumed duration (s) when real duration is unavailable. Default: 7200 (2h).")
|
||||||
|
p.add_argument("--infer", action="store_true",
|
||||||
|
help="Enable inferring duration from median start deltas (disabled by default).")
|
||||||
|
p.add_argument("--infer-min-sec", type=int, default=60,
|
||||||
|
help="Ignore deltas smaller than this when inferring (default: 60s)")
|
||||||
|
p.add_argument("--infer-max-sec", type=int, default=6*3600,
|
||||||
|
help="Ignore deltas larger than this when inferring (default: 6h)")
|
||||||
|
p.add_argument("--infer-default-sec", type=int, default=20*60,
|
||||||
|
help="Fallback segment length if nothing else is available (default: 1200s)")
|
||||||
|
p.add_argument("--guard-gap-sec", type=int, default=0,
|
||||||
|
help="Don’t let a segment extend within this many seconds of next start (default: 0)")
|
||||||
|
p.add_argument("--title", default="Portable Recorder Status", help="Page title")
|
||||||
|
args = p.parse_args()
|
||||||
|
build(args)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
31
clio-pi/bin/record.sh
Executable file
31
clio-pi/bin/record.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:1}" # ALSA device (run 'arecord -l' to list)
|
||||||
|
SEGMENT_SECONDS="${SEGMENT_SECONDS:-7200}" # 2 hours per file
|
||||||
|
RECORDINGS_DIR="${RECORDINGS_DIR:-$HOME/recordings}"
|
||||||
|
SOURCE_NAME="${SOURCE_NAME:-stationary}" # Prefix for filenames
|
||||||
|
|
||||||
|
mkdir -p "${RECORDINGS_DIR}"
|
||||||
|
|
||||||
|
echo "Starting recording..."
|
||||||
|
echo " Device: ${AUDIO_DEVICE}"
|
||||||
|
echo " Segment: ${SEGMENT_SECONDS}s"
|
||||||
|
echo " Output: ${RECORDINGS_DIR}/${SOURCE_NAME}-*.opus"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
ffmpeg -f alsa -ac 1 -i "${AUDIO_DEVICE}" \
|
||||||
|
-c:a libopus \
|
||||||
|
-b:a 32k \
|
||||||
|
-f segment \
|
||||||
|
-segment_time "${SEGMENT_SECONDS}" \
|
||||||
|
-strftime 1 \
|
||||||
|
-reset_timestamps 1 \
|
||||||
|
"${RECORDINGS_DIR}/${SOURCE_NAME}-%Y-%m-%d_%H-%M-%S.opus" \
|
||||||
|
2>&1 | while read line; do echo "[ffmpeg] $line"; done
|
||||||
|
|
||||||
|
echo "Recording stopped, restarting in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
238
clio-pi/setup.sh
Executable file
238
clio-pi/setup.sh
Executable file
|
|
@ -0,0 +1,238 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Clio Pi Setup Script
|
||||||
|
# Run this after a fresh Raspberry Pi OS install
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# 1. Flash Raspberry Pi OS Lite to SD card
|
||||||
|
# 2. Use Raspberry Pi Imager to pre-configure:
|
||||||
|
# - Hostname (e.g., clio-pi)
|
||||||
|
# - Username/password
|
||||||
|
# - WiFi credentials
|
||||||
|
# - Enable SSH
|
||||||
|
# 3. Boot Pi and SSH in
|
||||||
|
# 4. Copy this repo to /home/pi/clio-pi
|
||||||
|
# 5. Run: sudo ./setup.sh
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PI_USER="${PI_USER:-pi}"
|
||||||
|
PI_HOME="/home/${PI_USER}"
|
||||||
|
|
||||||
|
echo "=== Clio Pi Setup ==="
|
||||||
|
echo "User: ${PI_USER}"
|
||||||
|
echo "Home: ${PI_HOME}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. System updates
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Updating system packages..."
|
||||||
|
apt-get update
|
||||||
|
apt-get upgrade -y
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. Install required packages
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Installing required packages..."
|
||||||
|
apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
nginx-light \
|
||||||
|
chrony \
|
||||||
|
rsync
|
||||||
|
|
||||||
|
# Optional: for reading audio durations
|
||||||
|
pip3 install --break-system-packages mutagen || true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. Configure NTP/time sync (chrony)
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Configuring time sync (chrony)..."
|
||||||
|
cat > /etc/chrony/chrony.conf << 'EOF'
|
||||||
|
# Use default NTP pools
|
||||||
|
pool 2.debian.pool.ntp.org iburst
|
||||||
|
|
||||||
|
# Allow the system clock to be stepped in the first 3 updates
|
||||||
|
# if its offset is larger than 1 second
|
||||||
|
makestep 1.0 3
|
||||||
|
|
||||||
|
# Enable kernel synchronization of the real-time clock (RTC)
|
||||||
|
rtcsync
|
||||||
|
|
||||||
|
# Serve time to the local subnet (optional)
|
||||||
|
#allow 10.0.0.0/24
|
||||||
|
|
||||||
|
# Log tracking, measurements, statistics
|
||||||
|
logdir /var/log/chrony
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl enable chrony
|
||||||
|
systemctl restart chrony
|
||||||
|
|
||||||
|
# Set timezone (adjust as needed)
|
||||||
|
timedatectl set-timezone America/New_York
|
||||||
|
|
||||||
|
echo ">>> Time sync configured. Current time:"
|
||||||
|
timedatectl
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. Configure SSH key-only access
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Configuring SSH for key-only access..."
|
||||||
|
|
||||||
|
# Ensure .ssh directory exists
|
||||||
|
mkdir -p "${PI_HOME}/.ssh"
|
||||||
|
chmod 700 "${PI_HOME}/.ssh"
|
||||||
|
touch "${PI_HOME}/.ssh/authorized_keys"
|
||||||
|
chmod 600 "${PI_HOME}/.ssh/authorized_keys"
|
||||||
|
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/.ssh"
|
||||||
|
|
||||||
|
# Harden sshd_config
|
||||||
|
SSHD_CONFIG="/etc/ssh/sshd_config"
|
||||||
|
|
||||||
|
# Backup original
|
||||||
|
cp "${SSHD_CONFIG}" "${SSHD_CONFIG}.bak"
|
||||||
|
|
||||||
|
# Disable password authentication
|
||||||
|
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' "${SSHD_CONFIG}"
|
||||||
|
sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' "${SSHD_CONFIG}"
|
||||||
|
sed -i 's/^#*UsePAM.*/UsePAM no/' "${SSHD_CONFIG}"
|
||||||
|
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' "${SSHD_CONFIG}"
|
||||||
|
|
||||||
|
# Ensure pubkey auth is enabled
|
||||||
|
grep -q "^PubkeyAuthentication" "${SSHD_CONFIG}" || echo "PubkeyAuthentication yes" >> "${SSHD_CONFIG}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "!!! IMPORTANT !!!"
|
||||||
|
echo "Before rebooting, add your SSH public key to:"
|
||||||
|
echo " ${PI_HOME}/.ssh/authorized_keys"
|
||||||
|
echo ""
|
||||||
|
echo "From your local machine, run:"
|
||||||
|
echo " ssh-copy-id ${PI_USER}@<pi-ip-address>"
|
||||||
|
echo ""
|
||||||
|
echo "Or manually append your ~/.ssh/id_ed25519.pub (or id_rsa.pub)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Don't restart SSH yet - user needs to add their key first
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. Set up recording directories
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Setting up recording directories..."
|
||||||
|
mkdir -p "${PI_HOME}/recordings"
|
||||||
|
mkdir -p "${PI_HOME}/bin"
|
||||||
|
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/recordings"
|
||||||
|
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. Install recording scripts
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Installing recording scripts..."
|
||||||
|
cp "${SCRIPT_DIR}/bin/record.sh" "${PI_HOME}/bin/"
|
||||||
|
cp "${SCRIPT_DIR}/bin/gen_recording_status.py" "${PI_HOME}/bin/"
|
||||||
|
chmod +x "${PI_HOME}/bin/record.sh"
|
||||||
|
chmod +x "${PI_HOME}/bin/gen_recording_status.py"
|
||||||
|
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. Install systemd service
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Installing systemd service..."
|
||||||
|
cp "${SCRIPT_DIR}/systemd/record.service" /etc/systemd/system/
|
||||||
|
sed -i "s|/home/pi|${PI_HOME}|g" /etc/systemd/system/record.service
|
||||||
|
sed -i "s|User=pi|User=${PI_USER}|g" /etc/systemd/system/record.service
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable record.service
|
||||||
|
# Don't start yet - user may need to configure audio device
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. Set up cron for status page
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Setting up cron job for status page..."
|
||||||
|
CRON_LINE="*/10 * * * * python3 ${PI_HOME}/bin/gen_recording_status.py --dir ${PI_HOME}/recordings"
|
||||||
|
|
||||||
|
# Install cron job for pi user
|
||||||
|
(crontab -u "${PI_USER}" -l 2>/dev/null | grep -v gen_recording_status; echo "${CRON_LINE}") | crontab -u "${PI_USER}" -
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 9. Configure nginx for status page
|
||||||
|
# ============================================================
|
||||||
|
echo ">>> Configuring nginx for status page..."
|
||||||
|
cat > /etc/nginx/sites-available/default << 'EOF'
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
root /var/www/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Ensure www-data can read the status file
|
||||||
|
mkdir -p /var/www/html
|
||||||
|
chown -R www-data:www-data /var/www/html
|
||||||
|
|
||||||
|
# Allow pi user to write to /var/www/html
|
||||||
|
usermod -a -G www-data "${PI_USER}" || true
|
||||||
|
chmod 775 /var/www/html
|
||||||
|
|
||||||
|
systemctl enable nginx
|
||||||
|
systemctl restart nginx
|
||||||
|
|
||||||
|
# Generate initial status page
|
||||||
|
sudo -u "${PI_USER}" python3 "${PI_HOME}/bin/gen_recording_status.py" --dir "${PI_HOME}/recordings" || true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 10. Print audio device info
|
||||||
|
# ============================================================
|
||||||
|
echo ""
|
||||||
|
echo "=== Audio Devices ==="
|
||||||
|
arecord -l || echo "(no audio devices found yet)"
|
||||||
|
echo ""
|
||||||
|
echo "The recording script uses hw:1 by default."
|
||||||
|
echo "Edit ${PI_HOME}/bin/record.sh if your device is different."
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Summary
|
||||||
|
# ============================================================
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "=== Setup Complete ==="
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo "1. Add your SSH public key:"
|
||||||
|
echo " From your computer: ssh-copy-id ${PI_USER}@$(hostname -I | awk '{print $1}')"
|
||||||
|
echo ""
|
||||||
|
echo "2. Test SSH key login works, then restart SSH:"
|
||||||
|
echo " sudo systemctl restart sshd"
|
||||||
|
echo ""
|
||||||
|
echo "3. Check/configure audio device:"
|
||||||
|
echo " arecord -l"
|
||||||
|
echo " Edit ~/bin/record.sh if needed (currently uses hw:1)"
|
||||||
|
echo ""
|
||||||
|
echo "4. Start recording service:"
|
||||||
|
echo " sudo systemctl start record.service"
|
||||||
|
echo " sudo systemctl status record.service"
|
||||||
|
echo ""
|
||||||
|
echo "5. View status page:"
|
||||||
|
echo " http://$(hostname -I | awk '{print $1}')/"
|
||||||
|
echo ""
|
||||||
|
echo "Services installed:"
|
||||||
|
echo " - chrony (NTP time sync) - RUNNING"
|
||||||
|
echo " - nginx (status page) - RUNNING"
|
||||||
|
echo " - record.service - ENABLED (not started)"
|
||||||
|
echo ""
|
||||||
|
echo "Cron job installed:"
|
||||||
|
echo " - gen_recording_status.py runs every 10 minutes"
|
||||||
|
echo ""
|
||||||
11
clio-pi/systemd/record.service
Normal file
11
clio-pi/systemd/record.service
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Record
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/home/pi/bin/record.sh
|
||||||
|
User=pi
|
||||||
|
Type=simple
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue