This commit is contained in:
parent
ab3a76cd0f
commit
56d8fb4d56
85 changed files with 8879 additions and 0 deletions
1
clio-android/.gitignore
vendored
Normal file
1
clio-android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
android_studio_install_path
|
||||
61
clio-android/app/build.gradle.kts
Normal file
61
clio-android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.phonerecorder"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.phonerecorder"
|
||||
minSdk = 29
|
||||
targetSdk = 33
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
// Build timestamp for version display
|
||||
buildConfigField("long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.10.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
|
||||
// Encryption
|
||||
implementation("com.goterl:lazysodium-android:5.1.0@aar")
|
||||
implementation("net.java.dev.jna:jna:5.14.0@aar")
|
||||
|
||||
// Background sync
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
||||
// HTTP client
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
}
|
||||
7
clio-android/app/proguard-rules.pro
vendored
Normal file
7
clio-android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /sdk/tools/proguard/proguard-android.txt
|
||||
|
||||
# Keep service and receiver classes
|
||||
-keep class com.example.phonerecorder.RecordingService { *; }
|
||||
-keep class com.example.phonerecorder.BootReceiver { *; }
|
||||
74
clio-android/app/src/main/AndroidManifest.xml
Normal file
74
clio-android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Audio recording -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- Foreground service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<!-- Notifications (Android 13+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Auto-start on boot -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Keep CPU awake during recording -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Request to ignore battery optimizations -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Network for sync -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.PhoneRecorder"
|
||||
tools:targetApi="33">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".RecordingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.example.phonerecorder
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BootReceiver"
|
||||
private const val PREFS_NAME = "phone_recorder_prefs"
|
||||
private const val PREF_AUTO_START = "auto_start_enabled"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
Log.i(TAG, "Boot completed received")
|
||||
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val autoStartEnabled = prefs.getBoolean(PREF_AUTO_START, false)
|
||||
|
||||
if (autoStartEnabled) {
|
||||
Log.i(TAG, "Auto-start enabled, starting recording service")
|
||||
val serviceIntent = Intent(context, RecordingService::class.java)
|
||||
ContextCompat.startForegroundService(context, serviceIntent)
|
||||
} else {
|
||||
Log.i(TAG, "Auto-start disabled, not starting service")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,540 @@
|
|||
package com.example.phonerecorder
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.example.phonerecorder.crypto.KeyManager
|
||||
import com.example.phonerecorder.sync.NetworkHelper
|
||||
import com.example.phonerecorder.sync.SyncManager
|
||||
import com.example.phonerecorder.sync.SyncMode
|
||||
import com.example.phonerecorder.sync.SyncSettings
|
||||
import com.example.phonerecorder.sync.SyncStatus
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "phone_recorder_prefs"
|
||||
private const val PREF_AUTO_START = "auto_start_enabled"
|
||||
}
|
||||
|
||||
private lateinit var statusIndicator: android.view.View
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var elapsedTimeText: TextView
|
||||
private lateinit var currentFileText: TextView
|
||||
private lateinit var storageLocationText: TextView
|
||||
private lateinit var storageInfoText: TextView
|
||||
private lateinit var batteryInfoText: TextView
|
||||
private lateinit var toggleButton: MaterialButton
|
||||
private lateinit var autoStartSwitch: SwitchMaterial
|
||||
private lateinit var batteryOptimizationHint: TextView
|
||||
private lateinit var syncModeSpinner: Spinner
|
||||
private lateinit var keyStatusText: TextView
|
||||
private lateinit var importKeyButton: MaterialButton
|
||||
private lateinit var syncStatusText: TextView
|
||||
private lateinit var networkInfoText: TextView
|
||||
private lateinit var configureLanButton: MaterialButton
|
||||
private lateinit var versionText: TextView
|
||||
|
||||
private lateinit var storageHelper: StorageHelper
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private lateinit var keyManager: KeyManager
|
||||
private lateinit var syncManager: SyncManager
|
||||
private lateinit var networkHelper: NetworkHelper
|
||||
private lateinit var syncSettings: SyncSettings
|
||||
|
||||
private var recordingService: RecordingService? = null
|
||||
private var isBound = false
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as RecordingService.RecordingBinder
|
||||
recordingService = binder.getService()
|
||||
isBound = true
|
||||
updateUI()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
recordingService = null
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
|
||||
private val recordingReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
RecordingService.ACTION_RECORDING_STARTED,
|
||||
RecordingService.ACTION_RECORDING_STOPPED,
|
||||
RecordingService.ACTION_RECORDING_UPDATE -> updateUI()
|
||||
RecordingService.ACTION_LOW_STORAGE -> {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
R.string.storage_warning,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
updateUI()
|
||||
updateSyncStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val allGranted = permissions.all { it.value }
|
||||
if (allGranted) {
|
||||
startRecordingService()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_required, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
storageHelper = StorageHelper(this)
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
keyManager = KeyManager(this)
|
||||
syncManager = SyncManager.getInstance(this)
|
||||
networkHelper = NetworkHelper(this)
|
||||
syncSettings = SyncSettings(this)
|
||||
|
||||
initViews()
|
||||
setupListeners()
|
||||
setupSyncUI()
|
||||
checkBatteryOptimization()
|
||||
|
||||
// Start periodic sync based on settings
|
||||
syncManager.startPeriodicSync()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
bindRecordingService()
|
||||
registerReceivers()
|
||||
updateUI()
|
||||
updateNetworkInfo()
|
||||
updateSyncStatus()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
unregisterReceivers()
|
||||
unbindRecordingService()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
statusIndicator = findViewById(R.id.statusIndicator)
|
||||
statusText = findViewById(R.id.statusText)
|
||||
elapsedTimeText = findViewById(R.id.elapsedTimeText)
|
||||
currentFileText = findViewById(R.id.currentFileText)
|
||||
storageLocationText = findViewById(R.id.storageLocationText)
|
||||
storageInfoText = findViewById(R.id.storageInfoText)
|
||||
batteryInfoText = findViewById(R.id.batteryInfoText)
|
||||
toggleButton = findViewById(R.id.toggleButton)
|
||||
autoStartSwitch = findViewById(R.id.autoStartSwitch)
|
||||
batteryOptimizationHint = findViewById(R.id.batteryOptimizationHint)
|
||||
|
||||
// Initialize auto-start switch state
|
||||
autoStartSwitch.isChecked = prefs.getBoolean(PREF_AUTO_START, false)
|
||||
updateAutoStartText()
|
||||
|
||||
// Sync UI elements
|
||||
syncModeSpinner = findViewById(R.id.syncModeSpinner)
|
||||
keyStatusText = findViewById(R.id.keyStatusText)
|
||||
importKeyButton = findViewById(R.id.importKeyButton)
|
||||
syncStatusText = findViewById(R.id.syncStatusText)
|
||||
networkInfoText = findViewById(R.id.networkInfoText)
|
||||
configureLanButton = findViewById(R.id.configureLanButton)
|
||||
versionText = findViewById(R.id.versionText)
|
||||
|
||||
// Set version info
|
||||
val buildDate = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US)
|
||||
.format(java.util.Date(BuildConfig.BUILD_TIMESTAMP))
|
||||
versionText.text = "v${BuildConfig.VERSION_NAME} (build $buildDate)"
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
toggleButton.setOnClickListener {
|
||||
if (recordingService?.isRecording() == true) {
|
||||
stopRecordingService()
|
||||
} else {
|
||||
checkPermissionsAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
autoStartSwitch.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean(PREF_AUTO_START, isChecked).apply()
|
||||
updateAutoStartText()
|
||||
}
|
||||
|
||||
batteryOptimizationHint.setOnClickListener {
|
||||
openBatteryOptimizationSettings()
|
||||
}
|
||||
|
||||
importKeyButton.setOnClickListener {
|
||||
showImportKeyDialog()
|
||||
}
|
||||
|
||||
configureLanButton.setOnClickListener {
|
||||
showLanConfigDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSyncUI() {
|
||||
// Setup sync mode spinner
|
||||
val syncModes = arrayOf(
|
||||
getString(R.string.sync_mode_never),
|
||||
getString(R.string.sync_mode_lan_only),
|
||||
getString(R.string.sync_mode_wifi_only),
|
||||
getString(R.string.sync_mode_any_network)
|
||||
)
|
||||
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, syncModes)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
syncModeSpinner.adapter = adapter
|
||||
|
||||
// Set current selection
|
||||
syncModeSpinner.setSelection(syncManager.getSyncMode().ordinal)
|
||||
|
||||
// Handle selection changes
|
||||
syncModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val mode = SyncMode.entries[position]
|
||||
syncManager.setSyncMode(mode)
|
||||
updateSyncStatus()
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
|
||||
updateKeyStatus()
|
||||
updateSyncStatus()
|
||||
updateNetworkInfo()
|
||||
}
|
||||
|
||||
private fun updateNetworkInfo() {
|
||||
val ipAddress = networkHelper.getCurrentIpAddress()
|
||||
networkInfoText.text = when {
|
||||
ipAddress != null && networkHelper.isOnWifi() ->
|
||||
getString(R.string.network_wifi, ipAddress)
|
||||
networkHelper.isNetworkAvailable() ->
|
||||
getString(R.string.network_mobile)
|
||||
else ->
|
||||
getString(R.string.network_not_connected)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLanConfigDialog() {
|
||||
val currentIp = networkHelper.getCurrentIpAddress() ?: "Not connected"
|
||||
val currentServerUrl = syncSettings.lanServerUrl
|
||||
val currentPrefix = syncSettings.lanSubnetPrefix
|
||||
val currentApiKey = syncSettings.apiKey
|
||||
|
||||
// Create a layout with fields
|
||||
val layout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(48, 24, 48, 0)
|
||||
}
|
||||
|
||||
val serverLabel = TextView(this).apply {
|
||||
text = "LAN Server URL:"
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||
}
|
||||
layout.addView(serverLabel)
|
||||
|
||||
val serverEdit = EditText(this).apply {
|
||||
hint = "http://10.0.0.45:8000"
|
||||
setText(currentServerUrl)
|
||||
isSingleLine = true
|
||||
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
|
||||
}
|
||||
layout.addView(serverEdit)
|
||||
|
||||
val prefixLabel = TextView(this).apply {
|
||||
text = "\nLAN Subnet Prefix:"
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||
}
|
||||
layout.addView(prefixLabel)
|
||||
|
||||
val prefixEdit = EditText(this).apply {
|
||||
hint = getString(R.string.lan_subnet_hint)
|
||||
setText(currentPrefix)
|
||||
isSingleLine = true
|
||||
}
|
||||
layout.addView(prefixEdit)
|
||||
|
||||
val apiKeyLabel = TextView(this).apply {
|
||||
text = "\nAPI Key (for upload auth):"
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||
}
|
||||
layout.addView(apiKeyLabel)
|
||||
|
||||
val apiKeyEdit = EditText(this).apply {
|
||||
hint = "Leave empty if not required"
|
||||
setText(currentApiKey)
|
||||
isSingleLine = true
|
||||
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
layout.addView(apiKeyEdit)
|
||||
|
||||
val infoText = TextView(this).apply {
|
||||
text = "\nYour IP: $currentIp"
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.darker_gray))
|
||||
textSize = 12f
|
||||
}
|
||||
layout.addView(infoText)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.lan_config_title)
|
||||
.setView(layout)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
// Save server URL
|
||||
val serverUrl = serverEdit.text.toString().trim()
|
||||
if (serverUrl.isNotEmpty()) {
|
||||
syncSettings.lanServerUrl = serverUrl
|
||||
}
|
||||
|
||||
// Save subnet prefix
|
||||
var prefix = prefixEdit.text.toString().trim()
|
||||
if (prefix.isNotEmpty() && !prefix.endsWith(".")) {
|
||||
prefix = "$prefix."
|
||||
}
|
||||
if (prefix.isNotEmpty()) {
|
||||
syncSettings.lanSubnetPrefix = prefix
|
||||
}
|
||||
|
||||
// Save API key
|
||||
syncSettings.apiKey = apiKeyEdit.text.toString().trim()
|
||||
|
||||
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
|
||||
updateNetworkInfo()
|
||||
updateSyncStatus()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showImportKeyDialog() {
|
||||
val editText = EditText(this).apply {
|
||||
hint = getString(R.string.import_key_hint)
|
||||
isSingleLine = false
|
||||
minLines = 2
|
||||
maxLines = 4
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.import_key_title)
|
||||
.setView(editText)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val key = editText.text.toString().trim()
|
||||
if (keyManager.setPublicKey(key)) {
|
||||
Toast.makeText(this, R.string.import_key_success, Toast.LENGTH_SHORT).show()
|
||||
updateKeyStatus()
|
||||
updateSyncStatus()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.import_key_error, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun updateKeyStatus() {
|
||||
keyStatusText.text = if (keyManager.hasPublicKey()) {
|
||||
getString(R.string.key_status_configured)
|
||||
} else {
|
||||
getString(R.string.key_status_not_configured)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSyncStatus() {
|
||||
val status = syncManager.getSyncStatus(keyManager.hasPublicKey())
|
||||
syncStatusText.text = when (status) {
|
||||
is SyncStatus.Disabled -> getString(R.string.sync_status_disabled)
|
||||
is SyncStatus.NoKey -> getString(R.string.sync_status_no_key)
|
||||
is SyncStatus.NotOnWifi -> getString(R.string.sync_status_not_on_wifi)
|
||||
is SyncStatus.NotOnLan -> getString(R.string.sync_status_not_on_lan)
|
||||
is SyncStatus.NoNetwork -> getString(R.string.sync_status_no_network)
|
||||
is SyncStatus.Ready -> getString(R.string.sync_status_pending, status.pending)
|
||||
is SyncStatus.Synced -> getString(R.string.sync_status_synced)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAutoStartText() {
|
||||
autoStartSwitch.text = if (autoStartSwitch.isChecked) {
|
||||
getString(R.string.auto_start_enabled)
|
||||
} else {
|
||||
getString(R.string.auto_start_disabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPermissionsAndStart() {
|
||||
val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
val notGranted = permissions.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (notGranted.isEmpty()) {
|
||||
startRecordingService()
|
||||
} else {
|
||||
permissionLauncher.launch(notGranted.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecordingService() {
|
||||
val intent = Intent(this, RecordingService::class.java)
|
||||
ContextCompat.startForegroundService(this, intent)
|
||||
bindRecordingService()
|
||||
}
|
||||
|
||||
private fun stopRecordingService() {
|
||||
recordingService?.stopRecording()
|
||||
stopService(Intent(this, RecordingService::class.java))
|
||||
}
|
||||
|
||||
private fun bindRecordingService() {
|
||||
val intent = Intent(this, RecordingService::class.java)
|
||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
private fun unbindRecordingService() {
|
||||
if (isBound) {
|
||||
unbindService(serviceConnection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerReceivers() {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(RecordingService.ACTION_RECORDING_STARTED)
|
||||
addAction(RecordingService.ACTION_RECORDING_STOPPED)
|
||||
addAction(RecordingService.ACTION_RECORDING_UPDATE)
|
||||
addAction(RecordingService.ACTION_LOW_STORAGE)
|
||||
}
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(recordingReceiver, filter)
|
||||
}
|
||||
|
||||
private fun unregisterReceivers() {
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(recordingReceiver)
|
||||
}
|
||||
|
||||
private fun updateUI() {
|
||||
val isRecording = recordingService?.isRecording() == true
|
||||
|
||||
// Status indicator
|
||||
statusIndicator.setBackgroundResource(
|
||||
if (isRecording) R.drawable.status_circle_active else R.drawable.status_circle
|
||||
)
|
||||
statusText.text = getString(
|
||||
if (isRecording) R.string.status_recording else R.string.status_stopped
|
||||
)
|
||||
|
||||
// Elapsed time
|
||||
val elapsed = recordingService?.getElapsedTime() ?: 0L
|
||||
elapsedTimeText.text = formatElapsedTime(elapsed)
|
||||
|
||||
// Current file
|
||||
val filename = recordingService?.getCurrentFilename()
|
||||
currentFileText.text = if (filename != null) {
|
||||
getString(R.string.current_file, filename)
|
||||
} else {
|
||||
getString(R.string.current_file, getString(R.string.no_file))
|
||||
}
|
||||
|
||||
// Storage info
|
||||
val storageInfo = storageHelper.getStorageInfo()
|
||||
storageLocationText.text = getString(
|
||||
if (storageInfo.isSDCard) R.string.storage_location_sd else R.string.storage_location_internal
|
||||
)
|
||||
storageInfoText.text = getString(
|
||||
R.string.storage_info,
|
||||
storageHelper.formatBytes(storageInfo.usedBytes),
|
||||
storageHelper.formatBytes(storageInfo.availableBytes)
|
||||
)
|
||||
|
||||
// Battery info
|
||||
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||
val batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
batteryInfoText.text = getString(R.string.battery_info, batteryLevel)
|
||||
|
||||
// Toggle button
|
||||
toggleButton.text = getString(
|
||||
if (isRecording) R.string.stop_recording else R.string.start_recording
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatElapsedTime(millis: Long): String {
|
||||
val seconds = (millis / 1000) % 60
|
||||
val minutes = (millis / (1000 * 60)) % 60
|
||||
val hours = millis / (1000 * 60 * 60)
|
||||
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
|
||||
showBatteryOptimizationDialog()
|
||||
} else {
|
||||
batteryOptimizationHint.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBatteryOptimizationDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.battery_optimization_title)
|
||||
.setMessage(R.string.battery_optimization_message)
|
||||
.setPositiveButton(R.string.battery_optimization_button) { _, _ ->
|
||||
openBatteryOptimizationSettings()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openBatteryOptimizationSettings() {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Fall back to general battery settings
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
package com.example.phonerecorder
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.example.phonerecorder.crypto.CryptoHelper
|
||||
import com.example.phonerecorder.crypto.KeyManager
|
||||
import com.example.phonerecorder.sync.SyncManager
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class RecordingService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RecordingService"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val CHANNEL_ID = "recording_channel"
|
||||
|
||||
// Segment rotation: 2 hours in milliseconds
|
||||
private const val SEGMENT_DURATION_MS = 2 * 60 * 60 * 1000L
|
||||
|
||||
// Storage check interval: 5 minutes
|
||||
private const val STORAGE_CHECK_INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
// Notification update interval: 1 second
|
||||
private const val NOTIFICATION_UPDATE_INTERVAL_MS = 1000L
|
||||
|
||||
// Broadcast actions
|
||||
const val ACTION_RECORDING_STARTED = "com.example.phonerecorder.RECORDING_STARTED"
|
||||
const val ACTION_RECORDING_STOPPED = "com.example.phonerecorder.RECORDING_STOPPED"
|
||||
const val ACTION_RECORDING_UPDATE = "com.example.phonerecorder.RECORDING_UPDATE"
|
||||
const val ACTION_LOW_STORAGE = "com.example.phonerecorder.LOW_STORAGE"
|
||||
|
||||
// Intent extras
|
||||
const val EXTRA_FILENAME = "filename"
|
||||
const val EXTRA_ELAPSED_TIME = "elapsed_time"
|
||||
}
|
||||
|
||||
private val binder = RecordingBinder()
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private lateinit var storageHelper: StorageHelper
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private lateinit var keyManager: KeyManager
|
||||
private lateinit var cryptoHelper: CryptoHelper
|
||||
private var syncManager: SyncManager? = null
|
||||
|
||||
private var isRecording = false
|
||||
private var currentTempFile: File? = null
|
||||
private var currentFile: File? = null
|
||||
private var segmentStartTime: Long = 0
|
||||
private var totalElapsedTime: Long = 0
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val segmentRotationRunnable = Runnable { rotateSegment() }
|
||||
private val storageCheckRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
checkStorage()
|
||||
if (isRecording) {
|
||||
handler.postDelayed(this, STORAGE_CHECK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val notificationUpdateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
updateNotification()
|
||||
broadcastUpdate()
|
||||
if (isRecording) {
|
||||
handler.postDelayed(this, NOTIFICATION_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class RecordingBinder : Binder() {
|
||||
fun getService(): RecordingService = this@RecordingService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
storageHelper = StorageHelper(this)
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
keyManager = KeyManager(this)
|
||||
cryptoHelper = CryptoHelper(keyManager)
|
||||
syncManager = SyncManager.getInstance(this)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!isRecording) {
|
||||
startRecording()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopRecording()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun isRecording(): Boolean = isRecording
|
||||
|
||||
fun getCurrentFilename(): String? = currentFile?.name
|
||||
|
||||
fun getElapsedTime(): Long {
|
||||
return if (isRecording) {
|
||||
totalElapsedTime + (System.currentTimeMillis() - segmentStartTime)
|
||||
} else {
|
||||
totalElapsedTime
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
if (isRecording) return
|
||||
|
||||
if (storageHelper.isStorageLow()) {
|
||||
broadcastLowStorage()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
acquireWakeLock()
|
||||
startNewSegment()
|
||||
isRecording = true
|
||||
totalElapsedTime = 0
|
||||
|
||||
// Schedule segment rotation
|
||||
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
|
||||
|
||||
// Start storage checks
|
||||
handler.postDelayed(storageCheckRunnable, STORAGE_CHECK_INTERVAL_MS)
|
||||
|
||||
// Start notification updates
|
||||
handler.post(notificationUpdateRunnable)
|
||||
|
||||
// Start foreground service
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
broadcastRecordingStarted()
|
||||
Log.i(TAG, "Recording started: ${currentFile?.name}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording", e)
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
if (!isRecording) return
|
||||
|
||||
isRecording = false
|
||||
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
|
||||
|
||||
handler.removeCallbacks(segmentRotationRunnable)
|
||||
handler.removeCallbacks(storageCheckRunnable)
|
||||
handler.removeCallbacks(notificationUpdateRunnable)
|
||||
|
||||
releaseMediaRecorder()
|
||||
releaseWakeLock()
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
broadcastRecordingStopped()
|
||||
|
||||
Log.i(TAG, "Recording stopped")
|
||||
}
|
||||
|
||||
private fun startNewSegment() {
|
||||
releaseMediaRecorder()
|
||||
|
||||
// Record to temp file first (will be encrypted after recording stops)
|
||||
currentTempFile = storageHelper.getNewTempRecordingFile()
|
||||
segmentStartTime = System.currentTimeMillis()
|
||||
|
||||
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
mediaRecorder?.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.OGG)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||
setAudioEncodingBitRate(64000)
|
||||
setAudioSamplingRate(48000)
|
||||
setAudioChannels(1)
|
||||
setOutputFile(currentTempFile?.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
|
||||
// Update currentFile to show the expected encrypted filename
|
||||
currentFile = currentTempFile?.let { storageHelper.getEncryptedFileForTemp(it) }
|
||||
|
||||
Log.i(TAG, "New segment started: ${currentTempFile?.name}")
|
||||
}
|
||||
|
||||
private fun rotateSegment() {
|
||||
if (!isRecording) return
|
||||
|
||||
Log.i(TAG, "Rotating segment...")
|
||||
totalElapsedTime += System.currentTimeMillis() - segmentStartTime
|
||||
|
||||
try {
|
||||
startNewSegment()
|
||||
handler.postDelayed(segmentRotationRunnable, SEGMENT_DURATION_MS)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to rotate segment", e)
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkStorage() {
|
||||
if (storageHelper.isStorageLow()) {
|
||||
Log.w(TAG, "Low storage detected, stopping recording")
|
||||
stopRecording()
|
||||
broadcastLowStorage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseMediaRecorder() {
|
||||
val tempFile = currentTempFile
|
||||
try {
|
||||
mediaRecorder?.apply {
|
||||
stop()
|
||||
release()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error releasing MediaRecorder", e)
|
||||
}
|
||||
mediaRecorder = null
|
||||
|
||||
// Encrypt the completed recording
|
||||
if (tempFile != null && tempFile.exists()) {
|
||||
encryptCompletedRecording(tempFile)
|
||||
}
|
||||
currentTempFile = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a completed recording and deletes the temp file.
|
||||
* If no public key is available, the recording is NOT stored (security requirement).
|
||||
*/
|
||||
private fun encryptCompletedRecording(tempFile: File) {
|
||||
if (!cryptoHelper.hasPublicKey()) {
|
||||
Log.w(TAG, "No public key configured - deleting unencrypted recording for security")
|
||||
tempFile.delete()
|
||||
return
|
||||
}
|
||||
|
||||
val encryptedFile = storageHelper.getEncryptedFileForTemp(tempFile)
|
||||
|
||||
try {
|
||||
val success = cryptoHelper.encryptFile(tempFile, encryptedFile)
|
||||
if (success) {
|
||||
// Delete temp file after successful encryption
|
||||
tempFile.delete()
|
||||
currentFile = encryptedFile
|
||||
Log.i(TAG, "Recording encrypted: ${encryptedFile.name}")
|
||||
|
||||
// Trigger immediate sync
|
||||
syncManager?.scheduleImmediateSync()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to encrypt recording - deleting temp file for security")
|
||||
tempFile.delete()
|
||||
encryptedFile.delete() // Clean up partial encrypted file
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during encryption - deleting files for security", e)
|
||||
tempFile.delete()
|
||||
encryptedFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"PhoneRecorder::RecordingWakeLock"
|
||||
).apply {
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.notification_channel_description)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val elapsedFormatted = formatElapsedTime(getElapsedTime())
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.notification_title))
|
||||
.setContentText(getString(R.string.notification_text, elapsedFormatted))
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
if (isRecording) {
|
||||
notificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatElapsedTime(millis: Long): String {
|
||||
val seconds = (millis / 1000) % 60
|
||||
val minutes = (millis / (1000 * 60)) % 60
|
||||
val hours = millis / (1000 * 60 * 60)
|
||||
return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
private fun broadcastRecordingStarted() {
|
||||
val intent = Intent(ACTION_RECORDING_STARTED).apply {
|
||||
putExtra(EXTRA_FILENAME, currentFile?.name)
|
||||
}
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun broadcastRecordingStopped() {
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_RECORDING_STOPPED))
|
||||
}
|
||||
|
||||
private fun broadcastUpdate() {
|
||||
val intent = Intent(ACTION_RECORDING_UPDATE).apply {
|
||||
putExtra(EXTRA_FILENAME, currentFile?.name)
|
||||
putExtra(EXTRA_ELAPSED_TIME, getElapsedTime())
|
||||
}
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun broadcastLowStorage() {
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(ACTION_LOW_STORAGE))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.example.phonerecorder
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class StorageHelper(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val RECORDINGS_FOLDER = "recordings"
|
||||
private const val TEMP_FOLDER = "temp"
|
||||
private const val LOW_STORAGE_THRESHOLD_MB = 500L
|
||||
private const val BYTES_PER_MB = 1024L * 1024L
|
||||
private const val BYTES_PER_GB = 1024L * 1024L * 1024L
|
||||
}
|
||||
|
||||
data class StorageInfo(
|
||||
val usedBytes: Long,
|
||||
val availableBytes: Long,
|
||||
// this should be a storage type, not an isSDCard
|
||||
val isSDCard: Boolean,
|
||||
val recordingsDir: File
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the best available storage location.
|
||||
* Prefers SD card, falls back to internal storage.
|
||||
*/
|
||||
fun getRecordingsDirectory(): File {
|
||||
val externalDirs = context.getExternalFilesDirs(null)
|
||||
|
||||
// Find SD card (second external storage, if available)
|
||||
val sdCardDir = externalDirs.getOrNull(1)
|
||||
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
|
||||
return ensureRecordingsFolder(sdCardDir)
|
||||
}
|
||||
|
||||
// Fall back to primary external storage
|
||||
val primaryDir = externalDirs.getOrNull(0)
|
||||
if (primaryDir != null && isStorageAvailable(primaryDir)) {
|
||||
return ensureRecordingsFolder(primaryDir)
|
||||
}
|
||||
|
||||
// Last resort: internal files directory
|
||||
return ensureRecordingsFolder(context.filesDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're using SD card storage.
|
||||
*/
|
||||
fun isUsingSDCard(): Boolean {
|
||||
val externalDirs = context.getExternalFilesDirs(null)
|
||||
val sdCardDir = externalDirs.getOrNull(1)
|
||||
return sdCardDir != null && isStorageAvailable(sdCardDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets storage information for the recordings directory.
|
||||
*/
|
||||
fun getStorageInfo(): StorageInfo {
|
||||
val recordingsDir = getRecordingsDirectory()
|
||||
val parentDir = recordingsDir.parentFile ?: recordingsDir
|
||||
|
||||
val statFs = StatFs(parentDir.absolutePath)
|
||||
val availableBytes = statFs.availableBytes
|
||||
val usedBytes = calculateUsedStorage(recordingsDir)
|
||||
|
||||
return StorageInfo(
|
||||
usedBytes = usedBytes,
|
||||
availableBytes = availableBytes,
|
||||
isSDCard = isUsingSDCard(),
|
||||
recordingsDir = recordingsDir
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if storage is below the low threshold.
|
||||
*/
|
||||
fun isStorageLow(): Boolean {
|
||||
val info = getStorageInfo()
|
||||
return info.availableBytes < LOW_STORAGE_THRESHOLD_MB * BYTES_PER_MB
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the temp directory for unencrypted recordings during recording.
|
||||
*/
|
||||
fun getTempDirectory(): File {
|
||||
val baseDir = getBaseStorageDirectory()
|
||||
return ensureFolder(baseDir, TEMP_FOLDER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base storage directory (parent of recordings and temp).
|
||||
*/
|
||||
private fun getBaseStorageDirectory(): File {
|
||||
val externalDirs = context.getExternalFilesDirs(null)
|
||||
|
||||
// Find SD card (second external storage, if available)
|
||||
val sdCardDir = externalDirs.getOrNull(1)
|
||||
if (sdCardDir != null && isStorageAvailable(sdCardDir)) {
|
||||
return sdCardDir
|
||||
}
|
||||
|
||||
// Fall back to primary external storage
|
||||
val primaryDir = externalDirs.getOrNull(0)
|
||||
if (primaryDir != null && isStorageAvailable(primaryDir)) {
|
||||
return primaryDir
|
||||
}
|
||||
|
||||
// Last resort: internal files directory
|
||||
return context.filesDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new recording filename with current timestamp (for encrypted files).
|
||||
*/
|
||||
fun generateFilename(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||
val timestamp = dateFormat.format(Date())
|
||||
return "recording_$timestamp.ogg.enc"
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a temp filename (unencrypted, used during recording).
|
||||
*/
|
||||
fun generateTempFilename(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||
val timestamp = dateFormat.format(Date())
|
||||
return "recording_$timestamp.ogg"
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the full path for a new temp recording file (unencrypted during recording).
|
||||
*/
|
||||
fun getNewTempRecordingFile(): File {
|
||||
val dir = getTempDirectory()
|
||||
return File(dir, generateTempFilename())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the full path for a new encrypted recording file.
|
||||
*/
|
||||
fun getNewRecordingFile(): File {
|
||||
val dir = getRecordingsDirectory()
|
||||
return File(dir, generateFilename())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the encrypted file path corresponding to a temp file.
|
||||
*/
|
||||
fun getEncryptedFileForTemp(tempFile: File): File {
|
||||
val dir = getRecordingsDirectory()
|
||||
val encryptedName = tempFile.name + ".enc"
|
||||
return File(dir, encryptedName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all unsynced encrypted recording files.
|
||||
*/
|
||||
fun listUnsyncedRecordings(syncedFiles: Set<String>): List<File> {
|
||||
val dir = getRecordingsDirectory()
|
||||
return dir.listFiles { file -> file.name.endsWith(".ogg.enc") }
|
||||
?.filter { !syncedFiles.contains(it.name) }
|
||||
?.sortedBy { it.lastModified() }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats bytes as human-readable string (e.g., "2.5 GB").
|
||||
*/
|
||||
fun formatBytes(bytes: Long): String {
|
||||
return when {
|
||||
bytes >= BYTES_PER_GB -> String.format(Locale.US, "%.1f GB", bytes.toDouble() / BYTES_PER_GB)
|
||||
bytes >= BYTES_PER_MB -> String.format(Locale.US, "%.1f MB", bytes.toDouble() / BYTES_PER_MB)
|
||||
else -> String.format(Locale.US, "%d KB", bytes / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all recording files sorted by date (newest first).
|
||||
* Includes both encrypted (.ogg.enc) and unencrypted (.ogg) files.
|
||||
*/
|
||||
fun listRecordings(): List<File> {
|
||||
val dir = getRecordingsDirectory()
|
||||
return dir.listFiles { file ->
|
||||
file.name.endsWith(".ogg") || file.name.endsWith(".ogg.enc")
|
||||
}
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
private fun isStorageAvailable(dir: File): Boolean {
|
||||
return try {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
dir.canWrite()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureRecordingsFolder(baseDir: File): File {
|
||||
return ensureFolder(baseDir, RECORDINGS_FOLDER)
|
||||
}
|
||||
|
||||
private fun ensureFolder(baseDir: File, folderName: String): File {
|
||||
val dir = File(baseDir, folderName)
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private fun calculateUsedStorage(dir: File): Long {
|
||||
if (!dir.exists()) return 0L
|
||||
return dir.listFiles()?.sumOf { it.length() } ?: 0L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
package com.example.phonerecorder.api
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RecordingsApi {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RecordingsApi"
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 30L
|
||||
private const val READ_TIMEOUT_SECONDS = 120L
|
||||
private const val WRITE_TIMEOUT_SECONDS = 120L
|
||||
}
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Checks if the server is healthy and reachable.
|
||||
*
|
||||
* @param serverUrl The base server URL
|
||||
* @return true if server responds with status "ok"
|
||||
*/
|
||||
fun checkHealth(serverUrl: String): Boolean {
|
||||
val request = Request.Builder()
|
||||
.url("$serverUrl/health")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
val json = JSONObject(body ?: "{}")
|
||||
json.optString("status") == "ok"
|
||||
} else {
|
||||
Log.w(TAG, "Health check failed: ${response.code}")
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Health check error", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the server's public key for encryption.
|
||||
*
|
||||
* @param serverUrl The base server URL
|
||||
* @return Base64-encoded public key, or null on failure
|
||||
*/
|
||||
fun getPublicKey(serverUrl: String): String? {
|
||||
val request = Request.Builder()
|
||||
.url("$serverUrl/api/v1/public-key")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
val json = JSONObject(body ?: "{}")
|
||||
json.optString("public_key", null)
|
||||
} else {
|
||||
Log.w(TAG, "Get public key failed: ${response.code}")
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Get public key error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an encrypted recording file to the server.
|
||||
* Verifies the server received the correct data by comparing SHA-256 hashes.
|
||||
*
|
||||
* @param serverUrl The base server URL
|
||||
* @param file The encrypted file to upload
|
||||
* @param apiKey Optional API key for authentication
|
||||
* @return UploadResult indicating success or failure with message
|
||||
*/
|
||||
fun uploadRecording(serverUrl: String, file: File, apiKey: String? = null): UploadResult {
|
||||
// Compute hash of file before upload for verification
|
||||
val localHash = computeFileHash(file)
|
||||
if (localHash == null) {
|
||||
Log.e(TAG, "Failed to compute hash for: ${file.name}")
|
||||
return UploadResult(false, "Failed to compute file hash")
|
||||
}
|
||||
|
||||
val mediaType = "application/octet-stream".toMediaType()
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"file",
|
||||
file.name,
|
||||
file.asRequestBody(mediaType)
|
||||
)
|
||||
.build()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$serverUrl/api/v1/recordings/upload")
|
||||
.post(requestBody)
|
||||
|
||||
if (!apiKey.isNullOrEmpty()) {
|
||||
requestBuilder.addHeader("X-API-Key", apiKey)
|
||||
}
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string()
|
||||
if (response.isSuccessful) {
|
||||
// Verify server received correct data
|
||||
val json = JSONObject(body ?: "{}")
|
||||
val serverHash = json.optString("received_hash", "")
|
||||
val verified = json.optBoolean("verified", false)
|
||||
|
||||
if (serverHash.isNotEmpty() && serverHash == localHash && verified) {
|
||||
Log.i(TAG, "Upload verified: ${file.name} (hash: ${localHash.take(8)}...)")
|
||||
UploadResult(true, "Upload verified", verified = true)
|
||||
} else if (serverHash.isEmpty()) {
|
||||
// Legacy server without hash verification
|
||||
Log.w(TAG, "Upload successful but unverified (no hash): ${file.name}")
|
||||
UploadResult(true, "Upload successful (unverified)", verified = false)
|
||||
} else {
|
||||
Log.e(TAG, "Hash mismatch! Local: $localHash, Server: $serverHash")
|
||||
UploadResult(false, "Data corruption detected - hash mismatch")
|
||||
}
|
||||
} else {
|
||||
val errorMessage = try {
|
||||
val json = JSONObject(body ?: "{}")
|
||||
json.optString("detail", "Upload failed: ${response.code}")
|
||||
} catch (e: Exception) {
|
||||
"Upload failed: ${response.code}"
|
||||
}
|
||||
Log.w(TAG, "Upload failed: $errorMessage")
|
||||
UploadResult(false, errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Upload error", e)
|
||||
UploadResult(false, "Network error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes SHA-256 hash of a file.
|
||||
*/
|
||||
private fun computeFileHash(file: File): String? {
|
||||
return try {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).use { fis ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (fis.read(buffer).also { bytesRead = it } != -1) {
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
digest.digest().joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Hash computation failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists recordings on the server, optionally filtered by date.
|
||||
*
|
||||
* @param serverUrl The base server URL
|
||||
* @param date Optional date filter in YYYY-MM-DD format
|
||||
* @return List of recording filenames, or null on failure
|
||||
*/
|
||||
fun listRecordings(serverUrl: String, date: String? = null): List<String>? {
|
||||
val url = if (date != null) {
|
||||
"$serverUrl/api/v1/recordings?date=$date"
|
||||
} else {
|
||||
"$serverUrl/api/v1/recordings"
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
val json = JSONObject(body ?: "{}")
|
||||
val recordings = json.optJSONArray("recordings")
|
||||
if (recordings != null) {
|
||||
(0 until recordings.length()).map { recordings.getString(it) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "List recordings failed: ${response.code}")
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "List recordings error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class UploadResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val verified: Boolean = false
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.example.phonerecorder.crypto
|
||||
|
||||
import android.util.Log
|
||||
import com.goterl.lazysodium.LazySodiumAndroid
|
||||
import com.goterl.lazysodium.SodiumAndroid
|
||||
import com.goterl.lazysodium.interfaces.Box
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
class CryptoHelper(private val keyManager: KeyManager) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CryptoHelper"
|
||||
|
||||
// File format constants
|
||||
private val MAGIC_BYTES = byteArrayOf('S'.code.toByte(), 'R'.code.toByte(), 'E'.code.toByte(), 'C'.code.toByte())
|
||||
private const val VERSION: Byte = 1
|
||||
private const val CHUNK_SIZE = 64 * 1024 // 64KB chunks for streaming encryption
|
||||
}
|
||||
|
||||
private val lazySodium: LazySodiumAndroid = LazySodiumAndroid(SodiumAndroid())
|
||||
|
||||
/**
|
||||
* Encrypts a file using libsodium sealed box encryption.
|
||||
*
|
||||
* File format:
|
||||
* [4 bytes: "SREC" magic]
|
||||
* [1 byte: version (1)]
|
||||
* [4 bytes: chunk count (big-endian)]
|
||||
* For each chunk:
|
||||
* [4 bytes: encrypted length (big-endian)]
|
||||
* [N bytes: sealed box encrypted data]
|
||||
*
|
||||
* @param inputFile The unencrypted source file
|
||||
* @param outputFile The destination for the encrypted file
|
||||
* @return true if encryption succeeded, false otherwise
|
||||
*/
|
||||
fun encryptFile(inputFile: File, outputFile: File): Boolean {
|
||||
val publicKey = keyManager.getPublicKey()
|
||||
if (publicKey == null) {
|
||||
Log.e(TAG, "No public key available for encryption")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
FileInputStream(inputFile).use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
// Calculate number of chunks
|
||||
val fileSize = inputFile.length()
|
||||
val chunkCount = ((fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
|
||||
|
||||
// Write header
|
||||
output.write(MAGIC_BYTES)
|
||||
output.write(VERSION.toInt())
|
||||
output.write(intToBytes(chunkCount))
|
||||
|
||||
// Encrypt and write each chunk
|
||||
val buffer = ByteArray(CHUNK_SIZE)
|
||||
var bytesRead: Int
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
val chunk = if (bytesRead < CHUNK_SIZE) {
|
||||
buffer.copyOf(bytesRead)
|
||||
} else {
|
||||
buffer
|
||||
}
|
||||
|
||||
val encrypted = encryptChunk(chunk, publicKey)
|
||||
if (encrypted == null) {
|
||||
Log.e(TAG, "Failed to encrypt chunk")
|
||||
return false
|
||||
}
|
||||
|
||||
// Write encrypted chunk length and data
|
||||
output.write(intToBytes(encrypted.size))
|
||||
output.write(encrypted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Successfully encrypted file: ${inputFile.name} -> ${outputFile.name}")
|
||||
return true
|
||||
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "IO error during encryption", e)
|
||||
return false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unexpected error during encryption", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a single chunk using sealed box encryption.
|
||||
* Sealed box provides anonymous sender encryption using X25519 + XSalsa20-Poly1305.
|
||||
*/
|
||||
private fun encryptChunk(data: ByteArray, publicKey: ByteArray): ByteArray? {
|
||||
return try {
|
||||
// Sealed box overhead: crypto_box_SEALBYTES (48 bytes)
|
||||
val ciphertext = ByteArray(data.size + Box.SEALBYTES)
|
||||
val success = lazySodium.cryptoBoxSeal(ciphertext, data, data.size.toLong(), publicKey)
|
||||
if (success) ciphertext else null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Encryption failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun intToBytes(value: Int): ByteArray {
|
||||
return ByteBuffer.allocate(4)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
.putInt(value)
|
||||
.array()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a public key is available for encryption.
|
||||
*/
|
||||
fun hasPublicKey(): Boolean = keyManager.getPublicKey() != null
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.example.phonerecorder.crypto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.goterl.lazysodium.interfaces.Box
|
||||
|
||||
class KeyManager(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KeyManager"
|
||||
private const val PREFS_NAME = "crypto_prefs"
|
||||
private const val KEY_PUBLIC_KEY = "server_public_key"
|
||||
private const val PUBLIC_KEY_LENGTH = Box.PUBLICKEYBYTES // 32 bytes for X25519
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Stores the server's public key from a Base64-encoded string.
|
||||
*
|
||||
* @param base64Key The Base64-encoded public key
|
||||
* @return true if the key was valid and stored, false otherwise
|
||||
*/
|
||||
fun setPublicKey(base64Key: String): Boolean {
|
||||
return try {
|
||||
val keyBytes = Base64.decode(base64Key.trim(), Base64.DEFAULT)
|
||||
if (keyBytes.size != PUBLIC_KEY_LENGTH) {
|
||||
Log.e(TAG, "Invalid public key length: ${keyBytes.size}, expected $PUBLIC_KEY_LENGTH")
|
||||
return false
|
||||
}
|
||||
prefs.edit().putString(KEY_PUBLIC_KEY, base64Key.trim()).apply()
|
||||
Log.i(TAG, "Server public key stored successfully")
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Invalid Base64 encoding for public key", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the stored server public key as raw bytes.
|
||||
*
|
||||
* @return The public key bytes, or null if not set
|
||||
*/
|
||||
fun getPublicKey(): ByteArray? {
|
||||
val base64Key = prefs.getString(KEY_PUBLIC_KEY, null) ?: return null
|
||||
return try {
|
||||
val keyBytes = Base64.decode(base64Key, Base64.DEFAULT)
|
||||
if (keyBytes.size == PUBLIC_KEY_LENGTH) keyBytes else null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "Failed to decode stored public key", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored public key as a Base64 string for display.
|
||||
*/
|
||||
fun getPublicKeyBase64(): String? {
|
||||
return prefs.getString(KEY_PUBLIC_KEY, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a public key has been configured.
|
||||
*/
|
||||
fun hasPublicKey(): Boolean = getPublicKey() != null
|
||||
|
||||
/**
|
||||
* Clears the stored public key.
|
||||
*/
|
||||
fun clearPublicKey() {
|
||||
prefs.edit().remove(KEY_PUBLIC_KEY).apply()
|
||||
Log.i(TAG, "Server public key cleared")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.example.phonerecorder.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import android.util.Log
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class NetworkHelper(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NetworkHelper"
|
||||
}
|
||||
|
||||
private val connectivityManager: ConnectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
private val syncSettings = SyncSettings(context)
|
||||
|
||||
/**
|
||||
* Checks if any network is available.
|
||||
*/
|
||||
fun isNetworkAvailable(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if currently connected to WiFi.
|
||||
*/
|
||||
fun isOnWifi(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if currently on the configured LAN subnet.
|
||||
* The subnet prefix is configurable in SyncSettings (default: 10.0.0.)
|
||||
*/
|
||||
fun isOnLan(): Boolean {
|
||||
if (!isOnWifi()) return false
|
||||
|
||||
try {
|
||||
val ipAddress = getLocalIpAddress()
|
||||
val lanPrefix = syncSettings.lanSubnetPrefix
|
||||
val isLan = ipAddress?.startsWith(lanPrefix) == true
|
||||
Log.d(TAG, "Local IP: $ipAddress, LAN prefix: $lanPrefix, isOnLan: $isLan")
|
||||
return isLan
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking LAN status", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current local IP address (for display/debugging).
|
||||
*/
|
||||
fun getCurrentIpAddress(): String? = getLocalIpAddress()
|
||||
|
||||
/**
|
||||
* Gets the local IPv4 address.
|
||||
*/
|
||||
private fun getLocalIpAddress(): String? {
|
||||
try {
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val networkInterface = interfaces.nextElement()
|
||||
if (networkInterface.isLoopback || !networkInterface.isUp) continue
|
||||
|
||||
val addresses = networkInterface.inetAddresses
|
||||
while (addresses.hasMoreElements()) {
|
||||
val address = addresses.nextElement()
|
||||
if (address is Inet4Address && !address.isLoopbackAddress) {
|
||||
return address.hostAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting local IP address", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if sync should proceed based on current network state and sync mode.
|
||||
*/
|
||||
fun shouldSync(syncMode: SyncMode): Boolean {
|
||||
return when (syncMode) {
|
||||
SyncMode.NEVER -> false
|
||||
SyncMode.LAN_ONLY -> isOnLan()
|
||||
SyncMode.WIFI_ONLY -> isOnWifi()
|
||||
SyncMode.ANY_NETWORK -> isNetworkAvailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package com.example.phonerecorder.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.example.phonerecorder.StorageHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SyncManager private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncManager"
|
||||
private const val PERIODIC_SYNC_INTERVAL_MINUTES = 15L
|
||||
|
||||
@Volatile
|
||||
private var instance: SyncManager? = null
|
||||
|
||||
fun getInstance(context: Context): SyncManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: SyncManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val syncSettings = SyncSettings(context)
|
||||
private val syncTracker = SyncTracker(context)
|
||||
private val storageHelper = StorageHelper(context)
|
||||
private val networkHelper = NetworkHelper(context)
|
||||
|
||||
/**
|
||||
* Starts or updates the periodic sync schedule based on current settings.
|
||||
*/
|
||||
fun startPeriodicSync() {
|
||||
val syncMode = syncSettings.syncMode
|
||||
if (syncMode == SyncMode.NEVER) {
|
||||
Log.i(TAG, "Sync disabled, cancelling periodic work")
|
||||
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
|
||||
return
|
||||
}
|
||||
|
||||
val constraints = buildConstraints(syncMode)
|
||||
|
||||
val periodicWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
PERIODIC_SYNC_INTERVAL_MINUTES, TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
SyncWorker.WORK_NAME_PERIODIC,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
periodicWorkRequest
|
||||
)
|
||||
|
||||
Log.i(TAG, "Periodic sync scheduled for mode: $syncMode")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the periodic sync.
|
||||
*/
|
||||
fun stopPeriodicSync() {
|
||||
workManager.cancelUniqueWork(SyncWorker.WORK_NAME_PERIODIC)
|
||||
Log.i(TAG, "Periodic sync stopped")
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an immediate sync attempt.
|
||||
*/
|
||||
fun scheduleImmediateSync() {
|
||||
val syncMode = syncSettings.syncMode
|
||||
if (syncMode == SyncMode.NEVER) {
|
||||
Log.i(TAG, "Sync disabled, skipping immediate sync")
|
||||
return
|
||||
}
|
||||
|
||||
val constraints = buildConstraints(syncMode)
|
||||
|
||||
val immediateWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
SyncWorker.WORK_NAME_IMMEDIATE,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
immediateWorkRequest
|
||||
)
|
||||
|
||||
Log.i(TAG, "Immediate sync scheduled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of files pending sync.
|
||||
*/
|
||||
fun getPendingSyncCount(): Int {
|
||||
val syncedFiles = syncTracker.getSyncedFiles()
|
||||
val allFiles = storageHelper.listUnsyncedRecordings(emptySet())
|
||||
return allFiles.count { !syncedFiles.contains(it.name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the detailed sync status based on current conditions.
|
||||
* @param hasKey Whether the server public key is configured
|
||||
*/
|
||||
fun getSyncStatus(hasKey: Boolean): SyncStatus {
|
||||
val syncMode = syncSettings.syncMode
|
||||
|
||||
// Check sync mode first
|
||||
if (syncMode == SyncMode.NEVER) {
|
||||
return SyncStatus.Disabled
|
||||
}
|
||||
|
||||
// Check key
|
||||
if (!hasKey) {
|
||||
return SyncStatus.NoKey
|
||||
}
|
||||
|
||||
// Check network conditions based on mode
|
||||
when (syncMode) {
|
||||
SyncMode.LAN_ONLY -> {
|
||||
if (!networkHelper.isOnWifi()) {
|
||||
return SyncStatus.NotOnWifi
|
||||
}
|
||||
if (!networkHelper.isOnLan()) {
|
||||
return SyncStatus.NotOnLan
|
||||
}
|
||||
}
|
||||
SyncMode.WIFI_ONLY -> {
|
||||
if (!networkHelper.isOnWifi()) {
|
||||
return SyncStatus.NotOnWifi
|
||||
}
|
||||
}
|
||||
SyncMode.ANY_NETWORK -> {
|
||||
if (!networkHelper.isNetworkAvailable()) {
|
||||
return SyncStatus.NoNetwork
|
||||
}
|
||||
}
|
||||
SyncMode.NEVER -> { /* Already handled above */ }
|
||||
}
|
||||
|
||||
// Network conditions are met, check pending count
|
||||
val pending = getPendingSyncCount()
|
||||
return if (pending > 0) {
|
||||
SyncStatus.Ready(pending)
|
||||
} else {
|
||||
SyncStatus.Synced
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current sync mode.
|
||||
*/
|
||||
fun getSyncMode(): SyncMode = syncSettings.syncMode
|
||||
|
||||
/**
|
||||
* Updates the sync mode and restarts periodic sync if needed.
|
||||
*/
|
||||
fun setSyncMode(mode: SyncMode) {
|
||||
syncSettings.syncMode = mode
|
||||
startPeriodicSync()
|
||||
}
|
||||
|
||||
private fun buildConstraints(syncMode: SyncMode): Constraints {
|
||||
val builder = Constraints.Builder()
|
||||
|
||||
when (syncMode) {
|
||||
SyncMode.NEVER -> {
|
||||
// Should not reach here, but set impossible constraint
|
||||
builder.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||
}
|
||||
SyncMode.LAN_ONLY, SyncMode.WIFI_ONLY -> {
|
||||
// Require unmetered (WiFi) connection
|
||||
builder.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
}
|
||||
SyncMode.ANY_NETWORK -> {
|
||||
// Any connected network
|
||||
builder.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package com.example.phonerecorder.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
enum class SyncMode {
|
||||
NEVER, // Never sync
|
||||
LAN_ONLY, // Only sync when on LAN (10.0.0.x subnet)
|
||||
WIFI_ONLY, // Only sync when on WiFi (any network)
|
||||
ANY_NETWORK // Sync on any network (including mobile data)
|
||||
}
|
||||
|
||||
sealed class SyncStatus {
|
||||
object Disabled : SyncStatus()
|
||||
object NoKey : SyncStatus()
|
||||
object NotOnWifi : SyncStatus()
|
||||
object NotOnLan : SyncStatus()
|
||||
object NoNetwork : SyncStatus()
|
||||
data class Ready(val pending: Int) : SyncStatus()
|
||||
object Synced : SyncStatus()
|
||||
}
|
||||
|
||||
class SyncSettings(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "sync_settings"
|
||||
private const val KEY_SYNC_MODE = "sync_mode"
|
||||
private const val KEY_LAN_SERVER_URL = "lan_server_url"
|
||||
private const val KEY_REMOTE_SERVER_URL = "remote_server_url"
|
||||
private const val KEY_LAN_SUBNET_PREFIX = "lan_subnet_prefix"
|
||||
private const val KEY_API_KEY = "api_key"
|
||||
|
||||
const val DEFAULT_LAN_SERVER_URL = "http://10.0.0.45:8000"
|
||||
const val DEFAULT_REMOTE_SERVER_URL = "https://recordings.hallocks.xyz"
|
||||
const val DEFAULT_LAN_SUBNET_PREFIX = "10.0.0." // Change this to match your network
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var syncMode: SyncMode
|
||||
get() {
|
||||
val ordinal = prefs.getInt(KEY_SYNC_MODE, SyncMode.NEVER.ordinal)
|
||||
return SyncMode.entries.getOrElse(ordinal) { SyncMode.NEVER }
|
||||
}
|
||||
set(value) {
|
||||
prefs.edit().putInt(KEY_SYNC_MODE, value.ordinal).apply()
|
||||
}
|
||||
|
||||
var lanServerUrl: String
|
||||
get() = prefs.getString(KEY_LAN_SERVER_URL, DEFAULT_LAN_SERVER_URL) ?: DEFAULT_LAN_SERVER_URL
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_LAN_SERVER_URL, value).apply()
|
||||
}
|
||||
|
||||
var remoteServerUrl: String
|
||||
get() = prefs.getString(KEY_REMOTE_SERVER_URL, DEFAULT_REMOTE_SERVER_URL) ?: DEFAULT_REMOTE_SERVER_URL
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_REMOTE_SERVER_URL, value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The subnet prefix used to detect LAN (e.g., "192.168.1." or "10.0.0.").
|
||||
* Your phone must be on this subnet for LAN mode to work.
|
||||
*/
|
||||
var lanSubnetPrefix: String
|
||||
get() = prefs.getString(KEY_LAN_SUBNET_PREFIX, DEFAULT_LAN_SUBNET_PREFIX) ?: DEFAULT_LAN_SUBNET_PREFIX
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_LAN_SUBNET_PREFIX, value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* API key for upload authentication.
|
||||
*/
|
||||
var apiKey: String
|
||||
get() = prefs.getString(KEY_API_KEY, "") ?: ""
|
||||
set(value) {
|
||||
prefs.edit().putString(KEY_API_KEY, value).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate server URL based on whether we're on LAN.
|
||||
*/
|
||||
fun getServerUrl(isOnLan: Boolean): String {
|
||||
return if (isOnLan) lanServerUrl else remoteServerUrl
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.example.phonerecorder.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class SyncTracker(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "sync_tracker"
|
||||
private const val KEY_SYNCED_FILES = "synced_files"
|
||||
private const val SEPARATOR = "|"
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Returns the set of filenames that have been successfully synced.
|
||||
*/
|
||||
fun getSyncedFiles(): Set<String> {
|
||||
val stored = prefs.getString(KEY_SYNCED_FILES, "") ?: ""
|
||||
return if (stored.isEmpty()) {
|
||||
emptySet()
|
||||
} else {
|
||||
stored.split(SEPARATOR).toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a file as successfully synced.
|
||||
*/
|
||||
fun markSynced(filename: String) {
|
||||
val current = getSyncedFiles().toMutableSet()
|
||||
current.add(filename)
|
||||
saveSyncedFiles(current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks multiple files as successfully synced.
|
||||
*/
|
||||
fun markSynced(filenames: Collection<String>) {
|
||||
val current = getSyncedFiles().toMutableSet()
|
||||
current.addAll(filenames)
|
||||
saveSyncedFiles(current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific file has been synced.
|
||||
*/
|
||||
fun isSynced(filename: String): Boolean {
|
||||
return getSyncedFiles().contains(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of files pending sync.
|
||||
*/
|
||||
fun getPendingCount(allFiles: List<String>): Int {
|
||||
val synced = getSyncedFiles()
|
||||
return allFiles.count { !synced.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the sync history for a specific file.
|
||||
*/
|
||||
fun clearSyncStatus(filename: String) {
|
||||
val current = getSyncedFiles().toMutableSet()
|
||||
current.remove(filename)
|
||||
saveSyncedFiles(current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all sync history.
|
||||
*/
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_SYNCED_FILES).apply()
|
||||
}
|
||||
|
||||
private fun saveSyncedFiles(files: Set<String>) {
|
||||
prefs.edit()
|
||||
.putString(KEY_SYNCED_FILES, files.joinToString(SEPARATOR))
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.example.phonerecorder.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.example.phonerecorder.StorageHelper
|
||||
import com.example.phonerecorder.api.RecordingsApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SyncWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncWorker"
|
||||
const val WORK_NAME_PERIODIC = "sync_worker_periodic"
|
||||
const val WORK_NAME_IMMEDIATE = "sync_worker_immediate"
|
||||
}
|
||||
|
||||
private val syncSettings = SyncSettings(applicationContext)
|
||||
private val networkHelper = NetworkHelper(applicationContext)
|
||||
private val syncTracker = SyncTracker(applicationContext)
|
||||
private val storageHelper = StorageHelper(applicationContext)
|
||||
private val recordingsApi = RecordingsApi()
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "Starting sync work")
|
||||
|
||||
// Check if sync is enabled and conditions are met
|
||||
val syncMode = syncSettings.syncMode
|
||||
if (syncMode == SyncMode.NEVER) {
|
||||
Log.i(TAG, "Sync disabled")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (!networkHelper.shouldSync(syncMode)) {
|
||||
Log.i(TAG, "Network conditions not met for sync mode: $syncMode")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// Determine server URL based on sync mode
|
||||
// LAN_ONLY mode uses LAN URL, other modes use remote URL
|
||||
val serverUrl = if (syncMode == SyncMode.LAN_ONLY) {
|
||||
syncSettings.lanServerUrl
|
||||
} else {
|
||||
syncSettings.remoteServerUrl
|
||||
}
|
||||
Log.i(TAG, "Using server: $serverUrl (mode: $syncMode)")
|
||||
|
||||
// Check server health
|
||||
if (!recordingsApi.checkHealth(serverUrl)) {
|
||||
Log.w(TAG, "Server health check failed")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// Get unsynced files
|
||||
val syncedFiles = syncTracker.getSyncedFiles()
|
||||
val unsyncedFiles = storageHelper.listUnsyncedRecordings(syncedFiles)
|
||||
|
||||
if (unsyncedFiles.isEmpty()) {
|
||||
Log.i(TAG, "No files to sync")
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Found ${unsyncedFiles.size} files to sync")
|
||||
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for (file in unsyncedFiles) {
|
||||
// Re-check network conditions before each upload
|
||||
if (!networkHelper.shouldSync(syncMode)) {
|
||||
Log.w(TAG, "Network conditions changed during sync")
|
||||
break
|
||||
}
|
||||
|
||||
val result = recordingsApi.uploadRecording(serverUrl, file, syncSettings.apiKey)
|
||||
if (result.success && result.verified) {
|
||||
// Only mark as synced if server verified the hash
|
||||
syncTracker.markSynced(file.name)
|
||||
successCount++
|
||||
Log.i(TAG, "Synced and verified: ${file.name}")
|
||||
} else if (result.success && !result.verified) {
|
||||
// Upload succeeded but no verification - don't mark as synced, retry later
|
||||
Log.w(TAG, "Upload succeeded but unverified, will retry: ${file.name}")
|
||||
failCount++
|
||||
} else {
|
||||
failCount++
|
||||
Log.w(TAG, "Failed to sync ${file.name}: ${result.message}")
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Sync completed: $successCount succeeded, $failCount failed")
|
||||
|
||||
if (failCount > 0 && successCount == 0) {
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Microphone icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,28c-5.5,0 -10,4.5 -10,10v20c0,5.5 4.5,10 10,10s10,-4.5 10,-10v-20c0,-5.5 -4.5,-10 -10,-10z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M74,58v-8c0,-11 -9,-20 -20,-20s-20,9 -20,20v8h6v-8c0,-7.7 6.3,-14 14,-14s14,6.3 14,14v8h6z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M51,72v8h6v-8c9.4,-1.2 16.7,-9.2 16.7,-18.7h-6c0,7.5 -6.1,13.7 -13.7,13.7s-13.7,-6.1 -13.7,-13.7h-6c0,9.5 7.3,17.5 16.7,18.7z"/>
|
||||
</vector>
|
||||
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal file
5
clio-android/app/src/main/res/drawable/status_circle.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/recording_stopped" />
|
||||
</shape>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/recording_active" />
|
||||
</shape>
|
||||
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal file
232
clio-android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Status Indicator -->
|
||||
<View
|
||||
android:id="@+id/statusIndicator"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:background="@drawable/status_circle"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/status_stopped"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="@id/statusIndicator"
|
||||
app:layout_constraintStart_toEndOf="@id/statusIndicator"
|
||||
app:layout_constraintTop_toTopOf="@id/statusIndicator" />
|
||||
|
||||
<!-- Elapsed Time -->
|
||||
<TextView
|
||||
android:id="@+id/elapsedTimeText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="00:00:00"
|
||||
android:textSize="48sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusIndicator" />
|
||||
|
||||
<!-- Current File -->
|
||||
<TextView
|
||||
android:id="@+id/currentFileText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:ellipsize="middle"
|
||||
android:singleLine="true"
|
||||
android:textSize="14sp"
|
||||
tools:text="Current file: recording_2024-01-15_09-30-00.ogg"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/elapsedTimeText" />
|
||||
|
||||
<!-- Storage Location -->
|
||||
<TextView
|
||||
android:id="@+id/storageLocationText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Storage: SD Card"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/currentFileText" />
|
||||
|
||||
<!-- Storage Info -->
|
||||
<TextView
|
||||
android:id="@+id/storageInfoText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Used: 2.5 GB / Available: 28.3 GB"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/storageLocationText" />
|
||||
|
||||
<!-- Battery Info -->
|
||||
<TextView
|
||||
android:id="@+id/batteryInfoText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Battery: 85%"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/storageInfoText" />
|
||||
|
||||
<!-- Start/Stop Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/toggleButton"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="64dp"
|
||||
android:text="@string/start_recording"
|
||||
android:textSize="18sp"
|
||||
app:cornerRadius="32dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/autoStartSwitch"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/batteryInfoText"
|
||||
app:layout_constraintVertical_bias="0.5" />
|
||||
|
||||
<!-- Auto-start Switch -->
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/autoStartSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/auto_start_disabled"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toggleButton" />
|
||||
|
||||
<!-- Sync Settings Section -->
|
||||
<TextView
|
||||
android:id="@+id/syncSettingsLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/sync_settings_label"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/autoStartSwitch" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/syncModeSpinner"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/syncSettingsLabel" />
|
||||
|
||||
<!-- Network Info -->
|
||||
<TextView
|
||||
android:id="@+id/networkInfoText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Network: WiFi (192.168.1.42)"
|
||||
app:layout_constraintEnd_toStartOf="@id/configureLanButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner"
|
||||
app:layout_constraintBottom_toBottomOf="@id/configureLanButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/configureLanButton"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/configure_lan"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/syncModeSpinner" />
|
||||
|
||||
<!-- Key Status -->
|
||||
<TextView
|
||||
android:id="@+id/keyStatusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Key: Not configured"
|
||||
app:layout_constraintEnd_toStartOf="@id/importKeyButton"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/configureLanButton"
|
||||
app:layout_constraintBottom_toBottomOf="@id/importKeyButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/importKeyButton"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/import_key_button"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/configureLanButton" />
|
||||
|
||||
<!-- Sync Status -->
|
||||
<TextView
|
||||
android:id="@+id/syncStatusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="14sp"
|
||||
tools:text="Sync: 3 pending"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/importKeyButton" />
|
||||
|
||||
<!-- Battery Optimization Hint -->
|
||||
<TextView
|
||||
android:id="@+id/batteryOptimizationHint"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/battery_optimization_hint"
|
||||
android:textColor="@color/warning"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/syncStatusText" />
|
||||
|
||||
<!-- Version Info -->
|
||||
<TextView
|
||||
android:id="@+id/versionText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:textSize="11sp"
|
||||
tools:text="v1.1.0 (build 2026-05-11)"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/batteryOptimizationHint"
|
||||
app:layout_constraintVertical_bias="1.0" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
9
clio-android/app/src/main/res/values/colors.xml
Normal file
9
clio-android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#D32F2F</color>
|
||||
<color name="primary_dark">#B71C1C</color>
|
||||
<color name="accent">#FF5252</color>
|
||||
<color name="recording_active">#4CAF50</color>
|
||||
<color name="recording_stopped">#9E9E9E</color>
|
||||
<color name="warning">#FF9800</color>
|
||||
</resources>
|
||||
58
clio-android/app/src/main/res/values/strings.xml
Normal file
58
clio-android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Phone Recorder</string>
|
||||
<string name="status_recording">Recording</string>
|
||||
<string name="status_stopped">Stopped</string>
|
||||
<string name="start_recording">Start Recording</string>
|
||||
<string name="stop_recording">Stop Recording</string>
|
||||
<string name="storage_info">Storage: %1$s used / %2$s available</string>
|
||||
<string name="battery_info">Battery: %1$d%%</string>
|
||||
<string name="current_file">Current file: %1$s</string>
|
||||
<string name="no_file">No file</string>
|
||||
<string name="notification_channel_name">Recording</string>
|
||||
<string name="notification_channel_description">Shows when recording is active</string>
|
||||
<string name="notification_title">Recording in progress</string>
|
||||
<string name="notification_text">Elapsed: %1$s</string>
|
||||
<string name="storage_warning">Low storage! Recording stopped.</string>
|
||||
<string name="storage_location_sd">Storage: SD Card</string>
|
||||
<string name="storage_location_internal">Storage: Internal (SD card not available)</string>
|
||||
<string name="auto_start_enabled">Auto-start on boot: Enabled</string>
|
||||
<string name="auto_start_disabled">Auto-start on boot: Disabled</string>
|
||||
<string name="battery_optimization_title">Battery Optimization</string>
|
||||
<string name="battery_optimization_message">For reliable background recording, disable battery optimization for this app.</string>
|
||||
<string name="battery_optimization_button">Open Settings</string>
|
||||
<string name="battery_optimization_hint">Tip: Disable battery optimization for best reliability</string>
|
||||
<string name="permission_required">Microphone permission is required for recording</string>
|
||||
|
||||
<!-- Sync settings -->
|
||||
<string name="sync_settings_label">Sync Mode</string>
|
||||
<string name="sync_mode_never">Never</string>
|
||||
<string name="sync_mode_lan_only">LAN Only</string>
|
||||
<string name="sync_mode_wifi_only">WiFi Only</string>
|
||||
<string name="sync_mode_any_network">Any Network</string>
|
||||
<string name="sync_status_pending">Sync: %1$d pending</string>
|
||||
<string name="sync_status_disabled">Sync: Disabled</string>
|
||||
<string name="sync_status_no_key">Sync: No server key configured</string>
|
||||
<string name="sync_status_not_on_wifi">Sync: Not on WiFi</string>
|
||||
<string name="sync_status_not_on_lan">Sync: Not on configured LAN</string>
|
||||
<string name="sync_status_no_network">Sync: No network connection</string>
|
||||
<string name="sync_status_synced">Sync: All synced</string>
|
||||
<string name="import_key_button">Import Server Key</string>
|
||||
<string name="import_key_title">Import Server Public Key</string>
|
||||
<string name="import_key_hint">Paste Base64 public key</string>
|
||||
<string name="import_key_success">Server key imported successfully</string>
|
||||
<string name="import_key_error">Invalid key format</string>
|
||||
<string name="key_status_configured">Key: Configured</string>
|
||||
<string name="key_status_not_configured">Key: Not configured</string>
|
||||
|
||||
<!-- Network info -->
|
||||
<string name="network_not_connected">Network: Not connected</string>
|
||||
<string name="network_wifi">Network: WiFi (%1$s)</string>
|
||||
<string name="network_mobile">Network: Mobile data</string>
|
||||
<string name="lan_subnet_label">LAN Subnet</string>
|
||||
<string name="lan_subnet_hint">e.g., 192.168.1. or 10.0.0.</string>
|
||||
<string name="configure_lan">Configure</string>
|
||||
<string name="lan_config_title">LAN Subnet Configuration</string>
|
||||
<string name="lan_config_message">Enter the IP prefix for your home network.\n\nYour current IP: %1$s\n\nCommon examples:\n• 192.168.1.\n• 192.168.0.\n• 10.0.0.</string>
|
||||
<string name="lan_config_saved">LAN subnet saved: %1$s</string>
|
||||
</resources>
|
||||
11
clio-android/app/src/main/res/values/themes.xml
Normal file
11
clio-android/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.PhoneRecorder" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryVariant">@color/primary_dark</item>
|
||||
<item name="colorOnPrimary">@android:color/white</item>
|
||||
<item name="colorSecondary">@color/accent</item>
|
||||
<item name="colorSecondaryVariant">@color/accent</item>
|
||||
<item name="colorOnSecondary">@android:color/white</item>
|
||||
</style>
|
||||
</resources>
|
||||
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
6
clio-android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path
|
||||
name="recordings"
|
||||
path="recordings/" />
|
||||
</paths>
|
||||
4
clio-android/build.gradle.kts
Normal file
4
clio-android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||
}
|
||||
136
clio-android/description.md
Normal file
136
clio-android/description.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Phone Recorder - Architecture Overview
|
||||
|
||||
## Purpose
|
||||
|
||||
Android app that records audio continuously, encrypts recordings immediately (never stored unencrypted), and syncs them to a server over LAN or internet.
|
||||
|
||||
## Security Model
|
||||
|
||||
```
|
||||
Phone (has: server public key) Server (has: private key)
|
||||
───────────────────────────── ────────────────────────
|
||||
Record audio → temp.ogg
|
||||
Encrypt with public key →
|
||||
recording_*.ogg.enc
|
||||
│
|
||||
└──── HTTP POST ────────→ Receive encrypted file
|
||||
Decrypt with private key
|
||||
Store as .ogg in recordings/
|
||||
```
|
||||
|
||||
- **Sealed box encryption**: X25519 key exchange + XSalsa20-Poly1305
|
||||
- **Forward secrecy**: Each encryption uses ephemeral keys
|
||||
- **Phone never stores unencrypted**: Temp file deleted immediately after encryption
|
||||
- **If no public key configured**: Recordings are deleted (security-first design)
|
||||
|
||||
## File Format (SREC)
|
||||
|
||||
Encrypted files use a chunked format for streaming large files:
|
||||
|
||||
```
|
||||
[4 bytes: "SREC" magic]
|
||||
[1 byte: version (1)]
|
||||
[4 bytes: chunk count, big-endian]
|
||||
For each chunk (64KB max):
|
||||
[4 bytes: encrypted length, big-endian]
|
||||
[N bytes: sealed box ciphertext]
|
||||
```
|
||||
|
||||
## Android App Structure
|
||||
|
||||
### Main Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MainActivity.kt` | UI, settings, sync controls |
|
||||
| `RecordingService.kt` | Foreground service for recording, encryption on stop |
|
||||
| `StorageHelper.kt` | File paths, temp vs recordings directories |
|
||||
|
||||
### Crypto Package (`crypto/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `CryptoHelper.kt` | SREC format encryption using lazysodium |
|
||||
| `KeyManager.kt` | Store/retrieve server public key (SharedPreferences) |
|
||||
|
||||
### Sync Package (`sync/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SyncSettings.kt` | Sync mode enum, server URLs, LAN subnet prefix |
|
||||
| `NetworkHelper.kt` | Detect WiFi, LAN (by IP prefix), network availability |
|
||||
| `SyncTracker.kt` | Track which files synced successfully (SharedPreferences) |
|
||||
| `SyncWorker.kt` | WorkManager background job for uploads |
|
||||
| `SyncManager.kt` | Schedule periodic (15min) and immediate syncs |
|
||||
|
||||
### API Package (`api/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `RecordingsApi.kt` | OkHttp client, upload with SHA-256 verification |
|
||||
|
||||
## Sync Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| Never | No sync, files stay on phone |
|
||||
| LAN Only | Sync only when IP matches configured subnet (e.g., `10.0.0.`) |
|
||||
| WiFi Only | Sync on any WiFi network |
|
||||
| Any Network | Sync on WiFi or mobile data |
|
||||
|
||||
## Server Structure (Python/FastAPI)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.py` | FastAPI endpoints |
|
||||
| `crypto.py` | PyNaCl decryption, keypair management |
|
||||
| `config.py` | Paths, ports |
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Returns `{"status": "ok"}` |
|
||||
| `/api/v1/public-key` | GET | Returns Base64 public key |
|
||||
| `/api/v1/recordings/upload` | POST | Upload encrypted file, returns hash for verification |
|
||||
| `/api/v1/recordings` | GET | List decrypted recordings |
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Recording starts**: `RecordingService` writes to `temp/recording_TIMESTAMP.ogg`
|
||||
2. **Recording stops**:
|
||||
- `CryptoHelper.encryptFile()` reads temp, writes `recordings/recording_TIMESTAMP.ogg.enc`
|
||||
- Temp file deleted
|
||||
- `SyncManager.scheduleImmediateSync()` triggered
|
||||
3. **Sync runs**:
|
||||
- `SyncWorker` checks network conditions match `SyncMode`
|
||||
- Computes SHA-256 of encrypted file
|
||||
- Uploads to server
|
||||
- Server decrypts, returns hash
|
||||
- If hashes match, marks file as synced
|
||||
4. **Server storage**: `recordings/YYYY/MM/DD/recording_TIMESTAMP.ogg`
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
### Android
|
||||
- `lazysodium-android` - libsodium bindings
|
||||
- `androidx.work` - Background job scheduling
|
||||
- `okhttp3` - HTTP client
|
||||
|
||||
### Server
|
||||
- `fastapi` + `uvicorn` - Web framework
|
||||
- `pynacl` - libsodium bindings
|
||||
- `aiofiles` - Async file I/O
|
||||
|
||||
## Configuration
|
||||
|
||||
### Android (in-app settings)
|
||||
- Sync mode (Never/LAN/WiFi/Any)
|
||||
- LAN subnet prefix (default: `10.0.0.`)
|
||||
- Server URLs (LAN and remote)
|
||||
- Server public key (Base64)
|
||||
|
||||
### Server (config.py or env vars)
|
||||
- `HOST` - Bind address (default: 0.0.0.0)
|
||||
- `PORT` - Listen port (default: 8000)
|
||||
- `BASE_DIR` - Base directory for keys and recordings
|
||||
61
clio-android/flake.lock
generated
Normal file
61
clio-android/flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776877367,
|
||||
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
52
clio-android/flake.nix
Normal file
52
clio-android/flake.nix
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
description = "Phone Recorder Android app development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
allowUnfree = true;
|
||||
android_sdk.accept_license = true;
|
||||
};
|
||||
};
|
||||
|
||||
androidComposition = pkgs.androidenv.composeAndroidPackages {
|
||||
buildToolsVersions = [ "34.0.0" ];
|
||||
platformVersions = [ "34" "33" ];
|
||||
abiVersions = [ "arm64-v8a" "x86_64" ];
|
||||
includeEmulator = false;
|
||||
includeSystemImages = false;
|
||||
includeSources = false;
|
||||
includeNDK = false;
|
||||
};
|
||||
|
||||
androidSdk = androidComposition.androidsdk;
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
android-studio
|
||||
android-tools
|
||||
jdk17
|
||||
androidSdk
|
||||
];
|
||||
|
||||
ANDROID_HOME = "${androidSdk}/libexec/android-sdk";
|
||||
ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk";
|
||||
|
||||
shellHook = ''
|
||||
echo "Phone Recorder development environment"
|
||||
echo "Run 'android-studio' to launch Android Studio"
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
4
clio-android/gradle.properties
Normal file
4
clio-android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
251
clio-android/gradlew
vendored
Executable file
251
clio-android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
clio-android/gradlew.bat
vendored
Normal file
94
clio-android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
14
clio-android/notes.txt
Normal file
14
clio-android/notes.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
./gradlew assembleDebug
|
||||
|
||||
2. Connect your phone (USB debugging enabled) and verify connection:
|
||||
./android_studio_install_path/platform-tools/adb devices
|
||||
|
||||
3. Install the APK:
|
||||
./android_studio_install_path/platform-tools/adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
Or you can combine build + install in one step:
|
||||
./gradlew installDebug
|
||||
|
||||
This requires your phone to be connected with USB debugging enabled in Developer Options.
|
||||
|
||||
❯ can you add it to the flake?
|
||||
18
clio-android/settings.gradle.kts
Normal file
18
clio-android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "PhoneRecorder"
|
||||
include(":app")
|
||||
117
clio-android/todo.md
Normal file
117
clio-android/todo.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Phone Recorder - Future Ideas / TODO
|
||||
|
||||
## Questions to Address
|
||||
|
||||
### Key Rotation
|
||||
- What happens when server keypair changes?
|
||||
- Current behavior: Old encrypted files can't be decrypted with new key
|
||||
- Options:
|
||||
1. Keep old private key archived for decrypting old files
|
||||
2. Sync all files before rotating keys
|
||||
3. Store key version/ID in encrypted file header
|
||||
|
||||
### Manual Decryption
|
||||
- Document steps to decrypt files copied via USB from phone
|
||||
- Need: Python script using server's private key
|
||||
- Consider: CLI tool in server/ directory
|
||||
|
||||
### No-Key Behavior
|
||||
- Current: Deletes recordings if no public key configured (security-first)
|
||||
- Alternatives to consider:
|
||||
1. Store unencrypted with warning (less secure)
|
||||
2. Queue recordings until key configured (could fill storage)
|
||||
3. Current behavior is probably correct for security model
|
||||
|
||||
---
|
||||
|
||||
## Possible Features
|
||||
|
||||
### High Priority (if needed)
|
||||
- [ ] QR code for key import (avoid email/typing)
|
||||
- [ ] Server URL configuration in app UI (currently requires code change for non-defaults)
|
||||
- [ ] Manual sync trigger button
|
||||
- [ ] View sync history/status in app
|
||||
- [ ] **Display last successful sync time on phone** (e.g., "Last sync: 2 hours ago")
|
||||
- [ ] **Server status page** - HTML dashboard showing:
|
||||
- When phone last synced
|
||||
- Total recordings count
|
||||
- Recent uploads
|
||||
- Disk space usage
|
||||
- Could be at `/status` or `/dashboard`
|
||||
|
||||
### Medium Priority
|
||||
- [ ] Configurable recording quality (bitrate, sample rate)
|
||||
- [ ] Configurable segment duration (currently 2 hours)
|
||||
- [ ] Delete synced files automatically (with configurable retention)
|
||||
- [ ] Server: Authentication for upload endpoint
|
||||
- [ ] Server: HTTPS with Let's Encrypt
|
||||
|
||||
### Audio Analysis & Intelligence
|
||||
- [ ] **Speech detection bar** - Visual indicator showing when speech is detected vs silence
|
||||
- Display as additional bar in recording detail view alongside waveform
|
||||
- Data comes from batch processing script
|
||||
- [ ] **Sleeping detection** - Detect periods of sleep/silence for overnight recordings
|
||||
- Could help skip to interesting parts
|
||||
- Visual indicator in timeline
|
||||
- [ ] **Auto voice recognition / Speaker identification**
|
||||
- Identify who is talking (requires training on voice samples)
|
||||
- Label segments by speaker
|
||||
- Not text-based (transcripts are unreliable)
|
||||
- [ ] **Auto highlight generation**
|
||||
- Audio-based detection (not text search since transcripts are bad)
|
||||
- Example: detect phrase patterns like "Ok, note taken" acoustically
|
||||
- Auto-create highlights with tags like "note_taken"
|
||||
- Use audio fingerprinting or keyword spotting models
|
||||
|
||||
### Viewer UX
|
||||
- [ ] **Background audio playback** - Keep playing current audio while browsing other recordings
|
||||
- Mini player that persists across views
|
||||
- Allows reviewing recordings list while listening
|
||||
- [ ] **Highlight duration** - Currently highlights mark only a point; consider adding end time/duration
|
||||
- [ ] **Auto-scroll timeline to recordings** - If recordings are only at end of day, auto-scroll so user doesn't have to scroll down to see them
|
||||
|
||||
### Low Priority / Nice to Have
|
||||
- [ ] Multiple server destinations
|
||||
- [ ] Compression before encryption
|
||||
- [ ] Resume interrupted uploads
|
||||
- [ ] Server: Web UI to browse/play recordings
|
||||
- [ ] Server: Transcription integration
|
||||
|
||||
### Infrastructure
|
||||
- [ ] Systemd service file for server
|
||||
- [ ] Docker container for server
|
||||
- [ ] NixOS module for server
|
||||
- [ ] Backup strategy for server private key
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No streaming encryption**: Entire temp file must complete before encryption
|
||||
2. **No partial sync**: If upload fails, whole file retries
|
||||
3. **Single server**: Can only sync to one destination at a time
|
||||
4. **No authentication**: Anyone who can reach server can upload (relies on encryption for confidentiality)
|
||||
|
||||
---
|
||||
|
||||
## Testing Notes
|
||||
|
||||
### Unit Tests (server/)
|
||||
```bash
|
||||
python test_crypto.py
|
||||
```
|
||||
|
||||
### Integration Tests (server/)
|
||||
```bash
|
||||
# Start server first
|
||||
python main.py &
|
||||
python test_integration.py
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
- [ ] Record short clip, verify .ogg.enc created
|
||||
- [ ] Verify temp file deleted after encryption
|
||||
- [ ] Check sync triggers after recording stops
|
||||
- [ ] Verify hash verification (corrupt a file, see it retry)
|
||||
- [ ] Test LAN detection with different subnets
|
||||
- [ ] Test sync mode transitions
|
||||
Loading…
Add table
Add a link
Reference in a new issue