adding a bunch of untested files
Some checks failed
Deploy API / deploy (push) Failing after 1s

This commit is contained in:
Your Name 2026-05-19 20:12:49 -04:00
parent ab3a76cd0f
commit 56d8fb4d56
85 changed files with 8879 additions and 0 deletions

1
clio-android/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
android_studio_install_path

View 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
View 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 { *; }

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

View file

@ -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")
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}

View file

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

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

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

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

View 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_active" />
</shape>

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="recordings"
path="recordings/" />
</paths>

View 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
View 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
View 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
View 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"
'';
};
}
);
}

View 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
View 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
View 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
View 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?

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