This commit is contained in:
parent
ab3a76cd0f
commit
56d8fb4d56
85 changed files with 8879 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue