From c31f7a67461fe654def9c7fea689fbbf559f622e Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Thu, 18 Jun 2026 23:42:19 +0200 Subject: [PATCH 01/13] feat: add floating recorder overlay Add a default-off V2 floating recorder overlay that starts after the app has opened, controls recording through the existing recording service, and supports overlay-based rename flow when the rename-after-recording setting is enabled. Persist overlay and rename dialog positions, localize all overlay strings, and add disc-style save feedback with a continuous three-second rainbow-to-grey confirmation animation. Add focused tests for overlay permissions, settings decisions, geometry, save feedback colors, and preference persistence. Agentic harness: OpenCode with OpenAI GPT-5.5 (openai/gpt-5.5). --- app/src/main/AndroidManifest.xml | 11 + .../audiorecorder/v2/app/HomeActivity.kt | 20 + .../v2/app/home/HomeViewModel.kt | 16 +- .../FloatingRecorderOverlayGeometry.kt | 95 +++ .../FloatingRecorderOverlayPermission.kt | 20 + .../overlay/FloatingRecorderOverlayService.kt | 689 ++++++++++++++++++ ...FloatingRecorderOverlaySettingsDecision.kt | 18 + .../v2/app/settings/SettingsComponents.kt | 33 +- .../v2/app/settings/SettingsScreen.kt | 109 +++ .../v2/app/settings/SettingsState.kt | 3 +- .../v2/app/settings/SettingsViewModel.kt | 10 + .../settings/WelcomeSetupSettingsScreen.kt | 1 + .../v2/audio/AudioRecordingService.kt | 21 +- .../dimowner/audiorecorder/v2/data/PrefsV2.kt | 6 + .../audiorecorder/v2/data/PrefsV2Impl.kt | 48 ++ .../main/res/drawable/ic_floating_record.xml | 9 + app/src/main/res/values-bg/strings.xml | 9 + app/src/main/res/values-ca/strings.xml | 9 + app/src/main/res/values-de/strings.xml | 10 +- app/src/main/res/values-es/strings.xml | 10 +- app/src/main/res/values-fr/strings.xml | 9 + app/src/main/res/values-it/strings.xml | 10 +- app/src/main/res/values-ja/strings.xml | 10 +- app/src/main/res/values-pl/strings.xml | 10 +- app/src/main/res/values-pt-rBR/strings.xml | 10 +- app/src/main/res/values-pt-rPT/strings.xml | 10 +- app/src/main/res/values-ru/strings.xml | 9 + app/src/main/res/values-tr/strings.xml | 9 + app/src/main/res/values-uk/strings.xml | 9 + app/src/main/res/values-zh-rTW/strings.xml | 9 + app/src/main/res/values-zh/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + .../FloatingRecorderOverlayGeometryTest.kt | 109 +++ .../FloatingRecorderOverlayPermissionTest.kt | 25 + ...tingRecorderOverlaySettingsDecisionTest.kt | 47 ++ .../audiorecorder/v2/data/PrefsV2ImplTest.kt | 80 ++ .../floating-recorder-button-disc-mockup.html | 333 +++++++++ ...loating-recorder-overlay-button-visuals.md | 126 ++++ ...8-floating-recorder-overlay-disc-button.md | 56 ++ .../2026-06-18-floating-recorder-overlay.md | 75 ++ ...g-recorder-overlay-button-visual-design.md | 64 ++ ...-06-18-floating-recorder-overlay-design.md | 116 +++ 42 files changed, 2273 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermission.kt create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecision.kt create mode 100644 app/src/main/res/drawable/ic_floating_record.xml create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermissionTest.kt create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecisionTest.kt create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt create mode 100644 docs/superpowers/mockups/floating-recorder-button-disc-mockup.html create mode 100644 docs/superpowers/plans/2026-06-18-floating-recorder-overlay-button-visuals.md create mode 100644 docs/superpowers/plans/2026-06-18-floating-recorder-overlay-disc-button.md create mode 100644 docs/superpowers/plans/2026-06-18-floating-recorder-overlay.md create mode 100644 docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md create mode 100644 docs/superpowers/specs/2026-06-18-floating-recorder-overlay-design.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7521e35..c1a61e41 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + @@ -18,6 +19,7 @@ android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" android:minSdkVersion="33" /> + @@ -143,6 +145,15 @@ android:exported="false" android:foregroundServiceType="microphone" /> + + + + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt index 61181149..70a4e266 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt @@ -34,6 +34,8 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import com.dimowner.audiorecorder.app.main.MainActivity import com.dimowner.audiorecorder.v2.app.home.HomeViewModel +import com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayPermission +import com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayService import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.navigation.RecorderNavigationGraph import com.dimowner.audiorecorder.v2.theme.AppTheme @@ -71,6 +73,11 @@ class HomeActivity: ComponentActivity() { } } + override fun onStart() { + super.onStart() + reconcileFloatingRecorderOverlayService() + } + @Composable fun RecorderApp( coroutineScope: CoroutineScope @@ -99,4 +106,17 @@ class HomeActivity: ComponentActivity() { } } } + + private fun reconcileFloatingRecorderOverlayService() { + if (!prefs.isFloatingRecorderOverlayEnabled) return + + if (FloatingRecorderOverlayPermission.canDrawOverlays(this)) { + FloatingRecorderOverlayService.startService(applicationContext) + } else { + // Overlay permission can be revoked from Android settings while the app is closed. + // Keep the persisted setting aligned with the platform state. + prefs.isFloatingRecorderOverlayEnabled = false + FloatingRecorderOverlayService.stopService(applicationContext) + } + } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt index e48614c4..e239757d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -380,7 +380,11 @@ class HomeViewModel @Inject constructor( // event instead of the StateFlow state ensures delivery even when the // rapid STOPPED→IDLE StateFlow transition is conflated and the collector // misses the STOPPED state. - handleRecordingStopped(event.recordId, event.recordName) + handleRecordingStopped( + event.recordId, + event.recordName, + event.startedFromFloatingOverlay, + ) } is AudioRecordingServiceEvent.NewRecordingPartStarted -> { handleNewRecordingPartStarted(event.recordId) @@ -450,12 +454,16 @@ class HomeViewModel @Inject constructor( }) } - private suspend fun handleRecordingStopped(recordedRecordId: Long, recordName: String?) { + private suspend fun handleRecordingStopped( + recordedRecordId: Long, + recordName: String?, + startedFromFloatingOverlay: Boolean, + ) { withContext(ioDispatcher) { if (recordedRecordId >= 0) { if (_state.value.isDeleteRecordingProgressRequested) { moveRecordToRecycle(recordedRecordId, false) - } else if (prefs.askToRenameAfterRecordingStopped) { + } else if (prefs.askToRenameAfterRecordingStopped && !startedFromFloatingOverlay) { updateState() withContext(mainDispatcher) { _state.value = _state.value.copy( @@ -1343,4 +1351,4 @@ private class LongEvaluator : TypeEvaluator { override fun evaluate(fraction: Float, startValue: Long, endValue: Long): Long { return startValue + ((endValue - startValue) * fraction).toLong() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt new file mode 100644 index 00000000..8f32f5aa --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -0,0 +1,95 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import kotlin.math.abs +import kotlin.math.roundToInt + +internal data class OverlayPosition(val x: Int, val y: Int) + +internal fun clampOverlayPosition( + savedX: Int, + savedY: Int, + screenWidth: Int, + screenHeight: Int, + overlayWidth: Int, + overlayHeight: Int, +): OverlayPosition { + val maxX = (screenWidth - overlayWidth).coerceAtLeast(0) + val maxY = (screenHeight - overlayHeight).coerceAtLeast(0) + val defaultX = maxX - 24.coerceAtMost(maxX) + val defaultY = maxY / 2 + + return OverlayPosition( + // -1 is the persisted sentinel for "no user-selected position yet". + // Other out-of-bounds values can happen after display-size changes and should clamp. + x = if (savedX == -1) defaultX else savedX.coerceIn(0, maxX), + y = if (savedY == -1) defaultY else savedY.coerceIn(0, maxY), + ) +} + +internal fun calculateBoundedOverlayWidth( + screenWidth: Int, + horizontalMargin: Int, + minimumWidth: Int, + maximumWidth: Int, +): Int { + val availableWidth = (screenWidth - horizontalMargin).coerceAtLeast(0) + val effectiveMinimumWidth = minimumWidth.coerceAtMost(screenWidth) + return availableWidth + .coerceAtLeast(effectiveMinimumWidth) + .coerceAtMost(maximumWidth) +} + +internal fun calculateSaveFeedbackColor(progress: Float, idleColor: Int): Int { + val clampedProgress = progress.coerceIn(0f, 1f) + if (clampedProgress >= 1f) return idleColor + + val hue = 360f * clampedProgress + val saturation = (1f - clampedProgress).coerceIn(0f, 1f) + val rainbowColor = hsvToOpaqueColor(hue = hue, saturation = saturation, value = 1f) + + // The last slice intentionally settles into the exact idle color instead of ending on + // an almost-white red hue. That makes the completion state deterministic and calm. + val settleProgress = ((clampedProgress - 0.85f) / 0.15f).coerceIn(0f, 1f) + return blendArgb(from = rainbowColor, to = idleColor, ratio = settleProgress) +} + +private fun hsvToOpaqueColor(hue: Float, saturation: Float, value: Float): Int { + val normalizedHue = ((hue % 360f) + 360f) % 360f + val chroma = value * saturation + val hueSection = normalizedHue / 60f + val secondary = chroma * (1f - abs(hueSection % 2f - 1f)) + val match = value - chroma + val (redPrime, greenPrime, bluePrime) = when { + hueSection < 1f -> Triple(chroma, secondary, 0f) + hueSection < 2f -> Triple(secondary, chroma, 0f) + hueSection < 3f -> Triple(0f, chroma, secondary) + hueSection < 4f -> Triple(0f, secondary, chroma) + hueSection < 5f -> Triple(secondary, 0f, chroma) + else -> Triple(chroma, 0f, secondary) + } + + return argb( + alpha = 255, + red = ((redPrime + match) * 255f).roundToInt(), + green = ((greenPrime + match) * 255f).roundToInt(), + blue = ((bluePrime + match) * 255f).roundToInt(), + ) +} + +private fun blendArgb(from: Int, to: Int, ratio: Float): Int { + val clampedRatio = ratio.coerceIn(0f, 1f) + val inverseRatio = 1f - clampedRatio + return argb( + alpha = (((from ushr 24) and 0xFF) * inverseRatio + ((to ushr 24) and 0xFF) * clampedRatio).roundToInt(), + red = (((from ushr 16) and 0xFF) * inverseRatio + ((to ushr 16) and 0xFF) * clampedRatio).roundToInt(), + green = (((from ushr 8) and 0xFF) * inverseRatio + ((to ushr 8) and 0xFF) * clampedRatio).roundToInt(), + blue = ((from and 0xFF) * inverseRatio + (to and 0xFF) * clampedRatio).roundToInt(), + ) +} + +private fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int { + return (alpha.coerceIn(0, 255) shl 24) or + (red.coerceIn(0, 255) shl 16) or + (green.coerceIn(0, 255) shl 8) or + blue.coerceIn(0, 255) +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermission.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermission.kt new file mode 100644 index 00000000..6d97ce34 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermission.kt @@ -0,0 +1,20 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +object FloatingRecorderOverlayPermission { + + fun canDrawOverlays(context: Context): Boolean { + return Settings.canDrawOverlays(context) + } + + fun overlayPermissionSettingsIntent(context: Context): Intent { + return Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}") + ) + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt new file mode 100644 index 00000000..2e421b35 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -0,0 +1,689 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.ServiceInfo +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.IBinder +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import android.widget.Button +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.audio.player.PlayerContractNew +import com.dimowner.audiorecorder.v2.app.HomeActivity +import com.dimowner.audiorecorder.v2.audio.AudioRecordingService +import com.dimowner.audiorecorder.v2.audio.AudioRecordingServiceEvent +import com.dimowner.audiorecorder.v2.data.PrefsV2 +import com.dimowner.audiorecorder.v2.data.RecordsDataSource +import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.abs + +@AndroidEntryPoint +class FloatingRecorderOverlayService : Service() { + + @Inject lateinit var prefs: PrefsV2 + @Inject lateinit var recordsDataSource: RecordsDataSource + @Inject lateinit var audioPlayer: PlayerContractNew.Player + @Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher + + private val serviceScope by lazy { CoroutineScope(SupervisorJob() + ioDispatcher) } + private var recordingStateJob: Job? = null + private var recordingEventJob: Job? = null + + private lateinit var windowManager: WindowManager + private var iconView: FrameLayout? = null + private var iconParams: WindowManager.LayoutParams? = null + private var renameView: View? = null + private var saveFeedbackAnimator: AnimatorSet? = null + private var recordingService: AudioRecordingService? = null + private var isRecordingServiceBound = false + private var pendingStop = false + private var isRecording = false + + private val recordingServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as? AudioRecordingService.ServiceBinder + recordingService = binder?.getService() + isRecordingServiceBound = recordingService != null + recordingService?.let { boundService -> + subscribeRecordingService(boundService) + if (pendingStop) { + pendingStop = false + boundService.stopRecording() + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + recordingService = null + isRecordingServiceBound = false + recordingStateJob?.cancel() + recordingEventJob?.cancel() + } + } + + override fun onCreate() { + super.onCreate() + windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + createNotificationChannel() + startForegroundServiceNotification() + bindRecordingService() + + if (!prefs.isFloatingRecorderOverlayEnabled || !FloatingRecorderOverlayPermission.canDrawOverlays(this)) { + stopSelf() + return + } + + addIconOverlay() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + if (!prefs.isFloatingRecorderOverlayEnabled || !FloatingRecorderOverlayPermission.canDrawOverlays(this)) { + stopSelf() + return START_NOT_STICKY + } + if (iconView == null) { + addIconOverlay() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + removeRenameOverlay() + removeIconOverlay() + unbindRecordingService() + recordingStateJob?.cancel() + recordingEventJob?.cancel() + serviceScope.cancel() + super.onDestroy() + } + + private fun subscribeRecordingService(service: AudioRecordingService) { + recordingStateJob?.cancel() + recordingStateJob = serviceScope.launch { + service.recordingState.collect { state -> + val nowRecording = state.isRecording() + if (isRecording != nowRecording) { + isRecording = nowRecording + iconView?.post { updateIconAppearance(nowRecording) } + } + } + } + + recordingEventJob?.cancel() + recordingEventJob = serviceScope.launch { + service.event.collect { event -> + when (event) { + is AudioRecordingServiceEvent.RecordingStopped -> handleRecordingStopped(event.recordId) + is AudioRecordingServiceEvent.ShowErrorSnack -> { + Timber.w("Floating recorder start/stop error: ${event.message}") + iconView?.post { updateIconAppearance(false) } + } + else -> Unit + } + } + } + } + + private suspend fun handleRecordingStopped(recordId: Long) { + iconView?.post { runSavedAnimation() } + if (prefs.askToRenameAfterRecordingStopped && recordId >= 0) { + recordsDataSource.getRecord(recordId)?.let { record -> + iconView?.post { showRenameOverlay(record) } + } + } + } + + private fun addIconOverlay() { + if (iconView != null) return + + val size = dp(56) + val displayMetrics = resources.displayMetrics + val position = clampOverlayPosition( + savedX = prefs.floatingRecorderOverlayX, + savedY = prefs.floatingRecorderOverlayY, + screenWidth = displayMetrics.widthPixels, + screenHeight = displayMetrics.heightPixels, + overlayWidth = size, + overlayHeight = size, + ) + + val view = FrameLayout(this).apply { + background = iconBubbleDrawable(IDLE_ICON_COLOR) + elevation = dp(8).toFloat() + addView(View(this@FloatingRecorderOverlayService).apply { + background = recordDiscDrawable() + }, FrameLayout.LayoutParams(dp(30), dp(30), Gravity.CENTER)) + } + + val params = WindowManager.LayoutParams( + size, + size, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + x = position.x + y = position.y + } + + view.setOnTouchListener(OverlayTouchListener(params)) + iconView = view + iconParams = params + windowManager.addView(view, params) + updateIconAppearance(false) + } + + private fun removeIconOverlay() { + iconView?.let { view -> + runCatching { windowManager.removeView(view) } + } + iconView = null + iconParams = null + } + + private inner class OverlayTouchListener( + private val params: WindowManager.LayoutParams, + ) : View.OnTouchListener { + private val touchSlop = ViewConfiguration.get(this@FloatingRecorderOverlayService).scaledTouchSlop + private val longPressTimeout = ViewConfiguration.getLongPressTimeout() + private var downRawX = 0f + private var downRawY = 0f + private var startX = 0 + private var startY = 0 + private var downTime = 0L + private var dragging = false + + override fun onTouch(view: View, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downRawX = event.rawX + downRawY = event.rawY + startX = params.x + startY = params.y + downTime = event.eventTime + dragging = false + return true + } + MotionEvent.ACTION_MOVE -> { + val deltaX = event.rawX - downRawX + val deltaY = event.rawY - downRawY + val movedEnough = abs(deltaX) > touchSlop || abs(deltaY) > touchSlop + val heldLongEnough = event.eventTime - downTime >= longPressTimeout + if (movedEnough && heldLongEnough) { + dragging = true + params.x = startX + deltaX.toInt() + params.y = startY + deltaY.toInt() + windowManager.updateViewLayout(view, params) + } + return true + } + MotionEvent.ACTION_UP -> { + if (dragging) { + persistIconPosition(params) + } else { + handleIconTap() + } + return true + } + MotionEvent.ACTION_CANCEL -> { + if (dragging) persistIconPosition(params) + return true + } + } + return false + } + } + + private fun persistIconPosition(params: WindowManager.LayoutParams) { + val size = dp(56) + val metrics = resources.displayMetrics + val clamped = clampOverlayPosition( + savedX = params.x, + savedY = params.y, + screenWidth = metrics.widthPixels, + screenHeight = metrics.heightPixels, + overlayWidth = size, + overlayHeight = size, + ) + params.x = clamped.x + params.y = clamped.y + iconView?.let { windowManager.updateViewLayout(it, params) } + prefs.floatingRecorderOverlayX = clamped.x + prefs.floatingRecorderOverlayY = clamped.y + } + + private fun handleIconTap() { + if (isRecording) { + recordingService?.stopRecording() ?: run { + pendingStop = true + bindRecordingService() + } + } else { + audioPlayer.stop() + AudioRecordingService.startServiceForeground( + context = applicationContext, + startedFromFloatingOverlay = true, + ) + } + } + + private fun updateIconAppearance(recording: Boolean) { + if (recording) { + saveFeedbackAnimator?.cancel() + saveFeedbackAnimator = null + updateIconBackground(RECORDING_ICON_COLOR) + } else if (saveFeedbackAnimator?.isRunning != true) { + updateIconBackground(IDLE_ICON_COLOR) + } + } + + private fun updateIconBackground(color: Int) { + iconView?.background = iconBubbleDrawable(color) + } + + private fun runSavedAnimation() { + val view = iconView ?: return + saveFeedbackAnimator?.cancel() + + val scaleUpX = ObjectAnimator.ofFloat(view, View.SCALE_X, 1f, 1.18f) + val scaleUpY = ObjectAnimator.ofFloat(view, View.SCALE_Y, 1f, 1.18f) + val scaleDownX = ObjectAnimator.ofFloat(view, View.SCALE_X, 1.18f, 1f) + val scaleDownY = ObjectAnimator.ofFloat(view, View.SCALE_Y, 1.18f, 1f) + val scalePulse = AnimatorSet().apply { + play(scaleUpX).with(scaleUpY) + play(scaleDownX).with(scaleDownY).after(scaleUpX) + duration = SCALE_FEEDBACK_DURATION_MS + } + + val colorFeedback = ValueAnimator.ofFloat(0f, 1f).apply { + duration = SAVE_FEEDBACK_DURATION_MS + addUpdateListener { animator -> + val progress = animator.animatedValue as Float + updateIconBackground(calculateSaveFeedbackColor(progress, IDLE_ICON_COLOR)) + } + } + + saveFeedbackAnimator = AnimatorSet().apply { + playTogether(scalePulse, colorFeedback) + addListener(object : android.animation.AnimatorListenerAdapter() { + private var cancelled = false + + override fun onAnimationCancel(animation: android.animation.Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: android.animation.Animator) { + if (!cancelled) { + updateIconBackground(IDLE_ICON_COLOR) + } + if (saveFeedbackAnimator === animation) { + saveFeedbackAnimator = null + } + } + }) + start() + } + } + + private fun showRenameOverlay(record: Record) { + removeRenameOverlay() + + val metrics = resources.displayMetrics + val panelWidth = calculateRenamePanelWidth(metrics.widthPixels) + val position = clampOverlayPosition( + savedX = prefs.floatingRecorderRenameOverlayX, + savedY = prefs.floatingRecorderRenameOverlayY, + screenWidth = metrics.widthPixels, + screenHeight = metrics.heightPixels, + overlayWidth = panelWidth, + // The panel height is content-driven, so use a conservative first-pass estimate. + // A measured-height clamp runs immediately after attachment and on every drag end. + overlayHeight = dp(RENAME_PANEL_ESTIMATED_HEIGHT_DP), + ) + + val input = EditText(this).apply { + setText(record.name) + selectAll() + setSingleLine(true) + } + val error = TextView(this).apply { + setTextColor(RECORDING_ICON_COLOR) + visibility = View.GONE + } + + val panel = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(16), dp(12), dp(16), dp(12)) + background = roundedDrawable(Color.argb(236, 32, 32, 32), dp(16).toFloat()) + addView(TextView(this@FloatingRecorderOverlayService).apply { + text = getString(R.string.update_record_name) + setTextColor(Color.WHITE) + textSize = 18f + setPadding(0, 0, 0, dp(8)) + }) + addView(input, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + addView(error, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + addView(LinearLayout(this@FloatingRecorderOverlayService).apply { + gravity = Gravity.END + addView(Button(this@FloatingRecorderOverlayService).apply { + text = getString(R.string.keep_default_name) + setOnClickListener { removeRenameOverlay() } + }) + addView(Button(this@FloatingRecorderOverlayService).apply { + text = getString(R.string.btn_save) + setOnClickListener { saveRename(record, input.text.toString(), error) } + }) + }) + } + + val params = WindowManager.LayoutParams( + panelWidth, + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + x = position.x + y = position.y + softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + } + + // Only the non-editable header starts a drag; the text field and buttons keep their + // normal focus/click behavior, which is important because this overlay owns keyboard input. + (panel.getChildAt(0) as View).setOnTouchListener(RenameOverlayTouchListener(params)) + + renameView = panel + windowManager.addView(panel, params) + panel.post { clampAndPersistRenamePosition(panel, params, persist = false) } + input.requestFocus() + input.post { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + } + } + + private inner class RenameOverlayTouchListener( + private val params: WindowManager.LayoutParams, + ) : View.OnTouchListener { + private val touchSlop = ViewConfiguration.get(this@FloatingRecorderOverlayService).scaledTouchSlop + private var downRawX = 0f + private var downRawY = 0f + private var startX = 0 + private var startY = 0 + private var dragging = false + + override fun onTouch(view: View, event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downRawX = event.rawX + downRawY = event.rawY + startX = params.x + startY = params.y + dragging = false + return true + } + MotionEvent.ACTION_MOVE -> { + val deltaX = event.rawX - downRawX + val deltaY = event.rawY - downRawY + val movedEnough = abs(deltaX) > touchSlop || abs(deltaY) > touchSlop + if (movedEnough) { + dragging = true + params.x = startX + deltaX.toInt() + params.y = startY + deltaY.toInt() + clampAndPersistRenamePosition(view.rootView, params, persist = false) + } + return true + } + MotionEvent.ACTION_UP -> { + if (dragging) { + clampAndPersistRenamePosition(view.rootView, params, persist = true) + } else { + view.performClick() + } + return true + } + MotionEvent.ACTION_CANCEL -> { + if (dragging) { + clampAndPersistRenamePosition(view.rootView, params, persist = true) + } + return true + } + } + return false + } + } + + private fun clampAndPersistRenamePosition( + view: View, + params: WindowManager.LayoutParams, + persist: Boolean, + ) { + val metrics = resources.displayMetrics + val clamped = clampOverlayPosition( + savedX = params.x, + savedY = params.y, + screenWidth = metrics.widthPixels, + screenHeight = metrics.heightPixels, + overlayWidth = params.width.takeIf { it > 0 } ?: view.width, + overlayHeight = view.height.takeIf { it > 0 } ?: dp(RENAME_PANEL_ESTIMATED_HEIGHT_DP), + ) + params.x = clamped.x + params.y = clamped.y + runCatching { windowManager.updateViewLayout(view, params) } + if (persist) { + prefs.floatingRecorderRenameOverlayX = clamped.x + prefs.floatingRecorderRenameOverlayY = clamped.y + } + } + + private fun calculateRenamePanelWidth(screenWidthPx: Int): Int { + return calculateBoundedOverlayWidth( + screenWidth = screenWidthPx, + horizontalMargin = dp(16) * 2, + minimumWidth = dp(RENAME_PANEL_MIN_WIDTH_DP), + maximumWidth = dp(RENAME_PANEL_MAX_WIDTH_DP), + ) + } + + private fun saveRename(record: Record, newName: String, error: TextView) { + val trimmed = newName.trim() + if (trimmed.isEmpty()) { + error.text = getString(R.string.msg_name_cannot_be_empty) + error.visibility = View.VISIBLE + return + } + + serviceScope.launch { + val success = if (trimmed == record.name) { + true + } else { + recordsDataSource.renameRecord(record, trimmed) + } + withContext(ioDispatcher) { + iconView?.post { + if (success) { + removeRenameOverlay() + } else { + error.text = getString(R.string.msg_file_operation_failed) + error.visibility = View.VISIBLE + } + } + } + } + } + + private fun removeRenameOverlay() { + renameView?.let { view -> + runCatching { windowManager.removeView(view) } + } + renameView = null + } + + private fun bindRecordingService() { + if (!isRecordingServiceBound) { + bindService( + Intent(this, AudioRecordingService::class.java), + recordingServiceConnection, + Context.BIND_AUTO_CREATE, + ) + } + } + + private fun unbindRecordingService() { + if (isRecordingServiceBound) { + unbindService(recordingServiceConnection) + } + isRecordingServiceBound = false + recordingService = null + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_floating_recorder_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.notification_channel_floating_recorder_description) + setShowBadge(false) + setSound(null, null) + } + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .createNotificationChannel(channel) + } + } + + private fun startForegroundServiceNotification() { + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun buildNotification(): Notification { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, HomeActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) }, + flags, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.floating_recorder_notification_title)) + .setContentText(getString(R.string.floating_recorder_notification_text)) + .setSmallIcon(R.drawable.ic_record_rec) + .setContentIntent(contentIntent) + .setOngoing(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .setSound(null) + .build() + } + + private fun iconBubbleDrawable(color: Int): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + } + } + + private fun recordDiscDrawable(): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(RECORDING_ICON_COLOR) + // Only the central disc has a white contour; the outer bubble stays pure state color. + setStroke(dp(4), Color.WHITE) + } + } + + private fun roundedDrawable(color: Int, radius: Float): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = radius + setColor(color) + } + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + companion object { + private const val CHANNEL_ID = "floating_recorder_overlay_channel" + private const val NOTIFICATION_ID = 1004 + private const val ACTION_STOP = "com.dimowner.audiorecorder.ACTION_STOP_FLOATING_RECORDER_OVERLAY" + private const val SCALE_FEEDBACK_DURATION_MS = 160L + private const val SAVE_FEEDBACK_DURATION_MS = 3000L + private const val RENAME_PANEL_ESTIMATED_HEIGHT_DP = 220 + private const val RENAME_PANEL_MIN_WIDTH_DP = 240 + private const val RENAME_PANEL_MAX_WIDTH_DP = 360 + private val IDLE_ICON_COLOR = Color.DKGRAY + private val RECORDING_ICON_COLOR = Color.RED + + fun startService(context: Context) { + val intent = Intent(context, FloatingRecorderOverlayService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stopService(context: Context) { + context.stopService(Intent(context, FloatingRecorderOverlayService::class.java).apply { + action = ACTION_STOP + }) + } + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecision.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecision.kt new file mode 100644 index 00000000..8aa50a66 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecision.kt @@ -0,0 +1,18 @@ +package com.dimowner.audiorecorder.v2.app.settings + +internal enum class FloatingRecorderOverlayEnableAction { + Enable, + RequestMicrophonePermission, + OpenOverlayPermissionSettings, +} + +internal fun decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission: Boolean, + hasOverlayPermission: Boolean, +): FloatingRecorderOverlayEnableAction { + return when { + !hasMicrophonePermission -> FloatingRecorderOverlayEnableAction.RequestMicrophonePermission + !hasOverlayPermission -> FloatingRecorderOverlayEnableAction.OpenOverlayPermissionSettings + else -> FloatingRecorderOverlayEnableAction.Enable + } +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt index 367d147b..0a90b40b 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.OutlinedButton @@ -139,7 +140,6 @@ fun SettingsItemCheckBox( onCheckedChange: ((Boolean) -> Unit), enabled: Boolean = true, ) { - val checkState = remember { mutableStateOf(checked) } Row( modifier = Modifier .wrapContentHeight() @@ -169,9 +169,8 @@ fun SettingsItemCheckBox( ), ) Switch( - checked = checkState.value, + checked = checked, onCheckedChange = { - checkState.value = it onCheckedChange(it) }, enabled = enabled, @@ -180,6 +179,32 @@ fun SettingsItemCheckBox( } } +@Composable +fun FloatingRecorderOverlayPermissionDialog( + onOpenSettings: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { + Text(text = stringResource(id = R.string.floating_recorder_overlay_permission_title)) + }, + text = { + Text(text = stringResource(id = R.string.floating_recorder_overlay_permission_message)) + }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text(text = stringResource(id = R.string.open_overlay_settings)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = R.string.btn_cancel)) + } + }, + onDismissRequest = onDismiss, + ) +} + @Preview(showBackground = true) @Composable fun SettingsItemCheckBoxPreview() { @@ -916,4 +941,4 @@ private fun getTestChips(): List> { ChipItem(id = 2, value = SampleRate.SR22050, name = "22050", true), ChipItem(id = 3, value = SampleRate.SR32000, name = "32000", false), ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt index ccc2af39..bbaa722f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt @@ -16,9 +16,13 @@ package com.dimowner.audiorecorder.v2.app.settings +import android.Manifest import android.os.Build +import android.content.pm.PackageManager import android.text.format.Formatter import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -73,6 +77,9 @@ import com.dimowner.audiorecorder.v2.data.model.RecordingFormat import com.dimowner.audiorecorder.v2.data.model.SampleRate import timber.log.Timber import androidx.compose.ui.platform.LocalResources +import androidx.core.content.ContextCompat +import com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayPermission +import com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayService @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,6 +93,8 @@ internal fun SettingsScreen( val openInfoDialog = remember { mutableStateOf(false) } val openWarningDialog = remember { mutableStateOf(false) } + val openOverlayPermissionDialog = remember { mutableStateOf(false) } + val shouldEnableFloatingOverlayAfterPermission = remember { mutableStateOf(false) } val infoText = remember { mutableStateOf("") } val infoTextAnnotated = remember { mutableStateOf(null) } val warningText = remember { mutableStateOf("") } @@ -94,6 +103,58 @@ internal fun SettingsScreen( val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + fun hasMicrophonePermission(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + } + + fun setFloatingOverlayEnabled(enabled: Boolean) { + onAction(SettingsScreenAction.SetFloatingRecorderOverlayEnabled(enabled)) + if (enabled) { + FloatingRecorderOverlayService.startService(context.applicationContext) + } else { + FloatingRecorderOverlayService.stopService(context.applicationContext) + } + } + + fun enableFloatingOverlayIfAllowed() { + if (hasMicrophonePermission() && FloatingRecorderOverlayPermission.canDrawOverlays(context)) { + setFloatingOverlayEnabled(true) + } + } + + val overlayPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { + if (shouldEnableFloatingOverlayAfterPermission.value) { + shouldEnableFloatingOverlayAfterPermission.value = false + enableFloatingOverlayIfAllowed() + } + } + + val microphonePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + when (decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = true, + hasOverlayPermission = FloatingRecorderOverlayPermission.canDrawOverlays(context), + )) { + FloatingRecorderOverlayEnableAction.Enable -> { + setFloatingOverlayEnabled(true) + } + FloatingRecorderOverlayEnableAction.OpenOverlayPermissionSettings -> { + openOverlayPermissionDialog.value = true + } + FloatingRecorderOverlayEnableAction.RequestMicrophonePermission -> Unit + } + } else { + shouldEnableFloatingOverlayAfterPermission.value = false + } + } + ComposableLifecycle { _, event -> when (event) { Lifecycle.Event.ON_CREATE -> { @@ -106,6 +167,11 @@ internal fun SettingsScreen( Lifecycle.Event.ON_RESUME -> { Timber.d("SettingsScreen: On Resume") + if (uiState.isFloatingRecorderOverlayEnabled + && !FloatingRecorderOverlayPermission.canDrawOverlays(context) + ) { + setFloatingOverlayEnabled(false) + } } Lifecycle.Event.ON_PAUSE -> { @@ -176,6 +242,34 @@ internal fun SettingsScreen( { onAction(SettingsScreenAction.SetKeepScreenOn(it)) }) + SettingsItemCheckBox( + checked = uiState.isFloatingRecorderOverlayEnabled, + label = stringResource(R.string.floating_recorder_button), + iconRes = R.drawable.ic_floating_record, + onCheckedChange = { enabled -> + if (!enabled) { + shouldEnableFloatingOverlayAfterPermission.value = false + setFloatingOverlayEnabled(false) + } else { + when (decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = hasMicrophonePermission(), + hasOverlayPermission = FloatingRecorderOverlayPermission.canDrawOverlays(context), + )) { + FloatingRecorderOverlayEnableAction.Enable -> { + setFloatingOverlayEnabled(true) + } + FloatingRecorderOverlayEnableAction.RequestMicrophonePermission -> { + shouldEnableFloatingOverlayAfterPermission.value = true + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + FloatingRecorderOverlayEnableAction.OpenOverlayPermissionSettings -> { + shouldEnableFloatingOverlayAfterPermission.value = true + openOverlayPermissionDialog.value = true + } + } + } + }, + ) SettingsItemCheckBox( uiState.isShowRenameDialog, stringResource(R.string.ask_to_rename), @@ -297,6 +391,20 @@ internal fun SettingsScreen( if (openWarningDialog.value) { SettingsWarningDialog(openWarningDialog, warningText.value) } + if (openOverlayPermissionDialog.value) { + FloatingRecorderOverlayPermissionDialog( + onOpenSettings = { + openOverlayPermissionDialog.value = false + overlayPermissionLauncher.launch( + FloatingRecorderOverlayPermission.overlayPermissionSettingsIntent(context) + ) + }, + onDismiss = { + openOverlayPermissionDialog.value = false + shouldEnableFloatingOverlayAfterPermission.value = false + }, + ) + } } } } @@ -466,6 +574,7 @@ fun SettingsScreenPreview() { isDarkTheme = false, isAppV2 = false, isKeepScreenOn = false, + isFloatingRecorderOverlayEnabled = false, isShowRenameDialog = true, isRecordingSettingEditable = true, nameFormats = listOf(NameFormatItem(NameFormat.Record, "Name text")), diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt index 87babe87..eefffdeb 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt @@ -31,6 +31,7 @@ data class SettingsState( val isDarkTheme: Boolean, val isAppV2: Boolean, val isKeepScreenOn: Boolean, + val isFloatingRecorderOverlayEnabled: Boolean, val isShowRenameDialog: Boolean, val isRecordingSettingEditable: Boolean, val nameFormats: List, @@ -74,4 +75,4 @@ data class ChipItem( data class NameFormatItem( val nameFormat: NameFormat, val nameText: String, -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt index a0d704cf..6fd92db0 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt @@ -96,6 +96,7 @@ internal class SettingsViewModel @Inject constructor( isDarkTheme = prefs.isDarkTheme, isAppV2 = prefs.isAppV2, isKeepScreenOn = prefs.isKeepScreenOn, + isFloatingRecorderOverlayEnabled = prefs.isFloatingRecorderOverlayEnabled, isShowRenameDialog = prefs.askToRenameAfterRecordingStopped, isRecordingSettingEditable = true, selectedNameFormat = prefs.settingNamingFormat.toNameFormatItem(), @@ -222,6 +223,11 @@ internal class SettingsViewModel @Inject constructor( _state.value = _state.value.copy(isKeepScreenOn = value) } + fun setFloatingRecorderOverlayEnabled(value: Boolean) { + prefs.isFloatingRecorderOverlayEnabled = value + _state.value = _state.value.copy(isFloatingRecorderOverlayEnabled = value) + } + fun setShowRenamingDialog(value: Boolean) { prefs.askToRenameAfterRecordingStopped = value _state.value = _state.value.copy(isShowRenameDialog = value) @@ -411,6 +417,9 @@ internal class SettingsViewModel @Inject constructor( is SettingsScreenAction.SetDynamicTheme -> setDynamicTheme(action.value) is SettingsScreenAction.SetDarkTheme -> setDarkTheme(action.value) is SettingsScreenAction.SetKeepScreenOn -> setKeepScreenOn(action.value) + is SettingsScreenAction.SetFloatingRecorderOverlayEnabled -> { + setFloatingRecorderOverlayEnabled(action.value) + } is SettingsScreenAction.SetShowRenamingDialog -> setShowRenamingDialog(action.value) is SettingsScreenAction.SetNameFormat -> setNameFormat(action.value) SettingsScreenAction.ResetRecordingSettings -> resetRecordingSettings() @@ -478,6 +487,7 @@ internal sealed class SettingsScreenAction { data class SetDynamicTheme(val value: Boolean) : SettingsScreenAction() data class SetDarkTheme(val value: Boolean) : SettingsScreenAction() data class SetKeepScreenOn(val value: Boolean) : SettingsScreenAction() + data class SetFloatingRecorderOverlayEnabled(val value: Boolean) : SettingsScreenAction() data class SetShowRenamingDialog(val value: Boolean) : SettingsScreenAction() data class SetNameFormat(val value: NameFormatItem) : SettingsScreenAction() data object ResetRecordingSettings : SettingsScreenAction() diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt index b987e174..175f9ba2 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/WelcomeSetupSettingsScreen.kt @@ -365,6 +365,7 @@ fun WelcomeSetupSettingsScreenPreview() { appName = "App Name", appVersion = "1.0.0", maxRecordingDurationMinutes = 120, + isFloatingRecorderOverlayEnabled = false, recordAuthorName = "Author" ), {}) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt index 29911f90..466e43dc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt @@ -80,10 +80,14 @@ class AudioRecordingService : Service() { private const val ACTION_START_RECORDING = "com.dimowner.audiorecorder.ACTION_START_RECORDING" private const val ACTION_PAUSE_RESUME_RECORDING = "com.dimowner.audiorecorder.ACTION_PAUSE_RESUME_RECORDING" private const val ACTION_STOP_RECORDING = "com.dimowner.audiorecorder.ACTION_STOP_RECORDING" + private const val EXTRA_STARTED_FROM_FLOATING_OVERLAY = + "com.dimowner.audiorecorder.EXTRA_STARTED_FROM_FLOATING_OVERLAY" - fun startServiceForeground(context: Context) { + @JvmOverloads + fun startServiceForeground(context: Context, startedFromFloatingOverlay: Boolean = false) { val intent = Intent(context, AudioRecordingService::class.java).apply { action = ACTION_START_RECORDING + putExtra(EXTRA_STARTED_FROM_FLOATING_OVERLAY, startedFromFloatingOverlay) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) @@ -164,6 +168,9 @@ class AudioRecordingService : Service() { */ private var lastAvailableSpaceCheckTime: Long = 0L + /** True only for the current recording session if it was initiated by the overlay button. */ + private var currentRecordingStartedFromFloatingOverlay: Boolean = false + inner class ServiceBinder : Binder() { fun getService(): AudioRecordingService = this@AudioRecordingService } @@ -183,6 +190,10 @@ class AudioRecordingService : Service() { subscribeRecorderEvents() when (intent?.action) { ACTION_START_RECORDING -> { + currentRecordingStartedFromFloatingOverlay = intent.getBooleanExtra( + EXTRA_STARTED_FROM_FLOATING_OVERLAY, + false, + ) // Must call startForeground() synchronously before any async work // to satisfy the foreground service contract and avoid ANR. startForegroundWithNotification() @@ -520,6 +531,7 @@ class AudioRecordingService : Service() { emitEvent(AudioRecordingServiceEvent.RecordingStopped( recordId = recordedRecordId, recordName = record.name, + startedFromFloatingOverlay = currentRecordingStartedFromFloatingOverlay, )) decodeRecord( recordId = recordUpdated.id, @@ -551,6 +563,7 @@ class AudioRecordingService : Service() { recordingAmplitudes.clear() totalRecordingSampleCount = 0 recordingFullDataBuffer.reset() + currentRecordingStartedFromFloatingOverlay = false _recordingState.value = RecordingServiceState() stopNotificationUpdates() stopForeground(STOP_FOREGROUND_REMOVE) @@ -864,5 +877,9 @@ sealed class AudioRecordingServiceEvent { * Emitted after the recording file has been successfully saved and * [prefs.activeRecordId] has been set to [recordId]. */ - data class RecordingStopped(val recordId: Long, val recordName: String?) : AudioRecordingServiceEvent() + data class RecordingStopped( + val recordId: Long, + val recordName: String?, + val startedFromFloatingOverlay: Boolean = false, + ) : AudioRecordingServiceEvent() } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt index bb5972ee..745219b6 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -43,6 +43,12 @@ interface PrefsV2 { var isKeepScreenOn: Boolean + var isFloatingRecorderOverlayEnabled: Boolean + var floatingRecorderOverlayX: Int + var floatingRecorderOverlayY: Int + var floatingRecorderRenameOverlayX: Int + var floatingRecorderRenameOverlayY: Int + var recordsSortOrder: SortOrder var isDynamicTheme: Boolean diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt index dcc77f5e..98807555 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -142,6 +142,46 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont } } + override var isFloatingRecorderOverlayEnabled: Boolean + get() = sharedPreferences.getBoolean(PREF_KEY_FLOATING_RECORDER_OVERLAY_ENABLED, false) + set(value) { + sharedPreferences.edit { + putBoolean(PREF_KEY_FLOATING_RECORDER_OVERLAY_ENABLED, value) + } + } + + override var floatingRecorderOverlayX: Int + get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_X, -1) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_X, value) + } + } + + override var floatingRecorderOverlayY: Int + get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_Y, -1) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_Y, value) + } + } + + override var floatingRecorderRenameOverlayX: Int + get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_X, -1) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_X, value) + } + } + + override var floatingRecorderRenameOverlayY: Int + get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_Y, -1) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_Y, value) + } + } + override var recordsSortOrder: SortOrder get() = sharedPreferences.getString( PREF_KEY_RECORDS_SORT_ORDER, @@ -314,5 +354,13 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont private const val PREF_KEY_MAX_RECORDING_DURATION_MILLS = "pref_key_max_recording_duration_mills" private const val PREF_KEY_SETTING_AUDIO_SOURCE = "pref_key_setting_audio_source" private const val PREF_KEY_RECORD_AUTHOR_NAME = "pref_key_record_author_name" + private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_ENABLED = + "pref_key_floating_recorder_overlay_enabled" + private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_X = "pref_key_floating_recorder_overlay_x" + private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_Y = "pref_key_floating_recorder_overlay_y" + private const val PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_X = + "pref_key_floating_recorder_rename_overlay_x" + private const val PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_Y = + "pref_key_floating_recorder_rename_overlay_y" } } diff --git a/app/src/main/res/drawable/ic_floating_record.xml b/app/src/main/res/drawable/ic_floating_record.xml new file mode 100644 index 00000000..bbf8055b --- /dev/null +++ b/app/src/main/res/drawable/ic_floating_record.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a22d636c..4924591c 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -49,6 +49,15 @@ Записите по-дълги от 2 часа не се препоръчват, тъй като може да загубите целия записан напредък при грешка Екранът да е винаги включен при запис + Плаващ бутон за запис + Разрешете плаващия бутон за запис + Android изисква разрешението „Показване върху други приложения“, за да може Audio Recorder да показва плаващия бутон за запис върху приложения за навигация или четене. + Отвори настройките за наслагване + Запази името по подразбиране + Плаващият бутон за запис е активен + Докоснете плаващия бутон, за да започнете или спрете запис. + Плаващ запис + Поддържа плаващия бутон за запис достъпен върху други приложения. Автор на записите Автор на записа Автор diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9f61dd51..f3cbeb5d 100755 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -63,6 +63,15 @@ No es recomanen gravacions de més de 2 hores, ja que podríeu perdre tot el progrés gravat si es produeix un error Manté la pantalla encesa mentre es grava + Botó flotant de gravació + Permet el botó flotant de gravació + Android requereix el permís Mostra sobre altres aplicacions perquè Audio Recorder pugui mostrar el botó flotant de gravació sobre aplicacions de navegació o lectura. + Obre la configuració de superposició + Mantén el nom per defecte + El botó flotant de gravació està actiu + Toca el botó flotant per iniciar o aturar la gravació. + Gravador flotant + Manté el botó flotant de gravació disponible sobre altres aplicacions. Nom de l\'autor de les gravacions Autor de la gravació Nom de l\'autor diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 44361bfc..c4d30b03 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -66,6 +66,15 @@ Aufnahmen länger als 2 Stunden werden nicht empfohlen, da bei einem Fehler der gesamte Fortschritt verloren gehen kann Bildschirm während der Aufnahme eingeschaltet lassen + Schwebende Aufnahmetaste + Schwebende Aufnahmetaste erlauben + Android benötigt die Berechtigung Über anderen Apps einblenden, damit Audio Recorder die schwebende Aufnahmetaste über Navigations- oder Lese-Apps anzeigen kann. + Overlay-Einstellungen öffnen + Standardnamen behalten + Schwebende Aufnahmetaste ist aktiv + Tippen Sie auf die schwebende Taste, um die Aufnahme zu starten oder zu stoppen. + Schwebende Aufnahme + Hält die schwebende Aufnahmetaste über anderen Apps verfügbar. Autorenname der Aufnahmen Aufnahme-Autor Autorenname @@ -434,4 +443,3 @@ Zurck navigieren Menü - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 78eec1ac..c0a08743 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -65,6 +65,15 @@ No se recomiendan grabaciones de más de 2 horas ya que podría perder todo el progreso grabado si ocurre un error Mantener pantalla ENCENDIDA al grabar + Botón flotante de grabación + Permitir botón flotante de grabación + Android requiere el permiso Mostrar sobre otras apps para que Audio Recorder pueda mostrar el botón flotante de grabación sobre apps de navegación o lectura. + Abrir ajustes de superposición + Mantener nombre predeterminado + El botón flotante de grabación está activo + Toca el botón flotante para iniciar o detener la grabación. + Grabador flotante + Mantiene disponible el botón flotante de grabación sobre otras apps. Nombre del autor de grabaciones Autor de grabación Nombre del autor @@ -434,4 +443,3 @@ Volver Menú - diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a60791e9..680802e9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -48,6 +48,15 @@ Les enregistrements de plus de 2 heures ne sont pas recommandés car vous risquez de perdre toute progression en cas d\'erreur Laisser l\'écran allumé pendant l\'enregistrement + Bouton flottant d\'enregistrement + Autoriser le bouton flottant d\'enregistrement + Android exige l\'autorisation Afficher par-dessus d\'autres applications pour qu\'Audio Recorder puisse afficher le bouton flottant d\'enregistrement au-dessus des applications de navigation ou de lecture. + Ouvrir les paramètres de superposition + Conserver le nom par défaut + Le bouton flottant d\'enregistrement est actif + Touchez le bouton flottant pour démarrer ou arrêter l\'enregistrement. + Enregistreur flottant + Garde le bouton flottant d\'enregistrement disponible au-dessus des autres applications. Nom de l\'auteur des enregistrements Auteur de l\'enregistrement Nom de l\'auteur diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d3ed2004..77f957b2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -66,6 +66,15 @@ Le registrazioni superiori a 2 ore non sono consigliate: un errore potrebbe causare la perdita di tutti i dati registrati Tieni lo schermo acceso durante la registrazione + Pulsante flottante di registrazione + Consenti il pulsante flottante di registrazione + Android richiede l\'autorizzazione Mostra sopra altre app affinché Audio Recorder possa mostrare il pulsante flottante di registrazione sopra app di navigazione o lettura. + Apri impostazioni overlay + Mantieni nome predefinito + Il pulsante flottante di registrazione è attivo + Tocca il pulsante flottante per avviare o interrompere la registrazione. + Registratore flottante + Mantiene disponibile il pulsante flottante di registrazione sopra altre app. Nome autore delle registrazioni Autore della registrazione Nome autore @@ -434,4 +443,3 @@ Torna indietro Menu - diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 3a5a5923..15f8be1f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -67,6 +67,15 @@ 2時間を超える録音はエラー発生時にすべての進捗が失われる可能性があるため、推奨されません 録音中は画面をオンに保つ + フローティング録音ボタン + フローティング録音ボタンを許可 + Audio Recorder がナビゲーションアプリや読書アプリの上にフローティング録音ボタンを表示するには、Android の「他のアプリの上に表示」権限が必要です。 + オーバーレイ設定を開く + 既定の名前を保持 + フローティング録音ボタンが有効です + フローティングボタンをタップして録音を開始または停止します。 + フローティング録音 + 他のアプリの上でフローティング録音ボタンを使用できるようにします。 録音の作成者名 録音の作成者 作成者名 @@ -435,4 +444,3 @@ 前の画面に戻る メニュー - diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 00ae122e..1d8ffcaf 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -71,6 +71,15 @@ Nagrania dłuższe niż 2 godziny nie są zalecane — w przypadku błędu możesz utracić cały postęp nagrania Utrzymuj ekran włączony podczas nagrywania + Pływający przycisk nagrywania + Zezwól na pływający przycisk nagrywania + Android wymaga uprawnienia Wyświetlanie nad innymi aplikacjami, aby Audio Recorder mógł pokazywać pływający przycisk nagrywania nad aplikacjami do nawigacji lub czytania. + Otwórz ustawienia nakładki + Zachowaj nazwę domyślną + Pływający przycisk nagrywania jest aktywny + Dotknij pływającego przycisku, aby rozpocząć lub zatrzymać nagrywanie. + Pływające nagrywanie + Utrzymuje pływający przycisk nagrywania dostępny nad innymi aplikacjami. Imię i nazwisko autora nagrań Autor nagrania Imię i nazwisko autora @@ -453,4 +462,3 @@ Przejdź wstecz Menu - diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index cf603474..bdc781ae 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -64,6 +64,15 @@ Gravações com mais de 2 horas não são recomendadas, pois você pode perder todo o progresso em caso de erro Manter a tela ligada durante a gravação + Botão flutuante de gravação + Permitir botão flutuante de gravação + O Android exige a permissão Exibir sobre outros apps para que o Audio Recorder possa mostrar o botão flutuante de gravação sobre apps de navegação ou leitura. + Abrir configurações de sobreposição + Manter nome padrão + O botão flutuante de gravação está ativo + Toque no botão flutuante para iniciar ou parar a gravação. + Gravador flutuante + Mantém o botão flutuante de gravação disponível sobre outros apps. Nome do autor das gravações Autor da gravação Nome do autor @@ -432,4 +441,3 @@ Voltar Menu - diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 7bf9eb54..927e00e9 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -67,6 +67,15 @@ Gravações com mais de 2 horas não são recomendadas, pois pode perder todo o progresso gravado se ocorrer um erro Manter o ecrã ligado durante a gravação + Botão flutuante de gravação + Permitir botão flutuante de gravação + O Android requer a permissão Mostrar sobre outras aplicações para que o Audio Recorder possa mostrar o botão flutuante de gravação sobre aplicações de navegação ou leitura. + Abrir definições de sobreposição + Manter nome predefinido + O botão flutuante de gravação está ativo + Toque no botão flutuante para iniciar ou parar a gravação. + Gravador flutuante + Mantém o botão flutuante de gravação disponível sobre outras aplicações. Nome do autor das gravações Autor da gravação Nome do autor @@ -435,4 +444,3 @@ Navegar para trás Menu - diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9e5f3c88..afeb3b8f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -51,6 +51,15 @@ Записи длиннее 2 часов не рекомендуются, так как при ошибке вы можете потерять всё записанное Оставлять экран включенным во время записи + Плавающая кнопка записи + Разрешите плавающую кнопку записи + Android требует разрешение Показывать поверх других приложений, чтобы Audio Recorder мог отображать плавающую кнопку записи поверх приложений навигации или чтения. + Открыть настройки наложения + Оставить имя по умолчанию + Плавающая кнопка записи активна + Нажмите плавающую кнопку, чтобы начать или остановить запись. + Плавающая запись + Оставляет плавающую кнопку записи доступной поверх других приложений. Имя автора записей Автор записи Имя автора diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ab7ec56b..b653a082 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -64,6 +64,15 @@ 2 saatten uzun kayıtlar önerilmez, bir hata oluşursa tüm kaydınızı kaybedebilirsiniz Kayıttayken ekranı uyanık tut + Kayan kayıt düğmesi + Kayan kayıt düğmesine izin ver + Audio Recorder\'ın kayan kayıt düğmesini navigasyon veya okuma uygulamalarının üzerinde gösterebilmesi için Android Diğer uygulamaların üzerinde göster iznini gerektirir. + Üstte gösterme ayarlarını aç + Varsayılan adı koru + Kayan kayıt düğmesi etkin + Kaydı başlatmak veya durdurmak için kayan düğmeye dokunun. + Kayan kayıt + Kayan kayıt düğmesini diğer uygulamaların üzerinde kullanılabilir tutar. Kayıt sahibi adı Kayıt yazarı Yazar adı diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index d6f9db91..577ab686 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -28,6 +28,15 @@ Залишати екран увімкненим під час запису + Плаваюча кнопка запису + Дозвольте плаваючу кнопку запису + Android вимагає дозвіл Показувати поверх інших програм, щоб Audio Recorder міг показувати плаваючу кнопку запису поверх програм навігації або читання. + Відкрити налаштування накладання + Залишити назву за замовчуванням + Плаваюча кнопка запису активна + Торкніться плаваючої кнопки, щоб почати або зупинити запис. + Плаваючий запис + Залишає плаваючу кнопку запису доступною поверх інших програм. Ім\'я автора записів Автор запису Ім\'я автора diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index eef8daa9..b2d49fef 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -64,6 +64,15 @@ 建議錄製時長不超過 2 小時,以免發生錯誤導致所有已錄製的內容遺失 錄製期間保持螢幕開啟 + 浮動錄音按鈕 + 允許浮動錄音按鈕 + Android 需要「顯示在其他應用程式上層」權限,Audio Recorder 才能在導航或閱讀應用程式上方顯示浮動錄音按鈕。 + 開啟浮動視窗設定 + 保留預設名稱 + 浮動錄音按鈕已啟用 + 點選浮動按鈕以開始或停止錄音。 + 浮動錄音 + 讓浮動錄音按鈕可在其他應用程式上方使用。 錄音作者名稱 錄音作者 作者名稱 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 3be37681..2b36bff5 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -45,6 +45,15 @@ 不建议录制超过 2 小时,因为发生错误时可能丢失所有录音进度 录制时保持屏幕开启 + 悬浮录音按钮 + 允许悬浮录音按钮 + Android 需要“显示在其他应用上层”权限,Audio Recorder 才能在导航或阅读应用上方显示悬浮录音按钮。 + 打开悬浮窗设置 + 保留默认名称 + 悬浮录音按钮已启用 + 点击悬浮按钮开始或停止录音。 + 悬浮录音 + 让悬浮录音按钮可在其他应用上方使用。 录音作者名称 录音作者 作者名称 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5912c54d..1e48c9d2 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,15 @@ Recordings longer than 2 hours are not recommended as you may lose all recorded progress if an error occurs Keep screen ON while recording + Floating recorder button + Allow floating recorder button + Android requires Display over other apps permission before Audio Recorder can show the floating recorder button above navigation or reading apps. + Open overlay settings + Keep default + Floating recorder button is active + Tap the floating button to start or stop recording. + Floating recorder + Keeps the floating recorder button available over other apps. Records author name Record author Author name diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt new file mode 100644 index 00000000..9926ed05 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -0,0 +1,109 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FloatingRecorderOverlayGeometryTest { + + @Test + fun `clampOverlayPosition uses default when saved position is unset`() { + val position = clampOverlayPosition( + savedX = -1, + savedY = -1, + screenWidth = 1080, + screenHeight = 1920, + overlayWidth = 96, + overlayHeight = 96, + ) + + assertEquals(960, position.x) + assertEquals(912, position.y) + } + + @Test + fun `clampOverlayPosition keeps saved position inside visible bounds`() { + val position = clampOverlayPosition( + savedX = 200, + savedY = 300, + screenWidth = 1080, + screenHeight = 1920, + overlayWidth = 96, + overlayHeight = 96, + ) + + assertEquals(200, position.x) + assertEquals(300, position.y) + } + + @Test + fun `clampOverlayPosition clamps saved position outside visible bounds`() { + val position = clampOverlayPosition( + savedX = 5000, + savedY = -50, + screenWidth = 1080, + screenHeight = 1920, + overlayWidth = 96, + overlayHeight = 96, + ) + + assertEquals(984, position.x) + assertEquals(0, position.y) + } + + @Test + fun `calculateBoundedOverlayWidth caps panel width on large screens`() { + val width = calculateBoundedOverlayWidth( + screenWidth = 1080, + horizontalMargin = 32, + minimumWidth = 240, + maximumWidth = 360, + ) + + assertEquals(360, width) + } + + @Test + fun `calculateBoundedOverlayWidth preserves side margins when possible`() { + val width = calculateBoundedOverlayWidth( + screenWidth = 320, + horizontalMargin = 32, + minimumWidth = 240, + maximumWidth = 360, + ) + + assertEquals(288, width) + } + + @Test + fun `calculateBoundedOverlayWidth uses full width when screen is narrower than minimum`() { + val width = calculateBoundedOverlayWidth( + screenWidth = 200, + horizontalMargin = 32, + minimumWidth = 240, + maximumWidth = 360, + ) + + assertEquals(200, width) + } + + @Test + fun `calculateSaveFeedbackColor starts with vivid red`() { + val color = calculateSaveFeedbackColor(progress = 0f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFFFF0000.toInt(), color) + } + + @Test + fun `calculateSaveFeedbackColor produces desaturated rainbow color mid animation`() { + val color = calculateSaveFeedbackColor(progress = 0.5f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFF80FFFF.toInt(), color) + } + + @Test + fun `calculateSaveFeedbackColor ends at idle color`() { + val color = calculateSaveFeedbackColor(progress = 1f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFF444444.toInt(), color) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermissionTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermissionTest.kt new file mode 100644 index 00000000..9bc2c746 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermissionTest.kt @@ -0,0 +1,25 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import android.provider.Settings +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.util.TestARApplication +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = TestARApplication::class, sdk = [36]) +class FloatingRecorderOverlayPermissionTest { + + @Test + fun `overlay permission settings intent targets this app package`() { + val context = RuntimeEnvironment.getApplication() + + val intent = FloatingRecorderOverlayPermission.overlayPermissionSettingsIntent(context) + + assertEquals(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, intent.action) + assertEquals("package:${context.packageName}", intent.data.toString()) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecisionTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecisionTest.kt new file mode 100644 index 00000000..69f262b0 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecisionTest.kt @@ -0,0 +1,47 @@ +package com.dimowner.audiorecorder.v2.app.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FloatingRecorderOverlaySettingsDecisionTest { + + @Test + fun `decideFloatingRecorderOverlayEnableAction enables when all permissions are granted`() { + val result = decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = true, + hasOverlayPermission = true, + ) + + assertEquals(FloatingRecorderOverlayEnableAction.Enable, result) + } + + @Test + fun `decideFloatingRecorderOverlayEnableAction requests microphone before enabling`() { + val result = decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = false, + hasOverlayPermission = true, + ) + + assertEquals(FloatingRecorderOverlayEnableAction.RequestMicrophonePermission, result) + } + + @Test + fun `decideFloatingRecorderOverlayEnableAction opens overlay settings when overlay permission is missing`() { + val result = decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = true, + hasOverlayPermission = false, + ) + + assertEquals(FloatingRecorderOverlayEnableAction.OpenOverlayPermissionSettings, result) + } + + @Test + fun `decideFloatingRecorderOverlayEnableAction requests microphone first when both permissions are missing`() { + val result = decideFloatingRecorderOverlayEnableAction( + hasMicrophonePermission = false, + hasOverlayPermission = false, + ) + + assertEquals(FloatingRecorderOverlayEnableAction.RequestMicrophonePermission, result) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt new file mode 100644 index 00000000..bf342270 --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt @@ -0,0 +1,80 @@ +package com.dimowner.audiorecorder.v2.data + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.dimowner.audiorecorder.AppConstants.PREF_NAME +import com.dimowner.audiorecorder.util.TestARApplication +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = TestARApplication::class, sdk = [36]) +class PrefsV2ImplTest { + + private lateinit var context: Context + private lateinit var prefs: PrefsV2Impl + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + prefs = PrefsV2Impl(context) + } + + @Test + fun `floating recorder overlay is disabled by default`() { + assertFalse(prefs.isFloatingRecorderOverlayEnabled) + } + + @Test + fun `floating recorder overlay enabled value persists`() { + prefs.isFloatingRecorderOverlayEnabled = true + + val reloadedPrefs = PrefsV2Impl(context) + + assertTrue(reloadedPrefs.isFloatingRecorderOverlayEnabled) + } + + @Test + fun `floating recorder overlay position defaults to unset`() { + assertEquals(-1, prefs.floatingRecorderOverlayX) + assertEquals(-1, prefs.floatingRecorderOverlayY) + } + + @Test + fun `floating recorder rename overlay position defaults to unset`() { + assertEquals(-1, prefs.floatingRecorderRenameOverlayX) + assertEquals(-1, prefs.floatingRecorderRenameOverlayY) + } + + @Test + fun `floating recorder overlay position persists`() { + prefs.floatingRecorderOverlayX = 42 + prefs.floatingRecorderOverlayY = 84 + + val reloadedPrefs = PrefsV2Impl(context) + + assertEquals(42, reloadedPrefs.floatingRecorderOverlayX) + assertEquals(84, reloadedPrefs.floatingRecorderOverlayY) + } + + @Test + fun `floating recorder rename overlay position persists`() { + prefs.floatingRecorderRenameOverlayX = 123 + prefs.floatingRecorderRenameOverlayY = 456 + + val reloadedPrefs = PrefsV2Impl(context) + + assertEquals(123, reloadedPrefs.floatingRecorderRenameOverlayX) + assertEquals(456, reloadedPrefs.floatingRecorderRenameOverlayY) + } +} diff --git a/docs/superpowers/mockups/floating-recorder-button-disc-mockup.html b/docs/superpowers/mockups/floating-recorder-button-disc-mockup.html new file mode 100644 index 00000000..94fc4d7b --- /dev/null +++ b/docs/superpowers/mockups/floating-recorder-button-disc-mockup.html @@ -0,0 +1,333 @@ + + + + + + Floating Recorder Button Mockup + + + +
+
+
+

Floating Recorder Button

+
+

+ Revised concept: no square app icon, no contour around the outer bubble. + The visual identity is a red disc with a white contour, sitting on a state-colored bubble. +

+
+ +
+
+
+
+
+
+
+ Idle + Grey outer bubble, constant red disc, white disc contour only. +
+
+ +
+
+
+
+
+
+ Recording + Outer bubble becomes red; no extra white ring around the bubble. +
+
+ +
+
+
+
+
+
+
+ Save Frames + The outer bubble cycles through RGB/rainbow colors during the 3-second save feedback. +
+
+ +
+
+
+
+
+
+ Animated Preview + Scale pulse and continuous rainbow-to-grey animation start together. +
+
+
+ +
+
+
+
+

Selected Shape

+

+ The only white contour is around the inner red recording disc. The outer bubble is pure state color, + so it reads clearly from peripheral vision without becoming a double-ring target. +

+
+
+ +
+
+
+

Animation Intent

+

+ On stop/save, the existing responsive inflate/deflate effect remains short, while the background color + continuously travels through rainbow colors for about 3 seconds and settles back to grey. +

+
+
+
+
+ + diff --git a/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-button-visuals.md b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-button-visuals.md new file mode 100644 index 00000000..2c204f3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-button-visuals.md @@ -0,0 +1,126 @@ +# Floating Recorder Overlay Button Visuals Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the simple floating recorder circle with an app-icon bubble and add a 3-second rainbow-to-grey save animation. + +**Architecture:** Keep the implementation in the existing native Android overlay service. Add a small pure Kotlin color helper for deterministic JVM tests, then use `GradientDrawable`, `ImageView`, `AnimatorSet`, and `ValueAnimator` from `FloatingRecorderOverlayService`. + +**Tech Stack:** Kotlin, Android Views, `GradientDrawable`, `ValueAnimator`, JUnit/Robolectric unit tests, Gradle debugConfig flavor. + +--- + +## File Structure + +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt` + - Responsibility: pure overlay geometry/color helper functions that do not depend on Android service state. +- Modify: `app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt` + - Responsibility: JVM tests for pure helpers, including save animation color milestones. +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` + - Responsibility: actual overlay view construction, state updates, and animations. + +### Task 1: Save Animation Color Helper + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt` +- Test: `app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt` + +- [ ] **Step 1: Write failing tests** + +Add tests that prove `calculateSaveFeedbackColor(progress, idleColor)` starts vivid red, reaches a visible desaturated non-red rainbow color mid-animation, and ends exactly at idle grey. + +```kotlin +@Test +fun `calculateSaveFeedbackColor starts with vivid red`() { + val color = calculateSaveFeedbackColor(progress = 0f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFFFF0000.toInt(), color) +} + +@Test +fun `calculateSaveFeedbackColor produces desaturated rainbow color mid animation`() { + val color = calculateSaveFeedbackColor(progress = 0.5f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFF80FFFF.toInt(), color) +} + +@Test +fun `calculateSaveFeedbackColor ends at idle color`() { + val color = calculateSaveFeedbackColor(progress = 1f, idleColor = 0xFF444444.toInt()) + + assertEquals(0xFF444444.toInt(), color) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew testDebugConfigDebugUnitTest --tests "com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayGeometryTest"` + +Expected: compile failure with unresolved reference `calculateSaveFeedbackColor`. + +- [ ] **Step 3: Implement the helper** + +Add a pure function that clamps progress to `0f..1f`, maps hue from red around the color wheel, progressively desaturates, and blends the final part to the idle grey so the final frame is exact. + +```kotlin +internal fun calculateSaveFeedbackColor(progress: Float, idleColor: Int): Int { + val clampedProgress = progress.coerceIn(0f, 1f) + if (clampedProgress >= 1f) return idleColor + + val hue = 360f * clampedProgress + val saturation = (1f - clampedProgress).coerceIn(0f, 1f) + val value = 1f + val hsvColor = Color.HSVToColor(floatArrayOf(hue, saturation, value)) + val settleProgress = ((clampedProgress - 0.85f) / 0.15f).coerceIn(0f, 1f) + return blendArgb(hsvColor, idleColor, settleProgress) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew testDebugConfigDebugUnitTest --tests "com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayGeometryTest"` + +Expected: tests pass. + +### Task 2: Bubble Icon and Save Animation + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` +- Test: `app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt` + +- [ ] **Step 1: Replace the simple vector circle content** + +In `addIconOverlay()`, use a `FrameLayout` bubble with `roundedDrawable(fillColor, radius, strokeColor, strokeWidth)` and an `ImageView` set to `R.mipmap.audio_recorder_logo`. + +- [ ] **Step 2: Preserve idle and recording state colors** + +Update `updateIconAppearance(recording)` so it changes only the bubble background fill color while keeping the white stroke and app icon stable. + +- [ ] **Step 3: Replace save feedback animation** + +Update `runSavedAnimation()` so it starts the current scale pulse and a 3-second `ValueAnimator.ofFloat(0f, 1f)` together. On each color frame, call `calculateSaveFeedbackColor(progress, IDLE_ICON_COLOR)` and update the bubble drawable. On animation end, force idle grey. + +- [ ] **Step 4: Run targeted tests and build** + +Run: `./gradlew testDebugConfigDebugUnitTest --tests "com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayGeometryTest"` + +Expected: tests pass. + +Run: `./gradlew assembleDebugConfigDebug` + +Expected: build succeeds and writes `app/build/outputs/apk/debugConfig/debug/app-debugConfig-debug.apk`. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt docs/superpowers/plans/2026-06-18-floating-recorder-overlay-button-visuals.md +git commit -m "feat: improve floating recorder button feedback" +``` + +## Self-Review + +- Spec coverage: covers app icon, white contour, grey idle, red recording, simultaneous scale and 3-second rainbow/desaturation save feedback, and no recording behavior changes. +- Placeholder scan: no TODO/TBD placeholders. +- Type consistency: all new helpers are in the overlay package and called from the existing service. diff --git a/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-disc-button.md b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-disc-button.md new file mode 100644 index 00000000..d4b84935 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay-disc-button.md @@ -0,0 +1,56 @@ +# Floating Recorder Overlay Disc Button Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the square app icon inside the floating recorder overlay with a small red disc with a white contour, while keeping the continuous outer-bubble save animation. + +**Architecture:** Keep the change localized to `FloatingRecorderOverlayService`. The outer bubble remains the animated state surface; the inner disc is a static child view that does not participate in the RGB save animation. + +**Tech Stack:** Kotlin, Android Views, `GradientDrawable`, existing Gradle debugConfig flavor. + +--- + +## File Structure + +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` + - Remove the launcher-icon `ImageView` from the overlay button. + - Add a centered static red disc view with white stroke. + - Remove the white stroke from the outer bubble drawable. +- Modify: `docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md` + - Keep documentation aligned with the approved mockup. +- Keep: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt` + - The existing continuous save color helper remains valid. + +### Task 1: Implement Disc-Only Button + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` +- Modify: `docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md` + +- [ ] **Step 1: Update overlay button child view** + +In `addIconOverlay()`, replace the `ImageView` with a plain centered `View` using `recordDiscDrawable()` and a 30dp square layout. The 30dp view includes the white stroke, leaving a visually smaller red center. + +- [ ] **Step 2: Update outer bubble drawable** + +Change `iconBubbleDrawable(color)` so it only fills the circular outer bubble and no longer calls `setStroke(...)`. + +- [ ] **Step 3: Add red disc drawable helper** + +Add `recordDiscDrawable()` returning an oval `GradientDrawable` with red fill and 4dp white stroke. + +- [ ] **Step 4: Verify** + +Run: `./gradlew testDebugConfigDebugUnitTest --tests "com.dimowner.audiorecorder.v2.app.overlay.FloatingRecorderOverlayGeometryTest"` + +Expected: pass. + +Run: `./gradlew assembleDebugConfigDebug` + +Expected: build succeeds and writes `app/build/outputs/apk/debugConfig/debug/app-debugConfig-debug.apk`. + +## Self-Review + +- Spec coverage: implements the approved no-app-icon, no-outer-contour, smaller red-disc mockup while preserving save animation behavior. +- Placeholder scan: no TODO/TBD placeholders. +- Type consistency: only service drawable helpers are changed; no new public API is introduced. diff --git a/docs/superpowers/plans/2026-06-18-floating-recorder-overlay.md b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay.md new file mode 100644 index 00000000..87eb294c --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-floating-recorder-overlay.md @@ -0,0 +1,75 @@ +# Floating Recorder Overlay Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a default-off, permission-gated floating recorder overlay that can start/stop V2 recordings above other apps. + +**Architecture:** Add small testable permission/settings helpers, persist overlay preference and position in `PrefsV2`, wire a V2 settings switch and startup hook, then implement a dedicated foreground `FloatingRecorderOverlayService` that owns only the `WindowManager` UI while delegating recording to `AudioRecordingService`. + +**Tech Stack:** Kotlin, Android Services, Hilt, SharedPreferences, Jetpack Compose settings UI, Robolectric/MockK unit tests. + +--- + +### Task 1: Preference And Permission Helpers + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt` +- Create: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermission.kt` +- Create: `app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt` +- Create: `app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayPermissionTest.kt` + +- [ ] Step 1: Write failing preference and permission tests. +- [ ] Step 2: Run targeted tests and verify they fail for missing APIs/classes. +- [ ] Step 3: Add `PrefsV2` overlay enabled/position properties and permission helper. +- [ ] Step 4: Re-run targeted tests and verify they pass. + +### Task 2: Settings Switch And Permission Dialog + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsState.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsViewModel.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsComponents.kt` +- Modify: `app/src/main/res/values/strings.xml` +- Create: `app/src/main/res/drawable/ic_floating_record.xml` +- Create: `app/src/test/java/com/dimowner/audiorecorder/v2/app/settings/FloatingRecorderOverlaySettingsDecisionTest.kt` + +- [ ] Step 1: Write failing decision-helper tests for proceed/request-microphone/open-overlay-settings. +- [ ] Step 2: Implement the decision helper and settings state/action wiring. +- [ ] Step 3: Add switch UI, permission dialog, overlay settings launcher, and microphone request launcher. +- [ ] Step 4: Re-run targeted tests and compile if needed. + +### Task 3: Overlay Service + +**Files:** +- Create: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/main/res/values/strings.xml` + +- [ ] Step 1: Add minimal service manifest/strings and compile check. +- [ ] Step 2: Implement foreground idle notification and overlay icon window. +- [ ] Step 3: Bind to `AudioRecordingService`, start recording on idle tap, stop on recording tap, and update icon from actual recorder state. +- [ ] Step 4: Persist drag position and clamp restored coordinates. +- [ ] Step 5: Implement success animation and rename overlay panel using `RecordsDataSource.renameRecord`. + +### Task 4: Startup And Service Control + +**Files:** +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/HomeActivity.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/settings/SettingsScreen.kt` +- Modify: `app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt` + +- [ ] Step 1: Start overlay service when app opens and preference/permission are both true. +- [ ] Step 2: Start service when settings switch is enabled and stop service when disabled. +- [ ] Step 3: Reconcile revoked overlay permission by disabling preference and not starting service. + +### Task 5: Verification + +**Files:** +- All touched implementation and test files. + +- [ ] Step 1: Run targeted new tests. +- [ ] Step 2: Run `./gradlew testDebugConfigDebugUnitTest`. +- [ ] Step 3: Inspect `git status --short` and summarize changes. diff --git a/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md b/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md new file mode 100644 index 00000000..aca4344c --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-button-visual-design.md @@ -0,0 +1,64 @@ +# Floating Recorder Overlay Button Visual Design + +## Goal + +Improve the floating recorder overlay button so it is easier to recognize peripherally while driving, without changing the overlay service's recording behavior or touch model. + +This spec supersedes the earlier app-icon bubble concept. The square launcher icon looked visually awkward in the small circular overlay and should not be used for the final button. + +## Approved Approach + +Use the existing native Android `View`/`Drawable` implementation in `FloatingRecorderOverlayService`. This keeps the change small and avoids introducing a custom drawing surface for a button that already exists and already handles drag/tap behavior. + +## Static Button States + +The overlay button remains the current movable 56dp touch target. + +Idle state: + +- The button background is a rounded/circular grey bubble. +- The bubble has no contour stroke. +- The centered content is a small red circular disc with its own white contour. + +Recording state: + +- The same inner red disc and white contour remain visible. +- The outer bubble background changes to red. + +The inner red disc and its white contour must remain stable during state changes and animations. Only the outer bubble background color and the already-existing scale pulse animate after saving. + +## Save Feedback Animation + +When the user taps the overlay while recording and the recording stops/saves, two animations start at the same time: + +- A short scale pulse reuses the existing inflate/deflate behavior for immediate tactile feedback. +- A 3-second continuous background color animation cycles through rainbow hues and progressively desaturates until the bubble returns to the idle grey background. + +The rainbow/desaturation animation must be long enough to be visible from peripheral vision, but it must not block input, recording service events, or the rename overlay. The final background after the animation is the idle grey state. + +## Implementation Boundaries + +Keep the change localized to `FloatingRecorderOverlayService` unless a tiny pure helper is useful for testing color interpolation. + +Expected units: + +- A helper to create/update the outer button bubble drawable with fill color and no stroke. +- A helper to create the inner red disc drawable with a white stroke. +- A helper to compute the save-animation background color from animation progress. +- A save-animation method that starts the existing scale pulse and the new color animator together. + +Do not change: + +- Overlay permission behavior. +- Overlay drag/tap semantics. +- Recording start/stop behavior. +- Rename overlay behavior. + +## Testing + +Add or update JVM unit tests for any pure color/progress helper that is introduced. Verify with the targeted overlay tests and a debug build: + +- `./gradlew testDebugConfigDebugUnitTest --tests "com.dimowner.audiorecorder.v2.app.overlay.*"` +- `./gradlew assembleDebugConfigDebug` + +Manual visual validation is still needed on a device or emulator because the key success criterion is perceptual visibility of the overlay animation. diff --git a/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-design.md b/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-design.md new file mode 100644 index 00000000..b37ff3f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-floating-recorder-overlay-design.md @@ -0,0 +1,116 @@ +# Floating Recorder Overlay Design + +## Goal + +Add an optional, default-off floating recorder control for the V2 Android app so a user can start and stop recordings while another app, such as Waze, remains visible. The overlay must be minimalist, draggable, and must reuse the existing V2 recording pipeline so recording names, file creation, metadata, waveform processing, notifications, and saving behavior stay consistent with the main app. + +## Current Context + +- The active implementation target is V2 under `app/src/main/java/com/dimowner/audiorecorder/v2/`. +- `AudioRecordingService` already owns V2 recording start/stop, generated names via `NameFormat.getNewRecordName(prefs)`, file/database creation, save finalization, foreground recording notification, max-duration splitting, and decode handoff. +- `SettingsScreen`, `SettingsState`, `SettingsViewModel`, and `PrefsV2` are the canonical V2 settings path. +- The app does not currently declare `android.permission.SYSTEM_ALERT_WINDOW`. +- The project targets SDK 37, so the design must assume modern Android background execution and foreground-service restrictions. + +## User-Approved Behavior + +- Add a setting named conceptually as `Floating recorder button`; the default is off. +- Enabling the setting checks overlay permission and microphone permission. +- If overlay permission is missing, show a dialog explaining that Android requires display-over-other-apps permission, with a direct button to this app's overlay permission page. +- The setting remains off until the needed permission is granted. +- The overlay starts only after the app is opened; it must not auto-start after reboot. +- The overlay uses a persistent idle foreground-service notification for reliability. +- A tap while idle starts a new recording. +- A tap while recording stops and saves the recording. +- A long press/drag moves the icon; the final position persists across service restarts. +- The icon changes color only when recording state confirms the recording actually started. +- Stopping a recording shows a small success animation on the icon, not a toast/snackbar. +- If `askToRenameAfterRecordingStopped` is disabled, stopping from the overlay saves silently. +- If `askToRenameAfterRecordingStopped` is enabled, stopping from the overlay shows a compact translucent rename overlay over the current app. The panel has no timeout and remains until the user taps save/cancel. + +## Architecture + +Create a dedicated `FloatingRecorderOverlayService` in the V2 audio package. This service owns the overlay window and idle foreground notification. It delegates actual recording to the existing `AudioRecordingService` instead of duplicating recording logic. + +The overlay service should bind to `AudioRecordingService` to observe `RecordingServiceState` and `AudioRecordingServiceEvent`. It should also call `AudioRecordingService.startServiceForeground(context)` to start recording and call the bound service's `stopRecording()` to stop. If the recording service is not bound when the user taps stop, the overlay service should bind and defer the stop request until the service connection is available, rather than creating a parallel stop path. + +The overlay service should be started from settings only when the user has enabled the preference and `Settings.canDrawOverlays(context)` is true. The app's normal startup path should also start the overlay service once after opening the app if the preference is enabled and permission is still granted. If permission was revoked, the service should not start and the setting can be reconciled to off. + +## Overlay UI + +Use `WindowManager` with `TYPE_APPLICATION_OVERLAY` and `FLAG_NOT_FOCUSABLE` for the idle icon so the underlying app remains interactive. The icon should be a small circular view using existing vector assets where practical: + +- Idle: neutral surface/background with recorder glyph. +- Recording: red or high-emphasis recording color after confirmed service state. +- Saving/saved: brief scale/check animation on the icon. + +Drag handling should use touch slop to distinguish taps from moves. During drag, update `WindowManager.LayoutParams.x` and `y`; persist the final coordinates to `PrefsV2` on release. Clamp saved/restored coordinates to the current display bounds so the icon cannot be restored off-screen after rotation or screen-size changes. + +When rename is needed, add a second overlay view or replace/expand the icon view with a compact panel. The panel should use focusable overlay flags so the text field can receive input and show the keyboard. Waze or any other current app remains visible behind it. The panel should include the generated saved name, an editable field, `Save`, and `Cancel`/`Keep default` behavior that preserves the already-saved record name. + +## Settings And Permissions + +Add `PrefsV2.isFloatingRecorderOverlayEnabled` with default `false`. Add persisted icon position fields such as `floatingRecorderOverlayX` and `floatingRecorderOverlayY`, with safe defaults near an edge of the screen. + +Add a permission/helper unit that provides: + +- `canDrawOverlays(context): Boolean` wrapping `Settings.canDrawOverlays(context)`. +- `overlayPermissionSettingsIntent(context): Intent` using `Settings.ACTION_MANAGE_OVERLAY_PERMISSION` and `Uri.parse("package:${context.packageName}")`. + +Settings behavior: + +- On switch on, if overlay permission is missing, show the permission explanation dialog and leave the persisted setting off. +- On returning to settings after Android permission UI, re-check permission. If granted and the user was enabling the feature, enable preference and start the overlay service. +- If microphone permission is missing, request it before enabling or starting overlay-driven recording. The overlay cannot request runtime permissions directly because it is not an Activity. +- On switch off, persist off and stop `FloatingRecorderOverlayService`. + +Manifest changes: + +- Add `android.permission.SYSTEM_ALERT_WINDOW`. +- Register `FloatingRecorderOverlayService` with `android:exported="false"`. +- Declare the idle overlay service as a `specialUse` foreground service with `android.permission.FOREGROUND_SERVICE_SPECIAL_USE` and an `android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE` value describing the always-visible floating recorder control. The overlay service itself does not use the microphone; the microphone foreground-service type remains on `AudioRecordingService`. + +## Recording And Rename Data Flow + +1. User enables the setting in V2 settings. +2. App verifies permission and starts the overlay foreground service. +3. Overlay service adds the floating icon window. +4. User taps idle icon. +5. Overlay service calls `AudioRecordingService.startServiceForeground(context)`. +6. Overlay service observes `RecordingServiceState.isRecording()` and changes icon to recording color only after state confirms start. +7. User taps recording icon. +8. Overlay service calls `AudioRecordingService.stopRecording()` on the bound service. +9. Existing `AudioRecordingService` finalizes the record, updates Room, sets `prefs.activeRecordId`, emits `RecordingStopped`, and starts decode. +10. Overlay service receives the stop event/state transition and runs the icon success animation. +11. If rename-after-recording is enabled, overlay service loads the active record and displays the rename panel. +12. On save, overlay service calls the existing `RecordsDataSource.renameRecord(record, newName)` path so file and database rename behavior remains consistent. + +## Error Handling + +- If overlay permission is missing when service starts, the service should stop itself and leave no overlay view attached. +- If recording start fails, `AudioRecordingService` already emits an error event and cleans up invalid records. The overlay should return to idle color and avoid showing success animation. +- If stop/save fails, the overlay should return to idle or recording state based on actual service state and avoid showing success animation. +- If rename fails because the target file exists or is invalid, keep the rename panel open and show a concise inline error in the overlay panel, not a toast. +- If the system removes the overlay service, opening the app again should restart it when the setting is enabled and permission is granted. + +## Testing Strategy + +- Add a Robolectric unit test for `PrefsV2Impl` covering `isFloatingRecorderOverlayEnabled`, `floatingRecorderOverlayX`, and `floatingRecorderOverlayY` defaults and persistence. +- Add a unit test for the overlay permission helper covering the package-specific `ACTION_MANAGE_OVERLAY_PERMISSION` intent. +- Add a unit test for a small settings decision helper that returns whether enabling should proceed, request microphone permission, or open overlay permission settings. This keeps the permission decision testable without needing to instrument the Compose screen. +- Run `./gradlew testDebugConfigDebugUnitTest` after implementation. +- Manual or emulator validation should cover enabling permission, persistent notification, overlay over another app, tap-to-start, tap-to-stop, drag/persist position, rename overlay, and disabling the setting. + +## Non-Goals + +- No boot receiver and no automatic overlay start after device reboot before the user opens the app. +- No duplicate recording implementation in the overlay service. +- No toast notification for successful overlay recording start/stop. +- No forced timeout for the rename overlay. +- No changes to V1/legacy behavior unless required by shared manifest or permission declarations. + +## Open Risks + +- OEM Android builds can impose extra overlay restrictions or battery-management kills. The persistent foreground notification mitigates this but cannot guarantee behavior on every device. +- Text input from an overlay is more fragile than a normal Activity dialog, especially around keyboard focus. The design keeps the rename panel small and optional because users can disable rename-after-recording for driving. +- Android can suppress overlays on sensitive system screens. The feature is expected to work over navigation apps like Waze, but not necessarily over all system permission/security screens. From c27c75a836c98f29a1f25e1d134c17a4243fc7fd Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 19 Jun 2026 01:03:43 +0200 Subject: [PATCH 02/13] fix: theme floating rename overlay text Style the floating rename overlay from the V2 dark-theme preference so dark mode uses a dark panel with white bold filename text, and light mode uses a white panel with black bold filename text. Add focused tests for rename overlay dark and light style selection. Agentic harness: OpenCode with OpenAI GPT-5.5 (openai/gpt-5.5). --- .../overlay/FloatingRecorderOverlayGeometry.kt | 10 ++++++++++ .../overlay/FloatingRecorderOverlayService.kt | 10 ++++++++-- .../FloatingRecorderOverlayGeometryTest.kt | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index 8f32f5aa..f885505f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -5,6 +5,8 @@ import kotlin.math.roundToInt internal data class OverlayPosition(val x: Int, val y: Int) +internal data class RenameOverlayStyle(val panelColor: Int, val textColor: Int) + internal fun clampOverlayPosition( savedX: Int, savedY: Int, @@ -53,6 +55,14 @@ internal fun calculateSaveFeedbackColor(progress: Float, idleColor: Int): Int { return blendArgb(from = rainbowColor, to = idleColor, ratio = settleProgress) } +internal fun renameOverlayStyle(isDarkTheme: Boolean): RenameOverlayStyle { + return if (isDarkTheme) { + RenameOverlayStyle(panelColor = 0xEC202020.toInt(), textColor = 0xFFFFFFFF.toInt()) + } else { + RenameOverlayStyle(panelColor = 0xFFFFFFFF.toInt(), textColor = 0xFF000000.toInt()) + } +} + private fun hsvToOpaqueColor(hue: Float, saturation: Float, value: Float): Int { val normalizedHue = ((hue % 360f) + 360f) % 360f val chroma = value * saturation diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 2e421b35..24b653e6 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -13,8 +13,10 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.pm.ServiceInfo +import android.content.res.ColorStateList import android.graphics.Color import android.graphics.PixelFormat +import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.IBinder @@ -382,11 +384,15 @@ class FloatingRecorderOverlayService : Service() { // A measured-height clamp runs immediately after attachment and on every drag end. overlayHeight = dp(RENAME_PANEL_ESTIMATED_HEIGHT_DP), ) + val style = renameOverlayStyle(isDarkTheme = prefs.isDarkTheme) val input = EditText(this).apply { setText(record.name) selectAll() setSingleLine(true) + setTextColor(style.textColor) + typeface = Typeface.DEFAULT_BOLD + backgroundTintList = ColorStateList.valueOf(style.textColor) } val error = TextView(this).apply { setTextColor(RECORDING_ICON_COLOR) @@ -396,10 +402,10 @@ class FloatingRecorderOverlayService : Service() { val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(16), dp(12), dp(16), dp(12)) - background = roundedDrawable(Color.argb(236, 32, 32, 32), dp(16).toFloat()) + background = roundedDrawable(style.panelColor, dp(16).toFloat()) addView(TextView(this@FloatingRecorderOverlayService).apply { text = getString(R.string.update_record_name) - setTextColor(Color.WHITE) + setTextColor(style.textColor) textSize = 18f setPadding(0, 0, 0, dp(8)) }) diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 9926ed05..47b8f515 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -106,4 +106,20 @@ class FloatingRecorderOverlayGeometryTest { assertEquals(0xFF444444.toInt(), color) } + + @Test + fun `renameOverlayStyle uses dark background and white text in dark theme`() { + val style = renameOverlayStyle(isDarkTheme = true) + + assertEquals(0xEC202020.toInt(), style.panelColor) + assertEquals(0xFFFFFFFF.toInt(), style.textColor) + } + + @Test + fun `renameOverlayStyle uses white background and black text in light theme`() { + val style = renameOverlayStyle(isDarkTheme = false) + + assertEquals(0xFFFFFFFF.toInt(), style.panelColor) + assertEquals(0xFF000000.toInt(), style.textColor) + } } From b17329a55982180038e1454ec8b860ef95ad4ce2 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 19 Jun 2026 14:10:05 +0200 Subject: [PATCH 03/13] feat: add floating overlay pinch resize Persist the floating recorder overlay diameter and restore it with display-aware clamping so the button keeps a user-selected size across app restarts. Add tested geometry helpers for size bounds, saved-size clamping, and proportional record-disc scaling, then wire two-finger pinch handling into the overlay service while suppressing tap and drag during resize. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../FloatingRecorderOverlayGeometry.kt | 45 +++++++ .../overlay/FloatingRecorderOverlayService.kt | 127 +++++++++++++++++- .../dimowner/audiorecorder/v2/data/PrefsV2.kt | 1 + .../audiorecorder/v2/data/PrefsV2Impl.kt | 9 ++ .../FloatingRecorderOverlayGeometryTest.kt | 27 ++++ .../audiorecorder/v2/data/PrefsV2ImplTest.kt | 14 ++ 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index f885505f..c662497d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -1,12 +1,57 @@ package com.dimowner.audiorecorder.v2.app.overlay import kotlin.math.abs +import kotlin.math.min import kotlin.math.roundToInt internal data class OverlayPosition(val x: Int, val y: Int) +internal data class OverlaySizeBounds(val minSize: Int, val maxSize: Int) + internal data class RenameOverlayStyle(val panelColor: Int, val textColor: Int) +internal fun calculateOverlaySizeBounds( + defaultSize: Int, + screenWidth: Int, + screenHeight: Int, +): OverlaySizeBounds { + val minSize = defaultSize.coerceAtLeast(1) + val halfSmallerScreen = min(screenWidth, screenHeight) / 2 + + return OverlaySizeBounds( + minSize = minSize, + // Keep the range valid even on very small or transiently unmeasured displays. + maxSize = halfSmallerScreen.coerceAtLeast(minSize), + ) +} + +internal fun clampOverlaySize( + savedSize: Int, + defaultSize: Int, + screenWidth: Int, + screenHeight: Int, +): Int { + val bounds = calculateOverlaySizeBounds( + defaultSize = defaultSize, + screenWidth = screenWidth, + screenHeight = screenHeight, + ) + val requestedSize = if (savedSize == -1) defaultSize else savedSize + return requestedSize.coerceIn(bounds.minSize, bounds.maxSize) +} + +internal fun calculateRecordDiscSize( + overlaySize: Int, + defaultOverlaySize: Int, + defaultDiscSize: Int, +): Int { + if (defaultOverlaySize <= 0) return defaultDiscSize.coerceAtLeast(1) + + return (overlaySize * (defaultDiscSize.toFloat() / defaultOverlaySize.toFloat())) + .roundToInt() + .coerceAtLeast(1) +} + internal fun clampOverlayPosition( savedX: Int, savedY: Int, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 24b653e6..5aad18b7 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -53,6 +53,8 @@ import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sqrt @AndroidEntryPoint class FloatingRecorderOverlayService : Service() { @@ -179,8 +181,14 @@ class FloatingRecorderOverlayService : Service() { private fun addIconOverlay() { if (iconView != null) return - val size = dp(56) + val defaultSize = dp(DEFAULT_ICON_SIZE_DP) val displayMetrics = resources.displayMetrics + val size = clampOverlaySize( + savedSize = prefs.floatingRecorderOverlaySize, + defaultSize = defaultSize, + screenWidth = displayMetrics.widthPixels, + screenHeight = displayMetrics.heightPixels, + ) val position = clampOverlayPosition( savedX = prefs.floatingRecorderOverlayX, savedY = prefs.floatingRecorderOverlayY, @@ -195,7 +203,7 @@ class FloatingRecorderOverlayService : Service() { elevation = dp(8).toFloat() addView(View(this@FloatingRecorderOverlayService).apply { background = recordDiscDrawable() - }, FrameLayout.LayoutParams(dp(30), dp(30), Gravity.CENTER)) + }, FrameLayout.LayoutParams(recordDiscSize(size), recordDiscSize(size), Gravity.CENTER)) } val params = WindowManager.LayoutParams( @@ -236,6 +244,10 @@ class FloatingRecorderOverlayService : Service() { private var startY = 0 private var downTime = 0L private var dragging = false + private var pinching = false + private var suppressTap = false + private var initialPinchDistance = 0f + private var initialPinchSize = 0 override fun onTouch(view: View, event: MotionEvent): Boolean { when (event.actionMasked) { @@ -246,9 +258,23 @@ class FloatingRecorderOverlayService : Service() { startY = params.y downTime = event.eventTime dragging = false + pinching = false + suppressTap = false + return true + } + MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2) { + beginPinch(event) + } return true } MotionEvent.ACTION_MOVE -> { + if (pinching || event.pointerCount >= 2) { + if (!pinching) beginPinch(event) + updatePinch(view, event) + return true + } + val deltaX = event.rawX - downRawX val deltaY = event.rawY - downRawY val movedEnough = abs(deltaX) > touchSlop || abs(deltaY) > touchSlop @@ -261,25 +287,92 @@ class FloatingRecorderOverlayService : Service() { } return true } + MotionEvent.ACTION_POINTER_UP -> { + if (pinching) finishPinch(view) + return true + } MotionEvent.ACTION_UP -> { - if (dragging) { + if (pinching) { + finishPinch(view) + } else if (dragging) { persistIconPosition(params) - } else { + } else if (!suppressTap) { handleIconTap() } return true } MotionEvent.ACTION_CANCEL -> { - if (dragging) persistIconPosition(params) + if (pinching) { + finishPinch(view) + } else if (dragging) { + persistIconPosition(params) + } return true } } return false } + + private fun beginPinch(event: MotionEvent) { + val distance = pointerDistance(event) + if (distance <= 0f) return + + // Once a second finger joins, the gesture is resize-only. This prevents an + // accidental start/stop tap or long-press drag after the pinch ends. + pinching = true + suppressTap = true + dragging = false + initialPinchDistance = distance + initialPinchSize = params.width.takeIf { it > 0 } ?: dp(DEFAULT_ICON_SIZE_DP) + } + + private fun updatePinch(view: View, event: MotionEvent) { + if (event.pointerCount < 2 || initialPinchDistance <= 0f) return + + val scale = pointerDistance(event) / initialPinchDistance + val metrics = resources.displayMetrics + val size = clampOverlaySize( + savedSize = (initialPinchSize * scale).roundToInt(), + defaultSize = dp(DEFAULT_ICON_SIZE_DP), + screenWidth = metrics.widthPixels, + screenHeight = metrics.heightPixels, + ) + params.width = size + params.height = size + updateRecordDiscLayout(view as FrameLayout, size) + + val clamped = clampOverlayPosition( + savedX = params.x, + savedY = params.y, + screenWidth = metrics.widthPixels, + screenHeight = metrics.heightPixels, + overlayWidth = size, + overlayHeight = size, + ) + params.x = clamped.x + params.y = clamped.y + windowManager.updateViewLayout(view, params) + } + + private fun finishPinch(view: View) { + if (!pinching) return + + pinching = false + persistIconPosition(params) + updateRecordDiscLayout(view as FrameLayout, params.width.takeIf { it > 0 } ?: dp(DEFAULT_ICON_SIZE_DP)) + } + + private fun pointerDistance(event: MotionEvent): Float { + if (event.pointerCount < 2) return 0f + + val deltaX = event.getX(0) - event.getX(1) + val deltaY = event.getY(0) - event.getY(1) + return sqrt(deltaX * deltaX + deltaY * deltaY) + } } private fun persistIconPosition(params: WindowManager.LayoutParams) { - val size = dp(56) + val size = params.width.takeIf { it > 0 } ?: dp(DEFAULT_ICON_SIZE_DP) val metrics = resources.displayMetrics val clamped = clampOverlayPosition( savedX = params.x, @@ -294,6 +387,26 @@ class FloatingRecorderOverlayService : Service() { iconView?.let { windowManager.updateViewLayout(it, params) } prefs.floatingRecorderOverlayX = clamped.x prefs.floatingRecorderOverlayY = clamped.y + prefs.floatingRecorderOverlaySize = size + } + + private fun updateRecordDiscLayout(view: FrameLayout, overlaySize: Int) { + val disc = view.getChildAt(0) ?: return + val size = recordDiscSize(overlaySize) + val currentParams = disc.layoutParams as? FrameLayout.LayoutParams + disc.layoutParams = (currentParams ?: FrameLayout.LayoutParams(size, size, Gravity.CENTER)).apply { + width = size + height = size + gravity = Gravity.CENTER + } + } + + private fun recordDiscSize(overlaySize: Int): Int { + return calculateRecordDiscSize( + overlaySize = overlaySize, + defaultOverlaySize = dp(DEFAULT_ICON_SIZE_DP), + defaultDiscSize = dp(DEFAULT_RECORD_DISC_SIZE_DP), + ) } private fun handleIconTap() { @@ -671,6 +784,8 @@ class FloatingRecorderOverlayService : Service() { private const val ACTION_STOP = "com.dimowner.audiorecorder.ACTION_STOP_FLOATING_RECORDER_OVERLAY" private const val SCALE_FEEDBACK_DURATION_MS = 160L private const val SAVE_FEEDBACK_DURATION_MS = 3000L + private const val DEFAULT_ICON_SIZE_DP = 56 + private const val DEFAULT_RECORD_DISC_SIZE_DP = 30 private const val RENAME_PANEL_ESTIMATED_HEIGHT_DP = 220 private const val RENAME_PANEL_MIN_WIDTH_DP = 240 private const val RENAME_PANEL_MAX_WIDTH_DP = 360 diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt index 745219b6..7a5e16cc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -46,6 +46,7 @@ interface PrefsV2 { var isFloatingRecorderOverlayEnabled: Boolean var floatingRecorderOverlayX: Int var floatingRecorderOverlayY: Int + var floatingRecorderOverlaySize: Int var floatingRecorderRenameOverlayX: Int var floatingRecorderRenameOverlayY: Int diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt index 98807555..829c07aa 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -166,6 +166,14 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont } } + override var floatingRecorderOverlaySize: Int + get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_SIZE, -1) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_OVERLAY_SIZE, value) + } + } + override var floatingRecorderRenameOverlayX: Int get() = sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_X, -1) set(value) { @@ -358,6 +366,7 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont "pref_key_floating_recorder_overlay_enabled" private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_X = "pref_key_floating_recorder_overlay_x" private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_Y = "pref_key_floating_recorder_overlay_y" + private const val PREF_KEY_FLOATING_RECORDER_OVERLAY_SIZE = "pref_key_floating_recorder_overlay_size" private const val PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_X = "pref_key_floating_recorder_rename_overlay_x" private const val PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_Y = diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 47b8f515..47ea14e9 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -5,6 +5,33 @@ import org.junit.Test class FloatingRecorderOverlayGeometryTest { + @Test + fun `calculateOverlaySizeBounds uses default size as minimum and half smaller screen as maximum`() { + val bounds = calculateOverlaySizeBounds(defaultSize = 56, screenWidth = 1080, screenHeight = 1920) + + assertEquals(56, bounds.minSize) + assertEquals(540, bounds.maxSize) + } + + @Test + fun `clampOverlaySize uses default size when saved size is unset`() { + val size = clampOverlaySize(savedSize = -1, defaultSize = 56, screenWidth = 1080, screenHeight = 1920) + + assertEquals(56, size) + } + + @Test + fun `clampOverlaySize clamps size to current screen bounds`() { + assertEquals(56, clampOverlaySize(savedSize = 12, defaultSize = 56, screenWidth = 1080, screenHeight = 1920)) + assertEquals(540, clampOverlaySize(savedSize = 1000, defaultSize = 56, screenWidth = 1080, screenHeight = 1920)) + } + + @Test + fun `calculateRecordDiscSize scales with overlay size`() { + assertEquals(30, calculateRecordDiscSize(overlaySize = 56, defaultOverlaySize = 56, defaultDiscSize = 30)) + assertEquals(60, calculateRecordDiscSize(overlaySize = 112, defaultOverlaySize = 56, defaultDiscSize = 30)) + } + @Test fun `clampOverlayPosition uses default when saved position is unset`() { val position = clampOverlayPosition( diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt index bf342270..5a7b6581 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt @@ -50,6 +50,11 @@ class PrefsV2ImplTest { assertEquals(-1, prefs.floatingRecorderOverlayY) } + @Test + fun `floating recorder overlay size defaults to unset`() { + assertEquals(-1, prefs.floatingRecorderOverlaySize) + } + @Test fun `floating recorder rename overlay position defaults to unset`() { assertEquals(-1, prefs.floatingRecorderRenameOverlayX) @@ -67,6 +72,15 @@ class PrefsV2ImplTest { assertEquals(84, reloadedPrefs.floatingRecorderOverlayY) } + @Test + fun `floating recorder overlay size persists`() { + prefs.floatingRecorderOverlaySize = 112 + + val reloadedPrefs = PrefsV2Impl(context) + + assertEquals(112, reloadedPrefs.floatingRecorderOverlaySize) + } + @Test fun `floating recorder rename overlay position persists`() { prefs.floatingRecorderRenameOverlayX = 123 From bc1b5e4ed974995970dfb43520640f5353857485 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 19 Jun 2026 17:28:37 +0200 Subject: [PATCH 04/13] feat: add rename overlay speech input Add system SpeechRecognizer dictation to the floating rename overlay, with a large mic button that shows the persisted append/replace mode and a long-press popup to change it. Persist the rename speech mode, sanitize and normalize recognized text, and cap the visible filename to 251 characters so the hidden extension stays within the filename budget. Add focused tests for filename composition and preference persistence, plus Android package visibility and mic resources for recognition providers such as FUTO Voice Input when exposed as a RecognitionService. Agentic harness: OpenCode with OpenAI gpt-5.5. --- app/src/main/AndroidManifest.xml | 6 + .../FloatingRecorderOverlayGeometry.kt | 22 +++ .../overlay/FloatingRecorderOverlayService.kt | 175 ++++++++++++++++++ .../dimowner/audiorecorder/v2/data/PrefsV2.kt | 2 + .../audiorecorder/v2/data/PrefsV2Impl.kt | 13 ++ .../v2/data/model/RenameSpeechMode.kt | 13 ++ app/src/main/res/drawable/ic_mic.xml | 12 ++ app/src/main/res/values/strings.xml | 7 + .../FloatingRecorderOverlayGeometryTest.kt | 67 +++++++ .../audiorecorder/v2/data/PrefsV2ImplTest.kt | 15 ++ 10 files changed, 332 insertions(+) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt create mode 100644 app/src/main/res/drawable/ic_mic.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c1a61e41..232c9464 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index c662497d..eb7e5f5e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.v2.app.overlay +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt @@ -10,6 +11,27 @@ internal data class OverlaySizeBounds(val minSize: Int, val maxSize: Int) internal data class RenameOverlayStyle(val panelColor: Int, val textColor: Int) +internal fun applyRenameSpeechTranscription( + currentName: String, + transcript: String, + mode: RenameSpeechMode, + maxVisibleNameCharacters: Int = 251, +): String { + val normalizedTranscript = transcript + .filterNot { it == '/' || it == '\\' || Character.isISOControl(it) } + .trim() + .replace(Regex("\\s+"), " ") + if (normalizedTranscript.isBlank()) return currentName + + val combined = when (mode) { + RenameSpeechMode.Append -> listOf(currentName.trimEnd(), normalizedTranscript) + .filter { it.isNotBlank() } + .joinToString(" ") + RenameSpeechMode.Replace -> normalizedTranscript + } + return combined.take(maxVisibleNameCharacters.coerceAtLeast(0)) +} + internal fun calculateOverlaySizeBounds( defaultSize: Int, screenWidth: Int, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 5aad18b7..b0a7b8dc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.v2.app.overlay +import android.Manifest import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.ValueAnimator @@ -12,6 +13,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.content.res.ColorStateList import android.graphics.Color @@ -19,7 +21,11 @@ import android.graphics.PixelFormat import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build +import android.os.Bundle import android.os.IBinder +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -29,7 +35,9 @@ import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.EditText import android.widget.FrameLayout +import android.widget.ImageView import android.widget.LinearLayout +import android.widget.PopupMenu import android.widget.TextView import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -41,6 +49,7 @@ import com.dimowner.audiorecorder.v2.audio.AudioRecordingServiceEvent import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource import com.dimowner.audiorecorder.v2.data.model.Record +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import com.dimowner.audiorecorder.v2.di.qualifiers.IoDispatcher import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineDispatcher @@ -72,6 +81,8 @@ class FloatingRecorderOverlayService : Service() { private var iconView: FrameLayout? = null private var iconParams: WindowManager.LayoutParams? = null private var renameView: View? = null + private var renameSpeechRecognizer: SpeechRecognizer? = null + private var renameSpeechListening = false private var saveFeedbackAnimator: AnimatorSet? = null private var recordingService: AudioRecordingService? = null private var isRecordingServiceBound = false @@ -511,6 +522,7 @@ class FloatingRecorderOverlayService : Service() { setTextColor(RECORDING_ICON_COLOR) visibility = View.GONE } + val speechButton = createRenameSpeechButton(input, error) val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -524,6 +536,13 @@ class FloatingRecorderOverlayService : Service() { }) addView(input, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) addView(error, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + addView(speechButton, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = dp(10) + bottomMargin = dp(6) + }) addView(LinearLayout(this@FloatingRecorderOverlayService).apply { gravity = Gravity.END addView(Button(this@FloatingRecorderOverlayService).apply { @@ -564,6 +583,160 @@ class FloatingRecorderOverlayService : Service() { } } + private fun createRenameSpeechButton(input: EditText, error: TextView): LinearLayout { + val modeLabel = TextView(this).apply { + setTextColor(Color.WHITE) + textSize = 12f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + text = renameSpeechModeText(prefs.floatingRecorderRenameSpeechMode) + } + + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + minimumHeight = dp(RENAME_SPEECH_BUTTON_MIN_HEIGHT_DP) + isClickable = true + isFocusable = true + setPadding(dp(12), dp(10), dp(12), dp(8)) + background = roundedDrawable(IDLE_ICON_COLOR, dp(18).toFloat()) + addView(ImageView(this@FloatingRecorderOverlayService).apply { + setImageResource(R.drawable.ic_mic) + imageTintList = ColorStateList.valueOf(Color.WHITE) + contentDescription = getString(R.string.rename_speech_listening) + }, LinearLayout.LayoutParams(dp(32), dp(32))) + addView(modeLabel, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + )) + setOnClickListener { + startRenameSpeechRecognition(input = input, error = error, micButton = this, modeLabel = modeLabel) + } + setOnLongClickListener { + showRenameSpeechModePopup(anchor = this, modeLabel = modeLabel) + true + } + } + } + + private fun showRenameSpeechModePopup(anchor: View, modeLabel: TextView) { + PopupMenu(this, anchor).apply { + menu.add(0, RenameSpeechMode.Append.persistedValue, 0, getString(R.string.rename_speech_mode_append)) + menu.add(0, RenameSpeechMode.Replace.persistedValue, 1, getString(R.string.rename_speech_mode_replace)) + setOnMenuItemClickListener { item -> + val mode = RenameSpeechMode.fromPersistedValue(item.itemId) + prefs.floatingRecorderRenameSpeechMode = mode + modeLabel.text = renameSpeechModeText(mode) + true + } + show() + } + } + + private fun startRenameSpeechRecognition( + input: EditText, + error: TextView, + micButton: View, + modeLabel: TextView, + ) { + if (renameSpeechListening) return + if (!SpeechRecognizer.isRecognitionAvailable(this)) { + showRenameInlineMessage(error, R.string.rename_speech_no_recognizer) + return + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + showRenameInlineMessage(error, R.string.rename_speech_no_microphone_permission) + return + } + + destroyRenameSpeechRecognizer() + val recognizer = SpeechRecognizer.createSpeechRecognizer(this) + renameSpeechRecognizer = recognizer + renameSpeechListening = true + micButton.isEnabled = false + showRenameInlineMessage(error, R.string.rename_speech_listening) + + recognizer.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) = Unit + override fun onBeginningOfSpeech() = Unit + override fun onRmsChanged(rmsdB: Float) = Unit + override fun onBufferReceived(buffer: ByteArray?) = Unit + override fun onEndOfSpeech() = Unit + override fun onPartialResults(partialResults: Bundle?) = Unit + override fun onEvent(eventType: Int, params: Bundle?) = Unit + + override fun onError(errorCode: Int) { + val message = if (errorCode == SpeechRecognizer.ERROR_NO_MATCH || errorCode == SpeechRecognizer.ERROR_SPEECH_TIMEOUT) { + R.string.rename_speech_no_match + } else { + R.string.rename_speech_error + } + showRenameInlineMessage(error, message) + finishRenameSpeechRecognition(micButton) + } + + override fun onResults(results: Bundle?) { + val transcript = results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull { it.isNotBlank() } + if (transcript == null) { + showRenameInlineMessage(error, R.string.rename_speech_no_match) + } else { + val updated = applyRenameSpeechTranscription( + currentName = input.text.toString(), + transcript = transcript, + mode = prefs.floatingRecorderRenameSpeechMode, + ) + input.setText(updated) + input.setSelection(updated.length) + error.visibility = View.GONE + } + finishRenameSpeechRecognition(micButton) + } + }) + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) + } + + runCatching { recognizer.startListening(intent) } + .onFailure { + Timber.e(it, "Failed to start floating rename speech recognition") + showRenameInlineMessage(error, R.string.rename_speech_error) + finishRenameSpeechRecognition(micButton) + } + } + + private fun finishRenameSpeechRecognition(micButton: View) { + renameSpeechListening = false + micButton.isEnabled = true + destroyRenameSpeechRecognizer() + } + + private fun destroyRenameSpeechRecognizer() { + renameSpeechRecognizer?.let { recognizer -> + runCatching { recognizer.cancel() } + runCatching { recognizer.destroy() } + } + renameSpeechRecognizer = null + renameSpeechListening = false + } + + private fun showRenameInlineMessage(error: TextView, messageRes: Int) { + error.text = getString(messageRes) + error.visibility = View.VISIBLE + } + + private fun renameSpeechModeText(mode: RenameSpeechMode): String { + return when (mode) { + RenameSpeechMode.Append -> getString(R.string.rename_speech_mode_append) + RenameSpeechMode.Replace -> getString(R.string.rename_speech_mode_replace) + } + } + private inner class RenameOverlayTouchListener( private val params: WindowManager.LayoutParams, ) : View.OnTouchListener { @@ -675,6 +848,7 @@ class FloatingRecorderOverlayService : Service() { } private fun removeRenameOverlay() { + destroyRenameSpeechRecognizer() renameView?.let { view -> runCatching { windowManager.removeView(view) } } @@ -789,6 +963,7 @@ class FloatingRecorderOverlayService : Service() { private const val RENAME_PANEL_ESTIMATED_HEIGHT_DP = 220 private const val RENAME_PANEL_MIN_WIDTH_DP = 240 private const val RENAME_PANEL_MAX_WIDTH_DP = 360 + private const val RENAME_SPEECH_BUTTON_MIN_HEIGHT_DP = 72 private val IDLE_ICON_COLOR = Color.DKGRAY private val RECORDING_ICON_COLOR = Color.RED diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt index 7a5e16cc..ed119f79 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2.kt @@ -21,6 +21,7 @@ import com.dimowner.audiorecorder.v2.data.model.BitRate import com.dimowner.audiorecorder.v2.data.model.ChannelCount import com.dimowner.audiorecorder.v2.data.model.NameFormat import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import com.dimowner.audiorecorder.v2.data.model.SampleRate import com.dimowner.audiorecorder.v2.data.model.SortOrder import kotlinx.coroutines.flow.StateFlow @@ -49,6 +50,7 @@ interface PrefsV2 { var floatingRecorderOverlaySize: Int var floatingRecorderRenameOverlayX: Int var floatingRecorderRenameOverlayY: Int + var floatingRecorderRenameSpeechMode: RenameSpeechMode var recordsSortOrder: SortOrder diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt index 829c07aa..d0dba38e 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/PrefsV2Impl.kt @@ -27,6 +27,7 @@ import com.dimowner.audiorecorder.v2.data.model.BitRate import com.dimowner.audiorecorder.v2.data.model.ChannelCount import com.dimowner.audiorecorder.v2.data.model.NameFormat import com.dimowner.audiorecorder.v2.data.model.RecordingFormat +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import com.dimowner.audiorecorder.v2.data.model.SampleRate import com.dimowner.audiorecorder.v2.data.model.SortOrder import com.dimowner.audiorecorder.v2.data.model.convertToBitRate @@ -190,6 +191,16 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont } } + override var floatingRecorderRenameSpeechMode: RenameSpeechMode + get() = RenameSpeechMode.fromPersistedValue( + sharedPreferences.getInt(PREF_KEY_FLOATING_RECORDER_RENAME_SPEECH_MODE, RenameSpeechMode.Append.persistedValue) + ) + set(value) { + sharedPreferences.edit { + putInt(PREF_KEY_FLOATING_RECORDER_RENAME_SPEECH_MODE, value.persistedValue) + } + } + override var recordsSortOrder: SortOrder get() = sharedPreferences.getString( PREF_KEY_RECORDS_SORT_ORDER, @@ -371,5 +382,7 @@ class PrefsV2Impl @Inject internal constructor(@ApplicationContext context: Cont "pref_key_floating_recorder_rename_overlay_x" private const val PREF_KEY_FLOATING_RECORDER_RENAME_OVERLAY_Y = "pref_key_floating_recorder_rename_overlay_y" + private const val PREF_KEY_FLOATING_RECORDER_RENAME_SPEECH_MODE = + "pref_key_floating_recorder_rename_speech_mode" } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt new file mode 100644 index 00000000..f09e65fe --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt @@ -0,0 +1,13 @@ +package com.dimowner.audiorecorder.v2.data.model + +enum class RenameSpeechMode(val persistedValue: Int) { + Append(0), + Replace(1), + ; + + companion object { + fun fromPersistedValue(value: Int): RenameSpeechMode { + return entries.firstOrNull { it.persistedValue == value } ?: Append + } + } +} diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 00000000..bc798f49 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e48c9d2..92a5d134 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,13 @@ File operation failed. Please try again Name cannot be empty + Append + Replace + Listening... + Speech recognition is not available on this device. + Microphone permission is required for speech input. + No speech recognized. + Speech recognition failed. Microphone permission is required to record audio. Please grant the permission to use this feature. diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 47ea14e9..1accae82 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -1,10 +1,77 @@ package com.dimowner.audiorecorder.v2.app.overlay +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import org.junit.Assert.assertEquals import org.junit.Test class FloatingRecorderOverlayGeometryTest { + @Test + fun `applyRenameSpeechTranscription appends transcript with one separating space`() { + val result = applyRenameSpeechTranscription( + currentName = "Drive note", + transcript = " fuel stop ", + mode = RenameSpeechMode.Append, + ) + + assertEquals("Drive note fuel stop", result) + } + + @Test + fun `applyRenameSpeechTranscription appends to blank current name without leading space`() { + val result = applyRenameSpeechTranscription( + currentName = "", + transcript = "parking level two", + mode = RenameSpeechMode.Append, + ) + + assertEquals("parking level two", result) + } + + @Test + fun `applyRenameSpeechTranscription replaces current name`() { + val result = applyRenameSpeechTranscription( + currentName = "Old name", + transcript = "new voice title", + mode = RenameSpeechMode.Replace, + ) + + assertEquals("new voice title", result) + } + + @Test + fun `applyRenameSpeechTranscription keeps current name when transcript is blank`() { + val result = applyRenameSpeechTranscription( + currentName = "Existing", + transcript = " ", + mode = RenameSpeechMode.Replace, + ) + + assertEquals("Existing", result) + } + + @Test + fun `applyRenameSpeechTranscription removes filename hostile characters`() { + val result = applyRenameSpeechTranscription( + currentName = "Existing", + transcript = "road / bridge \\ tunnel\u0000", + mode = RenameSpeechMode.Replace, + ) + + assertEquals("road bridge tunnel", result) + } + + @Test + fun `applyRenameSpeechTranscription caps visible name to 251 characters`() { + val result = applyRenameSpeechTranscription( + currentName = "", + transcript = "a".repeat(300), + mode = RenameSpeechMode.Replace, + ) + + assertEquals(251, result.length) + } + @Test fun `calculateOverlaySizeBounds uses default size as minimum and half smaller screen as maximum`() { val bounds = calculateOverlaySizeBounds(defaultSize = 56, screenWidth = 1080, screenHeight = 1920) diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt index 5a7b6581..e69a6c4b 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import com.dimowner.audiorecorder.AppConstants.PREF_NAME import com.dimowner.audiorecorder.util.TestARApplication +import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -61,6 +62,11 @@ class PrefsV2ImplTest { assertEquals(-1, prefs.floatingRecorderRenameOverlayY) } + @Test + fun `floating recorder rename speech mode defaults to append`() { + assertEquals(RenameSpeechMode.Append, prefs.floatingRecorderRenameSpeechMode) + } + @Test fun `floating recorder overlay position persists`() { prefs.floatingRecorderOverlayX = 42 @@ -91,4 +97,13 @@ class PrefsV2ImplTest { assertEquals(123, reloadedPrefs.floatingRecorderRenameOverlayX) assertEquals(456, reloadedPrefs.floatingRecorderRenameOverlayY) } + + @Test + fun `floating recorder rename speech mode persists replace`() { + prefs.floatingRecorderRenameSpeechMode = RenameSpeechMode.Replace + + val reloadedPrefs = PrefsV2Impl(context) + + assertEquals(RenameSpeechMode.Replace, reloadedPrefs.floatingRecorderRenameSpeechMode) + } } From c95af17fa22401aea65dcf2840605397e7091779 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 19 Jun 2026 19:42:49 +0200 Subject: [PATCH 05/13] fix: use recognizer intent for rename speech input Replace the embedded SpeechRecognizer backend with a transparent RecognizerIntent proxy Activity so activity-based providers such as FUTO Voice Input can handle rename dictation like Chromium. Keep the existing large mic button, persisted long-press append/replace mode, and filename truncation while delegating recognition UI, permissions, and lifecycle to the recognizer app instead of managing low-level callbacks. Agentic harness: OpenCode with OpenAI gpt-5.5. --- app/src/main/AndroidManifest.xml | 8 +- .../overlay/FloatingRecorderOverlayService.kt | 110 ++++++------------ ...FloatingRenameSpeechRecognitionActivity.kt | 102 ++++++++++++++++ app/src/main/res/values/strings.xml | 1 - .../FloatingRecorderOverlayGeometryTest.kt | 12 ++ 5 files changed, 159 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRenameSpeechRecognitionActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 232c9464..8155e292 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ - + @@ -79,6 +79,12 @@ android:name=".v2.app.HomeActivity" android:launchMode="singleTask" android:exported="false" /> + applyRenameSpeechResult(input, error, resultData) + RENAME_SPEECH_RESULT_NO_MATCH -> showRenameInlineMessage(error, R.string.rename_speech_no_match) + else -> showRenameInlineMessage(error, R.string.rename_speech_error) } finishRenameSpeechRecognition(micButton) } - }) + } - val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, false) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) - putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) + val intent = Intent(this, FloatingRenameSpeechRecognitionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(EXTRA_RENAME_SPEECH_RESULT_RECEIVER, receiver) } - runCatching { recognizer.startListening(intent) } + runCatching { startActivity(intent) } .onFailure { Timber.e(it, "Failed to start floating rename speech recognition") showRenameInlineMessage(error, R.string.rename_speech_error) @@ -710,19 +669,26 @@ class FloatingRecorderOverlayService : Service() { } } - private fun finishRenameSpeechRecognition(micButton: View) { - renameSpeechListening = false - micButton.isEnabled = true - destroyRenameSpeechRecognizer() + private fun applyRenameSpeechResult(input: EditText, error: TextView, resultData: Bundle?) { + val transcript = resultData?.getString(EXTRA_RENAME_SPEECH_TEXT) + if (transcript.isNullOrBlank()) { + showRenameInlineMessage(error, R.string.rename_speech_no_match) + return + } + + val updated = applyRenameSpeechTranscription( + currentName = input.text.toString(), + transcript = transcript, + mode = prefs.floatingRecorderRenameSpeechMode, + ) + input.setText(updated) + input.setSelection(updated.length) + error.visibility = View.GONE } - private fun destroyRenameSpeechRecognizer() { - renameSpeechRecognizer?.let { recognizer -> - runCatching { recognizer.cancel() } - runCatching { recognizer.destroy() } - } - renameSpeechRecognizer = null - renameSpeechListening = false + private fun finishRenameSpeechRecognition(micButton: View) { + renameSpeechPending = false + micButton.isEnabled = true } private fun showRenameInlineMessage(error: TextView, messageRes: Int) { @@ -848,7 +814,7 @@ class FloatingRecorderOverlayService : Service() { } private fun removeRenameOverlay() { - destroyRenameSpeechRecognizer() + renameSpeechPending = false renameView?.let { view -> runCatching { windowManager.removeView(view) } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRenameSpeechRecognitionActivity.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRenameSpeechRecognitionActivity.kt new file mode 100644 index 00000000..997703b2 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRenameSpeechRecognitionActivity.kt @@ -0,0 +1,102 @@ +package com.dimowner.audiorecorder.v2.app.overlay + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.ResultReceiver +import android.speech.RecognizerIntent + +internal const val EXTRA_RENAME_SPEECH_RESULT_RECEIVER = + "com.dimowner.audiorecorder.v2.app.overlay.EXTRA_RENAME_SPEECH_RESULT_RECEIVER" +internal const val EXTRA_RENAME_SPEECH_TEXT = + "com.dimowner.audiorecorder.v2.app.overlay.EXTRA_RENAME_SPEECH_TEXT" +internal const val RENAME_SPEECH_RESULT_OK = 1 +internal const val RENAME_SPEECH_RESULT_NO_MATCH = 2 +internal const val RENAME_SPEECH_RESULT_ERROR = 3 + +internal data class RenameSpeechRecognitionConfig( + val action: String = RecognizerIntent.ACTION_RECOGNIZE_SPEECH, + val languageModel: String = RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + val partialResults: Boolean = false, + val maxResults: Int = 1, + val preferOffline: Boolean = true, +) + +internal fun renameSpeechRecognitionConfig(): RenameSpeechRecognitionConfig = RenameSpeechRecognitionConfig() + +internal fun buildRenameSpeechRecognitionIntent(): Intent { + val config = renameSpeechRecognitionConfig() + return Intent(config.action).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, config.languageModel) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, config.partialResults) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, config.maxResults) + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, config.preferOffline) + } +} + +class FloatingRenameSpeechRecognitionActivity : Activity() { + + private var resultReceiver: ResultReceiver? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + resultReceiver = readResultReceiver() + if (savedInstanceState == null) { + launchRecognizer() + } + } + + private fun launchRecognizer() { + try { + // Use the activity-based RecognizerIntent contract instead of SpeechRecognizer. + // This delegates UI, permissions, and recognition lifecycle to the user's chosen + // recognizer app, matching Chromium-style FUTO Voice Input integration and avoiding + // low-level RecognitionListener callback management in Audio Recorder. + startActivityForResult(buildRenameSpeechRecognitionIntent(), REQUEST_RENAME_SPEECH) + } catch (e: ActivityNotFoundException) { + sendResult(RENAME_SPEECH_RESULT_ERROR) + finish() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != REQUEST_RENAME_SPEECH) return + + if (resultCode == RESULT_OK) { + val transcript = data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull { it.isNotBlank() } + if (transcript == null) { + sendResult(RENAME_SPEECH_RESULT_NO_MATCH) + } else { + sendResult(RENAME_SPEECH_RESULT_OK, transcript) + } + } else { + sendResult(RENAME_SPEECH_RESULT_NO_MATCH) + } + finish() + } + + @Suppress("DEPRECATION") + private fun readResultReceiver(): ResultReceiver? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_RENAME_SPEECH_RESULT_RECEIVER, ResultReceiver::class.java) + } else { + intent.getParcelableExtra(EXTRA_RENAME_SPEECH_RESULT_RECEIVER) + } + } + + private fun sendResult(resultCode: Int, transcript: String? = null) { + val data = Bundle().apply { + if (transcript != null) putString(EXTRA_RENAME_SPEECH_TEXT, transcript) + } + resultReceiver?.send(resultCode, data) + } + + companion object { + private const val REQUEST_RENAME_SPEECH = 1001 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92a5d134..2c533ef9 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,7 +54,6 @@ Replace Listening... Speech recognition is not available on this device. - Microphone permission is required for speech input. No speech recognized. Speech recognition failed. diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 1accae82..62fac639 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -1,11 +1,23 @@ package com.dimowner.audiorecorder.v2.app.overlay +import android.speech.RecognizerIntent import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Test class FloatingRecorderOverlayGeometryTest { + @Test + fun `renameSpeechRecognitionConfig uses activity recognizer action`() { + val config = renameSpeechRecognitionConfig() + + assertEquals(RecognizerIntent.ACTION_RECOGNIZE_SPEECH, config.action) + assertEquals(RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, config.languageModel) + assertEquals(1, config.maxResults) + assertFalse(config.partialResults) + } + @Test fun `applyRenameSpeechTranscription appends transcript with one separating space`() { val result = applyRenameSpeechTranscription( From 62ed1912448f001262a26d85beb26e341d2d9f3d Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 19 Jun 2026 20:25:44 +0200 Subject: [PATCH 06/13] feat: polish rename overlay actions Make the floating rename overlay save action more prominent and turn the previous default-name shortcut into a non-saving reset action. The reset action now restores the original record name, clears inline feedback, and leaves the cursor at the end so mistakes can be corrected without closing the overlay. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../FloatingRecorderOverlayGeometry.kt | 17 +++++ .../overlay/FloatingRecorderOverlayService.kt | 63 ++++++++++++++++--- app/src/main/res/values/strings.xml | 1 + .../FloatingRecorderOverlayGeometryTest.kt | 10 +++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index eb7e5f5e..9b28858a 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -11,6 +11,13 @@ internal data class OverlaySizeBounds(val minSize: Int, val maxSize: Int) internal data class RenameOverlayStyle(val panelColor: Int, val textColor: Int) +internal data class RenameResetState( + val text: String, + val selectionStart: Int, + val selectionEnd: Int, + val showInlineMessage: Boolean, +) + internal fun applyRenameSpeechTranscription( currentName: String, transcript: String, @@ -32,6 +39,16 @@ internal fun applyRenameSpeechTranscription( return combined.take(maxVisibleNameCharacters.coerceAtLeast(0)) } +internal fun buildRenameResetState(originalName: String): RenameResetState { + val cursorPosition = originalName.length + return RenameResetState( + text = originalName, + selectionStart = cursorPosition, + selectionEnd = cursorPosition, + showInlineMessage = false, + ) +} + internal fun calculateOverlaySizeBounds( defaultSize: Int, screenWidth: Int, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 752d37a5..4b9def63 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -39,6 +39,7 @@ import android.widget.PopupMenu import android.widget.TextView import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.audio.player.PlayerContractNew import com.dimowner.audiorecorder.v2.app.HomeActivity @@ -541,14 +542,14 @@ class FloatingRecorderOverlayService : Service() { bottomMargin = dp(6) }) addView(LinearLayout(this@FloatingRecorderOverlayService).apply { + orientation = LinearLayout.HORIZONTAL gravity = Gravity.END - addView(Button(this@FloatingRecorderOverlayService).apply { - text = getString(R.string.keep_default_name) - setOnClickListener { removeRenameOverlay() } - }) - addView(Button(this@FloatingRecorderOverlayService).apply { - text = getString(R.string.btn_save) - setOnClickListener { saveRename(record, input.text.toString(), error) } + addView(createRenameResetButton(record, input, error)) + addView(createRenameSaveButton(record, input, error), LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + leftMargin = dp(10) }) }) } @@ -580,6 +581,49 @@ class FloatingRecorderOverlayService : Service() { } } + private fun createRenameResetButton(record: Record, input: EditText, error: TextView): Button { + return Button(this).apply { + text = getString(R.string.reset_record_name) + isAllCaps = false + minWidth = dp(RENAME_RESET_BUTTON_MIN_WIDTH_DP) + minimumHeight = dp(RENAME_RESET_BUTTON_MIN_HEIGHT_DP) + textSize = 14f + setPadding(dp(12), 0, dp(12), 0) + setOnClickListener { resetRenameInput(record.name, input, error) } + } + } + + private fun createRenameSaveButton(record: Record, input: EditText, error: TextView): Button { + val checkIcon = ContextCompat.getDrawable(this, R.drawable.ic_check_circle)?.mutate()?.let { icon -> + DrawableCompat.wrap(icon).apply { + DrawableCompat.setTint(this, Color.WHITE) + } + } + + return Button(this).apply { + text = getString(R.string.btn_save) + isAllCaps = false + typeface = Typeface.DEFAULT_BOLD + textSize = 16f + minWidth = dp(RENAME_SAVE_BUTTON_MIN_WIDTH_DP) + minimumHeight = dp(RENAME_SAVE_BUTTON_MIN_HEIGHT_DP) + setTextColor(Color.WHITE) + setPadding(dp(18), 0, dp(20), 0) + background = roundedDrawable(SAVE_BUTTON_COLOR, dp(24).toFloat()) + compoundDrawablePadding = dp(8) + setCompoundDrawablesWithIntrinsicBounds(checkIcon, null, null, null) + setOnClickListener { saveRename(record, input.text.toString(), error) } + } + } + + private fun resetRenameInput(originalName: String, input: EditText, error: TextView) { + val resetState = buildRenameResetState(originalName) + input.setText(resetState.text) + input.setSelection(resetState.selectionStart, resetState.selectionEnd) + error.visibility = if (resetState.showInlineMessage) View.VISIBLE else View.GONE + input.requestFocus() + } + private fun createRenameSpeechButton(input: EditText, error: TextView): LinearLayout { val modeLabel = TextView(this).apply { setTextColor(Color.WHITE) @@ -930,8 +974,13 @@ class FloatingRecorderOverlayService : Service() { private const val RENAME_PANEL_MIN_WIDTH_DP = 240 private const val RENAME_PANEL_MAX_WIDTH_DP = 360 private const val RENAME_SPEECH_BUTTON_MIN_HEIGHT_DP = 72 + private const val RENAME_RESET_BUTTON_MIN_WIDTH_DP = 72 + private const val RENAME_RESET_BUTTON_MIN_HEIGHT_DP = 40 + private const val RENAME_SAVE_BUTTON_MIN_WIDTH_DP = 132 + private const val RENAME_SAVE_BUTTON_MIN_HEIGHT_DP = 52 private val IDLE_ICON_COLOR = Color.DKGRAY private val RECORDING_ICON_COLOR = Color.RED + private val SAVE_BUTTON_COLOR = Color.rgb(34, 139, 34) fun startService(context: Context) { val intent = Intent(context, FloatingRecorderOverlayService::class.java) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c533ef9..d9c5de9a 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,7 @@ Android requires Display over other apps permission before Audio Recorder can show the floating recorder button above navigation or reading apps. Open overlay settings Keep default + Reset Floating recorder button is active Tap the floating button to start or stop recording. Floating recorder diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 62fac639..aa045ec9 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -84,6 +84,16 @@ class FloatingRecorderOverlayGeometryTest { assertEquals(251, result.length) } + @Test + fun `buildRenameResetState restores original name and moves cursor to end`() { + val result = buildRenameResetState(originalName = "Original recording") + + assertEquals("Original recording", result.text) + assertEquals("Original recording".length, result.selectionStart) + assertEquals("Original recording".length, result.selectionEnd) + assertFalse(result.showInlineMessage) + } + @Test fun `calculateOverlaySizeBounds uses default size as minimum and half smaller screen as maximum`() { val bounds = calculateOverlaySizeBounds(defaultSize = 56, screenWidth = 1080, screenHeight = 1920) From 31eada7a59a9ac204cac720a74ce65409ba6dd95 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 20 Jun 2026 10:19:51 +0200 Subject: [PATCH 07/13] fix: keep rename overlay keyboard hidden Avoid focusing the rename text field or opening the soft keyboard when the floating rename overlay appears. Keep speech append and replace working by updating the filename field directly, and keep reset from focusing the text field so GPS/navigation views remain unobstructed. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../overlay/FloatingRecorderOverlayGeometry.kt | 16 ++++++++++++++++ .../overlay/FloatingRecorderOverlayService.kt | 12 ++++++------ .../FloatingRecorderOverlayGeometryTest.kt | 9 +++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index 9b28858a..c2b26ca0 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -18,6 +18,12 @@ internal data class RenameResetState( val showInlineMessage: Boolean, ) +internal data class RenameKeyboardPolicy( + val focusInputOnOpen: Boolean, + val showKeyboardOnOpen: Boolean, + val focusInputAfterReset: Boolean, +) + internal fun applyRenameSpeechTranscription( currentName: String, transcript: String, @@ -49,6 +55,16 @@ internal fun buildRenameResetState(originalName: String): RenameResetState { ) } +internal fun renameKeyboardPolicy(): RenameKeyboardPolicy { + return RenameKeyboardPolicy( + // The floating overlay is optimized for GPS/driving use: open quietly and let the + // dedicated speech button handle hands-light renaming without covering the host app. + focusInputOnOpen = false, + showKeyboardOnOpen = false, + focusInputAfterReset = false, + ) +} + internal fun calculateOverlaySizeBounds( defaultSize: Int, screenWidth: Int, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 4b9def63..04e8040d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -29,7 +29,6 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.WindowManager -import android.view.inputmethod.InputMethodManager import android.widget.Button import android.widget.EditText import android.widget.FrameLayout @@ -507,6 +506,7 @@ class FloatingRecorderOverlayService : Service() { overlayHeight = dp(RENAME_PANEL_ESTIMATED_HEIGHT_DP), ) val style = renameOverlayStyle(isDarkTheme = prefs.isDarkTheme) + val keyboardPolicy = renameKeyboardPolicy() val input = EditText(this).apply { setText(record.name) @@ -574,10 +574,8 @@ class FloatingRecorderOverlayService : Service() { renameView = panel windowManager.addView(panel, params) panel.post { clampAndPersistRenamePosition(panel, params, persist = false) } - input.requestFocus() - input.post { - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT) + if (keyboardPolicy.focusInputOnOpen) { + input.requestFocus() } } @@ -621,7 +619,9 @@ class FloatingRecorderOverlayService : Service() { input.setText(resetState.text) input.setSelection(resetState.selectionStart, resetState.selectionEnd) error.visibility = if (resetState.showInlineMessage) View.VISIBLE else View.GONE - input.requestFocus() + if (renameKeyboardPolicy().focusInputAfterReset) { + input.requestFocus() + } } private fun createRenameSpeechButton(input: EditText, error: TextView): LinearLayout { diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index aa045ec9..798042bc 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -94,6 +94,15 @@ class FloatingRecorderOverlayGeometryTest { assertFalse(result.showInlineMessage) } + @Test + fun `renameKeyboardPolicy keeps keyboard hidden until user edits manually`() { + val policy = renameKeyboardPolicy() + + assertFalse(policy.focusInputOnOpen) + assertFalse(policy.showKeyboardOnOpen) + assertFalse(policy.focusInputAfterReset) + } + @Test fun `calculateOverlaySizeBounds uses default size as minimum and half smaller screen as maximum`() { val bounds = calculateOverlaySizeBounds(defaultSize = 56, screenWidth = 1080, screenHeight = 1920) From 8222e624aa9a5f628d2608520067ba9fe376c741 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Mon, 22 Jun 2026 01:54:15 +0200 Subject: [PATCH 08/13] fix: gate overlay rename by stop source Prevent the floating rename overlay from opening after recordings stopped from the in-app record controls. Track whether the current stop request came from the overlay, and route post-recording rename UI through a shared policy so in-app stops show the app dialog while overlay stops show the overlay dialog. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../v2/app/home/HomeViewModel.kt | 12 +++- .../overlay/FloatingRecorderOverlayService.kt | 19 +++++-- .../v2/audio/AudioRecordingService.kt | 16 +++++- .../v2/audio/RecordingStoppedRenamePolicy.kt | 18 ++++++ .../audio/RecordingStoppedRenamePolicyTest.kt | 56 +++++++++++++++++++ 5 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicy.kt create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicyTest.kt diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt index e239757d..3f4bd447 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -63,6 +63,7 @@ import com.dimowner.audiorecorder.v2.audio.AudioRecordingServiceEvent import com.dimowner.audiorecorder.v2.audio.RecordingServiceState import com.dimowner.audiorecorder.v2.audio.RecordingState import com.dimowner.audiorecorder.v2.audio.readDescription +import com.dimowner.audiorecorder.v2.audio.recordingStoppedRenamePolicy import com.dimowner.audiorecorder.v2.data.FileDataSource import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource @@ -383,7 +384,7 @@ class HomeViewModel @Inject constructor( handleRecordingStopped( event.recordId, event.recordName, - event.startedFromFloatingOverlay, + event.stoppedFromFloatingOverlay, ) } is AudioRecordingServiceEvent.NewRecordingPartStarted -> { @@ -457,13 +458,18 @@ class HomeViewModel @Inject constructor( private suspend fun handleRecordingStopped( recordedRecordId: Long, recordName: String?, - startedFromFloatingOverlay: Boolean, + stoppedFromFloatingOverlay: Boolean, ) { withContext(ioDispatcher) { if (recordedRecordId >= 0) { if (_state.value.isDeleteRecordingProgressRequested) { moveRecordToRecycle(recordedRecordId, false) - } else if (prefs.askToRenameAfterRecordingStopped && !startedFromFloatingOverlay) { + } else if (recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = prefs.askToRenameAfterRecordingStopped, + recordId = recordedRecordId, + stoppedFromFloatingOverlay = stoppedFromFloatingOverlay, + ).showInAppRenameDialog + ) { updateState() withContext(mainDispatcher) { _state.value = _state.value.copy( diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 04e8040d..08dfeddc 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -44,6 +44,7 @@ import com.dimowner.audiorecorder.audio.player.PlayerContractNew import com.dimowner.audiorecorder.v2.app.HomeActivity import com.dimowner.audiorecorder.v2.audio.AudioRecordingService import com.dimowner.audiorecorder.v2.audio.AudioRecordingServiceEvent +import com.dimowner.audiorecorder.v2.audio.recordingStoppedRenamePolicy import com.dimowner.audiorecorder.v2.data.PrefsV2 import com.dimowner.audiorecorder.v2.data.RecordsDataSource import com.dimowner.audiorecorder.v2.data.model.Record @@ -95,7 +96,7 @@ class FloatingRecorderOverlayService : Service() { subscribeRecordingService(boundService) if (pendingStop) { pendingStop = false - boundService.stopRecording() + boundService.stopRecording(stoppedFromFloatingOverlay = true) } } } @@ -166,7 +167,10 @@ class FloatingRecorderOverlayService : Service() { recordingEventJob = serviceScope.launch { service.event.collect { event -> when (event) { - is AudioRecordingServiceEvent.RecordingStopped -> handleRecordingStopped(event.recordId) + is AudioRecordingServiceEvent.RecordingStopped -> handleRecordingStopped( + recordId = event.recordId, + stoppedFromFloatingOverlay = event.stoppedFromFloatingOverlay, + ) is AudioRecordingServiceEvent.ShowErrorSnack -> { Timber.w("Floating recorder start/stop error: ${event.message}") iconView?.post { updateIconAppearance(false) } @@ -177,9 +181,14 @@ class FloatingRecorderOverlayService : Service() { } } - private suspend fun handleRecordingStopped(recordId: Long) { + private suspend fun handleRecordingStopped(recordId: Long, stoppedFromFloatingOverlay: Boolean) { iconView?.post { runSavedAnimation() } - if (prefs.askToRenameAfterRecordingStopped && recordId >= 0) { + val renamePolicy = recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = prefs.askToRenameAfterRecordingStopped, + recordId = recordId, + stoppedFromFloatingOverlay = stoppedFromFloatingOverlay, + ) + if (renamePolicy.showFloatingOverlayRenameDialog) { recordsDataSource.getRecord(recordId)?.let { record -> iconView?.post { showRenameOverlay(record) } } @@ -419,7 +428,7 @@ class FloatingRecorderOverlayService : Service() { private fun handleIconTap() { if (isRecording) { - recordingService?.stopRecording() ?: run { + recordingService?.stopRecording(stoppedFromFloatingOverlay = true) ?: run { pendingStop = true bindRecordingService() } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt index 466e43dc..ed43655b 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/AudioRecordingService.kt @@ -171,6 +171,9 @@ class AudioRecordingService : Service() { /** True only for the current recording session if it was initiated by the overlay button. */ private var currentRecordingStartedFromFloatingOverlay: Boolean = false + /** True only for the current stop request if it was initiated by the overlay button. */ + private var currentRecordingStoppedFromFloatingOverlay: Boolean = false + inner class ServiceBinder : Binder() { fun getService(): AudioRecordingService = this@AudioRecordingService } @@ -194,6 +197,7 @@ class AudioRecordingService : Service() { EXTRA_STARTED_FROM_FLOATING_OVERLAY, false, ) + currentRecordingStoppedFromFloatingOverlay = false // Must call startForeground() synchronously before any async work // to satisfy the foreground service contract and avoid ANR. startForegroundWithNotification() @@ -489,7 +493,14 @@ class AudioRecordingService : Service() { } fun stopRecording() { - audioRecorder.stopRecording() + stopRecording(stoppedFromFloatingOverlay = false) + } + + fun stopRecording(stoppedFromFloatingOverlay: Boolean) { + currentRecordingStoppedFromFloatingOverlay = stoppedFromFloatingOverlay + if (!audioRecorder.stopRecording()) { + currentRecordingStoppedFromFloatingOverlay = false + } } private suspend fun handleRecordingStopped(isNotMaxDurationHandling: Boolean = true) { @@ -532,6 +543,7 @@ class AudioRecordingService : Service() { recordId = recordedRecordId, recordName = record.name, startedFromFloatingOverlay = currentRecordingStartedFromFloatingOverlay, + stoppedFromFloatingOverlay = currentRecordingStoppedFromFloatingOverlay, )) decodeRecord( recordId = recordUpdated.id, @@ -564,6 +576,7 @@ class AudioRecordingService : Service() { totalRecordingSampleCount = 0 recordingFullDataBuffer.reset() currentRecordingStartedFromFloatingOverlay = false + currentRecordingStoppedFromFloatingOverlay = false _recordingState.value = RecordingServiceState() stopNotificationUpdates() stopForeground(STOP_FOREGROUND_REMOVE) @@ -881,5 +894,6 @@ sealed class AudioRecordingServiceEvent { val recordId: Long, val recordName: String?, val startedFromFloatingOverlay: Boolean = false, + val stoppedFromFloatingOverlay: Boolean = false, ) : AudioRecordingServiceEvent() } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicy.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicy.kt new file mode 100644 index 00000000..9aef16ea --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicy.kt @@ -0,0 +1,18 @@ +package com.dimowner.audiorecorder.v2.audio + +internal data class RecordingStoppedRenamePolicy( + val showInAppRenameDialog: Boolean, + val showFloatingOverlayRenameDialog: Boolean, +) + +internal fun recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped: Boolean, + recordId: Long, + stoppedFromFloatingOverlay: Boolean, +): RecordingStoppedRenamePolicy { + val canRename = askToRenameAfterRecordingStopped && recordId >= 0 + return RecordingStoppedRenamePolicy( + showInAppRenameDialog = canRename && !stoppedFromFloatingOverlay, + showFloatingOverlayRenameDialog = canRename && stoppedFromFloatingOverlay, + ) +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicyTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicyTest.kt new file mode 100644 index 00000000..d7cb7a0c --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/audio/RecordingStoppedRenamePolicyTest.kt @@ -0,0 +1,56 @@ +package com.dimowner.audiorecorder.v2.audio + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingStoppedRenamePolicyTest { + + @Test + fun `in-app stop shows only in-app rename dialog`() { + val policy = recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = true, + recordId = 7L, + stoppedFromFloatingOverlay = false, + ) + + assertTrue(policy.showInAppRenameDialog) + assertFalse(policy.showFloatingOverlayRenameDialog) + } + + @Test + fun `overlay stop shows only floating overlay rename dialog`() { + val policy = recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = true, + recordId = 7L, + stoppedFromFloatingOverlay = true, + ) + + assertFalse(policy.showInAppRenameDialog) + assertTrue(policy.showFloatingOverlayRenameDialog) + } + + @Test + fun `rename disabled suppresses both post-recording rename surfaces`() { + val policy = recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = false, + recordId = 7L, + stoppedFromFloatingOverlay = true, + ) + + assertFalse(policy.showInAppRenameDialog) + assertFalse(policy.showFloatingOverlayRenameDialog) + } + + @Test + fun `invalid record id suppresses both post-recording rename surfaces`() { + val policy = recordingStoppedRenamePolicy( + askToRenameAfterRecordingStopped = true, + recordId = -1L, + stoppedFromFloatingOverlay = true, + ) + + assertFalse(policy.showInAppRenameDialog) + assertFalse(policy.showFloatingOverlayRenameDialog) + } +} From 7b24842599e90d1d77271e2fdd365aeedcd33758 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Mon, 22 Jun 2026 03:12:47 +0200 Subject: [PATCH 09/13] feat: append rename speech to audio notes Add a third floating rename mic mode that appends recognized speech to the record description as a new line while leaving the rename dialog open. Persist the new mode through the existing rename speech mode preference and save notes through updateRecordDescription so the existing saveDescriptionToFile setting controls COMMENT tag embedding. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../FloatingRecorderOverlayGeometry.kt | 19 ++++ .../overlay/FloatingRecorderOverlayService.kt | 93 ++++++++++++++++++- .../v2/data/model/RenameSpeechMode.kt | 1 + app/src/main/res/values/strings.xml | 1 + .../FloatingRecorderOverlayGeometryTest.kt | 31 +++++++ .../audiorecorder/v2/data/PrefsV2ImplTest.kt | 9 ++ 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index c2b26ca0..3e025e7f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -1,5 +1,6 @@ package com.dimowner.audiorecorder.v2.app.overlay +import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import kotlin.math.abs import kotlin.math.min @@ -41,10 +42,28 @@ internal fun applyRenameSpeechTranscription( .filter { it.isNotBlank() } .joinToString(" ") RenameSpeechMode.Replace -> normalizedTranscript + RenameSpeechMode.AppendToAudioNote -> currentName } return combined.take(maxVisibleNameCharacters.coerceAtLeast(0)) } +internal fun applyRenameSpeechTranscriptionToAudioNote( + currentDescription: String, + transcript: String, + maxDescriptionCharacters: Int = RECORD_DESCRIPTION_MAX_LENGTH, +): String { + val normalizedTranscript = transcript + .filterNot { Character.isISOControl(it) } + .trim() + .replace(Regex("\\s+"), " ") + if (normalizedTranscript.isBlank()) return currentDescription + + return listOf(currentDescription.trimEnd(), normalizedTranscript) + .filter { it.isNotBlank() } + .joinToString("\n") + .take(maxDescriptionCharacters.coerceAtLeast(0)) +} + internal fun buildRenameResetState(originalName: String): RenameResetState { val cursorPosition = originalName.length return RenameResetState( diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 08dfeddc..27a53753 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -529,7 +529,14 @@ class FloatingRecorderOverlayService : Service() { setTextColor(RECORDING_ICON_COLOR) visibility = View.GONE } - val speechButton = createRenameSpeechButton(input, error) + var currentDescription = record.description + val speechButton = createRenameSpeechButton( + record = record, + input = input, + error = error, + getCurrentDescription = { currentDescription }, + onDescriptionUpdated = { currentDescription = it }, + ) val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -633,7 +640,13 @@ class FloatingRecorderOverlayService : Service() { } } - private fun createRenameSpeechButton(input: EditText, error: TextView): LinearLayout { + private fun createRenameSpeechButton( + record: Record, + input: EditText, + error: TextView, + getCurrentDescription: () -> String, + onDescriptionUpdated: (String) -> Unit, + ): LinearLayout { val modeLabel = TextView(this).apply { setTextColor(Color.WHITE) textSize = 12f @@ -660,7 +673,14 @@ class FloatingRecorderOverlayService : Service() { LinearLayout.LayoutParams.WRAP_CONTENT, )) setOnClickListener { - startRenameSpeechRecognition(input = input, error = error, micButton = this) + startRenameSpeechRecognition( + record = record, + input = input, + error = error, + micButton = this, + getCurrentDescription = getCurrentDescription, + onDescriptionUpdated = onDescriptionUpdated, + ) } setOnLongClickListener { showRenameSpeechModePopup(anchor = this, modeLabel = modeLabel) @@ -673,6 +693,7 @@ class FloatingRecorderOverlayService : Service() { PopupMenu(this, anchor).apply { menu.add(0, RenameSpeechMode.Append.persistedValue, 0, getString(R.string.rename_speech_mode_append)) menu.add(0, RenameSpeechMode.Replace.persistedValue, 1, getString(R.string.rename_speech_mode_replace)) + menu.add(0, RenameSpeechMode.AppendToAudioNote.persistedValue, 2, getString(R.string.rename_speech_mode_append_note)) setOnMenuItemClickListener { item -> val mode = RenameSpeechMode.fromPersistedValue(item.itemId) prefs.floatingRecorderRenameSpeechMode = mode @@ -684,9 +705,12 @@ class FloatingRecorderOverlayService : Service() { } private fun startRenameSpeechRecognition( + record: Record, input: EditText, error: TextView, micButton: View, + getCurrentDescription: () -> String, + onDescriptionUpdated: (String) -> Unit, ) { if (renameSpeechPending) return if (buildRenameSpeechRecognitionIntent().resolveActivity(packageManager) == null) { @@ -701,7 +725,14 @@ class FloatingRecorderOverlayService : Service() { val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { when (resultCode) { - RENAME_SPEECH_RESULT_OK -> applyRenameSpeechResult(input, error, resultData) + RENAME_SPEECH_RESULT_OK -> applyRenameSpeechResult( + record = record, + input = input, + error = error, + resultData = resultData, + getCurrentDescription = getCurrentDescription, + onDescriptionUpdated = onDescriptionUpdated, + ) RENAME_SPEECH_RESULT_NO_MATCH -> showRenameInlineMessage(error, R.string.rename_speech_no_match) else -> showRenameInlineMessage(error, R.string.rename_speech_error) } @@ -722,13 +753,31 @@ class FloatingRecorderOverlayService : Service() { } } - private fun applyRenameSpeechResult(input: EditText, error: TextView, resultData: Bundle?) { + private fun applyRenameSpeechResult( + record: Record, + input: EditText, + error: TextView, + resultData: Bundle?, + getCurrentDescription: () -> String, + onDescriptionUpdated: (String) -> Unit, + ) { val transcript = resultData?.getString(EXTRA_RENAME_SPEECH_TEXT) if (transcript.isNullOrBlank()) { showRenameInlineMessage(error, R.string.rename_speech_no_match) return } + if (prefs.floatingRecorderRenameSpeechMode == RenameSpeechMode.AppendToAudioNote) { + appendRenameSpeechResultToAudioNote( + record = record, + currentDescription = getCurrentDescription(), + transcript = transcript, + error = error, + onDescriptionUpdated = onDescriptionUpdated, + ) + return + } + val updated = applyRenameSpeechTranscription( currentName = input.text.toString(), transcript = transcript, @@ -739,6 +788,39 @@ class FloatingRecorderOverlayService : Service() { error.visibility = View.GONE } + private fun appendRenameSpeechResultToAudioNote( + record: Record, + currentDescription: String, + transcript: String, + error: TextView, + onDescriptionUpdated: (String) -> Unit, + ) { + val updatedDescription = applyRenameSpeechTranscriptionToAudioNote( + currentDescription = currentDescription, + transcript = transcript, + ) + if (updatedDescription == currentDescription) { + showRenameInlineMessage(error, R.string.rename_speech_no_match) + return + } + + serviceScope.launch { + val success = recordsDataSource.updateRecordDescription( + recordId = record.id, + description = updatedDescription, + writeToFile = prefs.saveDescriptionToFile, + ) + iconView?.post { + if (success) { + onDescriptionUpdated(updatedDescription) + showRenameInlineMessage(error, R.string.msg_saved_successfully) + } else { + showRenameInlineMessage(error, R.string.msg_file_operation_failed) + } + } + } + } + private fun finishRenameSpeechRecognition(micButton: View) { renameSpeechPending = false micButton.isEnabled = true @@ -753,6 +835,7 @@ class FloatingRecorderOverlayService : Service() { return when (mode) { RenameSpeechMode.Append -> getString(R.string.rename_speech_mode_append) RenameSpeechMode.Replace -> getString(R.string.rename_speech_mode_replace) + RenameSpeechMode.AppendToAudioNote -> getString(R.string.rename_speech_mode_append_note) } } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt index f09e65fe..260abeec 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/data/model/RenameSpeechMode.kt @@ -3,6 +3,7 @@ package com.dimowner.audiorecorder.v2.data.model enum class RenameSpeechMode(val persistedValue: Int) { Append(0), Replace(1), + AppendToAudioNote(2), ; companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b214b8cb..4b293ef9 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,7 @@ Name cannot be empty Append Replace + Append to note Listening... Speech recognition is not available on this device. No speech recognized. diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 798042bc..83866eb7 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -84,6 +84,37 @@ class FloatingRecorderOverlayGeometryTest { assertEquals(251, result.length) } + @Test + fun `applyRenameSpeechTranscriptionToAudioNote appends transcript on a new line`() { + val result = applyRenameSpeechTranscriptionToAudioNote( + currentDescription = "First thought", + transcript = " second thought ", + ) + + assertEquals("First thought\nsecond thought", result) + } + + @Test + fun `applyRenameSpeechTranscriptionToAudioNote appends to blank note without leading newline`() { + val result = applyRenameSpeechTranscriptionToAudioNote( + currentDescription = "", + transcript = "new note", + ) + + assertEquals("new note", result) + } + + @Test + fun `applyRenameSpeechTranscriptionToAudioNote caps note to max description length`() { + val result = applyRenameSpeechTranscriptionToAudioNote( + currentDescription = "Existing", + transcript = "a".repeat(600), + maxDescriptionCharacters = 20, + ) + + assertEquals("Existing\naaaaaaaaaaa", result) + } + @Test fun `buildRenameResetState restores original name and moves cursor to end`() { val result = buildRenameResetState(originalName = "Original recording") diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt index e69a6c4b..1d30b2f6 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/data/PrefsV2ImplTest.kt @@ -106,4 +106,13 @@ class PrefsV2ImplTest { assertEquals(RenameSpeechMode.Replace, reloadedPrefs.floatingRecorderRenameSpeechMode) } + + @Test + fun `floating recorder rename speech mode persists append to audio note`() { + prefs.floatingRecorderRenameSpeechMode = RenameSpeechMode.AppendToAudioNote + + val reloadedPrefs = PrefsV2Impl(context) + + assertEquals(RenameSpeechMode.AppendToAudioNote, reloadedPrefs.floatingRecorderRenameSpeechMode) + } } From be60009dd1fb1244e90074c17eca23f81431bf83 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Mon, 22 Jun 2026 03:50:29 +0200 Subject: [PATCH 10/13] fix: preserve overlay description drafts Add a compact one-line visible description field to the floating rename overlay, update it when speech is captured in description mode, and save pending filename and description changes together. Rename speech mode labels so filename and description targets are explicit in the mic long-press menu. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../FloatingRecorderOverlayGeometry.kt | 36 +++++ .../overlay/FloatingRecorderOverlayService.kt | 137 ++++++++++-------- app/src/main/res/values/strings.xml | 6 +- .../FloatingRecorderOverlayGeometryTest.kt | 41 ++++++ 4 files changed, 156 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index 3e025e7f..d2d66342 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -1,6 +1,7 @@ package com.dimowner.audiorecorder.v2.app.overlay import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH +import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import kotlin.math.abs import kotlin.math.min @@ -25,6 +26,41 @@ internal data class RenameKeyboardPolicy( val focusInputAfterReset: Boolean, ) +internal data class RenameOverlaySaveRequest( + val name: String, + val description: String, + val shouldRename: Boolean, + val shouldUpdateDescription: Boolean, + val showNameEmptyError: Boolean, +) + +internal fun renameSpeechModeLabelRes(mode: RenameSpeechMode): Int { + return when (mode) { + RenameSpeechMode.Append -> R.string.rename_speech_mode_append_filename + RenameSpeechMode.Replace -> R.string.rename_speech_mode_replace_filename + RenameSpeechMode.AppendToAudioNote -> R.string.rename_speech_mode_append_description + } +} + +internal fun buildRenameOverlaySaveRequest( + originalName: String, + originalDescription: String, + inputName: String, + inputDescription: String, + maxDescriptionCharacters: Int = RECORD_DESCRIPTION_MAX_LENGTH, +): RenameOverlaySaveRequest { + val trimmedName = inputName.trim() + val boundedDescription = inputDescription.take(maxDescriptionCharacters.coerceAtLeast(0)) + + return RenameOverlaySaveRequest( + name = trimmedName, + description = boundedDescription, + shouldRename = trimmedName.isNotEmpty() && trimmedName != originalName, + shouldUpdateDescription = boundedDescription != originalDescription, + showNameEmptyError = trimmedName.isEmpty(), + ) +} + internal fun applyRenameSpeechTranscription( currentName: String, transcript: String, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 27a53753..982fb57d 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -24,6 +24,8 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ResultReceiver +import android.text.InputFilter +import android.text.InputType import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -39,6 +41,7 @@ import android.widget.TextView import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat +import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.audio.player.PlayerContractNew import com.dimowner.audiorecorder.v2.app.HomeActivity @@ -525,17 +528,28 @@ class FloatingRecorderOverlayService : Service() { typeface = Typeface.DEFAULT_BOLD backgroundTintList = ColorStateList.valueOf(style.textColor) } + val descriptionInput = EditText(this).apply { + setText(record.description) + setTextColor(style.textColor) + setHintTextColor(if (prefs.isDarkTheme) 0x99FFFFFF.toInt() else 0x99000000.toInt()) + backgroundTintList = ColorStateList.valueOf(style.textColor) + hint = getString(R.string.rec_description_hint) + minLines = RENAME_DESCRIPTION_VISIBLE_LINES + maxLines = RENAME_DESCRIPTION_VISIBLE_LINES + gravity = Gravity.TOP or Gravity.START + filters = arrayOf(InputFilter.LengthFilter(RECORD_DESCRIPTION_MAX_LENGTH)) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + } val error = TextView(this).apply { setTextColor(RECORDING_ICON_COLOR) visibility = View.GONE } - var currentDescription = record.description val speechButton = createRenameSpeechButton( - record = record, input = input, + descriptionInput = descriptionInput, error = error, - getCurrentDescription = { currentDescription }, - onDescriptionUpdated = { currentDescription = it }, ) val panel = LinearLayout(this).apply { @@ -549,6 +563,7 @@ class FloatingRecorderOverlayService : Service() { setPadding(0, 0, 0, dp(8)) }) addView(input, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + addView(descriptionInput, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) addView(error, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) addView(speechButton, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, @@ -561,7 +576,7 @@ class FloatingRecorderOverlayService : Service() { orientation = LinearLayout.HORIZONTAL gravity = Gravity.END addView(createRenameResetButton(record, input, error)) - addView(createRenameSaveButton(record, input, error), LinearLayout.LayoutParams( + addView(createRenameSaveButton(record, input, descriptionInput, error), LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ).apply { @@ -607,7 +622,12 @@ class FloatingRecorderOverlayService : Service() { } } - private fun createRenameSaveButton(record: Record, input: EditText, error: TextView): Button { + private fun createRenameSaveButton( + record: Record, + input: EditText, + descriptionInput: EditText, + error: TextView, + ): Button { val checkIcon = ContextCompat.getDrawable(this, R.drawable.ic_check_circle)?.mutate()?.let { icon -> DrawableCompat.wrap(icon).apply { DrawableCompat.setTint(this, Color.WHITE) @@ -626,7 +646,14 @@ class FloatingRecorderOverlayService : Service() { background = roundedDrawable(SAVE_BUTTON_COLOR, dp(24).toFloat()) compoundDrawablePadding = dp(8) setCompoundDrawablesWithIntrinsicBounds(checkIcon, null, null, null) - setOnClickListener { saveRename(record, input.text.toString(), error) } + setOnClickListener { + saveRename( + record = record, + newName = input.text.toString(), + newDescription = descriptionInput.text.toString(), + error = error, + ) + } } } @@ -641,11 +668,9 @@ class FloatingRecorderOverlayService : Service() { } private fun createRenameSpeechButton( - record: Record, input: EditText, + descriptionInput: EditText, error: TextView, - getCurrentDescription: () -> String, - onDescriptionUpdated: (String) -> Unit, ): LinearLayout { val modeLabel = TextView(this).apply { setTextColor(Color.WHITE) @@ -674,12 +699,10 @@ class FloatingRecorderOverlayService : Service() { )) setOnClickListener { startRenameSpeechRecognition( - record = record, input = input, + descriptionInput = descriptionInput, error = error, micButton = this, - getCurrentDescription = getCurrentDescription, - onDescriptionUpdated = onDescriptionUpdated, ) } setOnLongClickListener { @@ -691,9 +714,9 @@ class FloatingRecorderOverlayService : Service() { private fun showRenameSpeechModePopup(anchor: View, modeLabel: TextView) { PopupMenu(this, anchor).apply { - menu.add(0, RenameSpeechMode.Append.persistedValue, 0, getString(R.string.rename_speech_mode_append)) - menu.add(0, RenameSpeechMode.Replace.persistedValue, 1, getString(R.string.rename_speech_mode_replace)) - menu.add(0, RenameSpeechMode.AppendToAudioNote.persistedValue, 2, getString(R.string.rename_speech_mode_append_note)) + menu.add(0, RenameSpeechMode.Append.persistedValue, 0, getString(renameSpeechModeLabelRes(RenameSpeechMode.Append))) + menu.add(0, RenameSpeechMode.Replace.persistedValue, 1, getString(renameSpeechModeLabelRes(RenameSpeechMode.Replace))) + menu.add(0, RenameSpeechMode.AppendToAudioNote.persistedValue, 2, getString(renameSpeechModeLabelRes(RenameSpeechMode.AppendToAudioNote))) setOnMenuItemClickListener { item -> val mode = RenameSpeechMode.fromPersistedValue(item.itemId) prefs.floatingRecorderRenameSpeechMode = mode @@ -705,12 +728,10 @@ class FloatingRecorderOverlayService : Service() { } private fun startRenameSpeechRecognition( - record: Record, input: EditText, + descriptionInput: EditText, error: TextView, micButton: View, - getCurrentDescription: () -> String, - onDescriptionUpdated: (String) -> Unit, ) { if (renameSpeechPending) return if (buildRenameSpeechRecognitionIntent().resolveActivity(packageManager) == null) { @@ -726,12 +747,10 @@ class FloatingRecorderOverlayService : Service() { override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { when (resultCode) { RENAME_SPEECH_RESULT_OK -> applyRenameSpeechResult( - record = record, input = input, + descriptionInput = descriptionInput, error = error, resultData = resultData, - getCurrentDescription = getCurrentDescription, - onDescriptionUpdated = onDescriptionUpdated, ) RENAME_SPEECH_RESULT_NO_MATCH -> showRenameInlineMessage(error, R.string.rename_speech_no_match) else -> showRenameInlineMessage(error, R.string.rename_speech_error) @@ -754,12 +773,10 @@ class FloatingRecorderOverlayService : Service() { } private fun applyRenameSpeechResult( - record: Record, input: EditText, + descriptionInput: EditText, error: TextView, resultData: Bundle?, - getCurrentDescription: () -> String, - onDescriptionUpdated: (String) -> Unit, ) { val transcript = resultData?.getString(EXTRA_RENAME_SPEECH_TEXT) if (transcript.isNullOrBlank()) { @@ -768,12 +785,10 @@ class FloatingRecorderOverlayService : Service() { } if (prefs.floatingRecorderRenameSpeechMode == RenameSpeechMode.AppendToAudioNote) { - appendRenameSpeechResultToAudioNote( - record = record, - currentDescription = getCurrentDescription(), + appendRenameSpeechResultToDescriptionDraft( + descriptionInput = descriptionInput, transcript = transcript, error = error, - onDescriptionUpdated = onDescriptionUpdated, ) return } @@ -788,13 +803,12 @@ class FloatingRecorderOverlayService : Service() { error.visibility = View.GONE } - private fun appendRenameSpeechResultToAudioNote( - record: Record, - currentDescription: String, + private fun appendRenameSpeechResultToDescriptionDraft( + descriptionInput: EditText, transcript: String, error: TextView, - onDescriptionUpdated: (String) -> Unit, ) { + val currentDescription = descriptionInput.text.toString() val updatedDescription = applyRenameSpeechTranscriptionToAudioNote( currentDescription = currentDescription, transcript = transcript, @@ -804,21 +818,9 @@ class FloatingRecorderOverlayService : Service() { return } - serviceScope.launch { - val success = recordsDataSource.updateRecordDescription( - recordId = record.id, - description = updatedDescription, - writeToFile = prefs.saveDescriptionToFile, - ) - iconView?.post { - if (success) { - onDescriptionUpdated(updatedDescription) - showRenameInlineMessage(error, R.string.msg_saved_successfully) - } else { - showRenameInlineMessage(error, R.string.msg_file_operation_failed) - } - } - } + descriptionInput.setText(updatedDescription) + descriptionInput.setSelection(updatedDescription.length) + error.visibility = View.GONE } private fun finishRenameSpeechRecognition(micButton: View) { @@ -832,11 +834,7 @@ class FloatingRecorderOverlayService : Service() { } private fun renameSpeechModeText(mode: RenameSpeechMode): String { - return when (mode) { - RenameSpeechMode.Append -> getString(R.string.rename_speech_mode_append) - RenameSpeechMode.Replace -> getString(R.string.rename_speech_mode_replace) - RenameSpeechMode.AppendToAudioNote -> getString(R.string.rename_speech_mode_append_note) - } + return getString(renameSpeechModeLabelRes(mode)) } private inner class RenameOverlayTouchListener( @@ -922,19 +920,35 @@ class FloatingRecorderOverlayService : Service() { ) } - private fun saveRename(record: Record, newName: String, error: TextView) { - val trimmed = newName.trim() - if (trimmed.isEmpty()) { + private fun saveRename( + record: Record, + newName: String, + newDescription: String, + error: TextView, + ) { + val request = buildRenameOverlaySaveRequest( + originalName = record.name, + originalDescription = record.description, + inputName = newName, + inputDescription = newDescription, + ) + if (request.showNameEmptyError) { error.text = getString(R.string.msg_name_cannot_be_empty) error.visibility = View.VISIBLE return } serviceScope.launch { - val success = if (trimmed == record.name) { - true - } else { - recordsDataSource.renameRecord(record, trimmed) + var success = true + if (request.shouldRename) { + success = recordsDataSource.renameRecord(record, request.name) + } + if (success && request.shouldUpdateDescription) { + success = recordsDataSource.updateRecordDescription( + recordId = record.id, + description = request.description, + writeToFile = prefs.saveDescriptionToFile, + ) } withContext(ioDispatcher) { iconView?.post { @@ -1062,10 +1076,11 @@ class FloatingRecorderOverlayService : Service() { private const val SAVE_FEEDBACK_DURATION_MS = 3000L private const val DEFAULT_ICON_SIZE_DP = 56 private const val DEFAULT_RECORD_DISC_SIZE_DP = 30 - private const val RENAME_PANEL_ESTIMATED_HEIGHT_DP = 220 + private const val RENAME_PANEL_ESTIMATED_HEIGHT_DP = 320 private const val RENAME_PANEL_MIN_WIDTH_DP = 240 private const val RENAME_PANEL_MAX_WIDTH_DP = 360 private const val RENAME_SPEECH_BUTTON_MIN_HEIGHT_DP = 72 + private const val RENAME_DESCRIPTION_VISIBLE_LINES = 1 private const val RENAME_RESET_BUTTON_MIN_WIDTH_DP = 72 private const val RENAME_RESET_BUTTON_MIN_HEIGHT_DP = 40 private const val RENAME_SAVE_BUTTON_MIN_WIDTH_DP = 132 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b293ef9..cd7f6926 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,9 +50,9 @@ File operation failed. Please try again Name cannot be empty - Append - Replace - Append to note + Append to filename + Replace filename + Append to description Listening... Speech recognition is not available on this device. No speech recognized. diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 83866eb7..a4fd07d9 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -1,9 +1,11 @@ package com.dimowner.audiorecorder.v2.app.overlay import android.speech.RecognizerIntent +import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class FloatingRecorderOverlayGeometryTest { @@ -84,6 +86,13 @@ class FloatingRecorderOverlayGeometryTest { assertEquals(251, result.length) } + @Test + fun `renameSpeechModeLabelRes describes filename and description targets explicitly`() { + assertEquals(R.string.rename_speech_mode_append_filename, renameSpeechModeLabelRes(RenameSpeechMode.Append)) + assertEquals(R.string.rename_speech_mode_replace_filename, renameSpeechModeLabelRes(RenameSpeechMode.Replace)) + assertEquals(R.string.rename_speech_mode_append_description, renameSpeechModeLabelRes(RenameSpeechMode.AppendToAudioNote)) + } + @Test fun `applyRenameSpeechTranscriptionToAudioNote appends transcript on a new line`() { val result = applyRenameSpeechTranscriptionToAudioNote( @@ -115,6 +124,38 @@ class FloatingRecorderOverlayGeometryTest { assertEquals("Existing\naaaaaaaaaaa", result) } + @Test + fun `buildRenameOverlaySaveRequest keeps pending filename and description changes`() { + val request = buildRenameOverlaySaveRequest( + originalName = "Original name", + originalDescription = "Original description", + inputName = " New name ", + inputDescription = "Original description\nVoice note", + ) + + assertEquals("New name", request.name) + assertEquals("Original description\nVoice note", request.description) + assertTrue(request.shouldRename) + assertTrue(request.shouldUpdateDescription) + assertFalse(request.showNameEmptyError) + } + + @Test + fun `buildRenameOverlaySaveRequest rejects blank filename without dropping description draft`() { + val request = buildRenameOverlaySaveRequest( + originalName = "Original name", + originalDescription = "", + inputName = " ", + inputDescription = "Voice note", + ) + + assertEquals("", request.name) + assertEquals("Voice note", request.description) + assertFalse(request.shouldRename) + assertTrue(request.shouldUpdateDescription) + assertTrue(request.showNameEmptyError) + } + @Test fun `buildRenameResetState restores original name and moves cursor to end`() { val result = buildRenameResetState(originalName = "Original recording") From a409098251e5f400d8eb41558509f2afe04e3131 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Mon, 22 Jun 2026 04:12:51 +0200 Subject: [PATCH 11/13] fix: compact overlay description field Use an overlay-specific short description hint and clear the default EditText minimum height, vertical padding, font padding, and scrollbar so the description draft field stays one visible line high. Add localized short hint strings for all supported resource folders and cover the compact field configuration with a focused unit test. Agentic harness: OpenCode with OpenAI gpt-5.5. --- .../overlay/FloatingRecorderOverlayGeometry.kt | 18 ++++++++++++++++++ .../overlay/FloatingRecorderOverlayService.kt | 16 ++++++++++++---- app/src/main/res/values-bg/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 3 ++- app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt-rPT/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-tr/strings.xml | 1 + app/src/main/res/values-uk/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../FloatingRecorderOverlayGeometryTest.kt | 11 +++++++++++ 20 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index d2d66342..b985d358 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -26,6 +26,14 @@ internal data class RenameKeyboardPolicy( val focusInputAfterReset: Boolean, ) +internal data class RenameDescriptionInputConfig( + val hintRes: Int, + val visibleLines: Int, + val minimumHeightPx: Int, + val verticalPaddingPx: Int, + val clearDefaultMinimumHeight: Boolean, +) + internal data class RenameOverlaySaveRequest( val name: String, val description: String, @@ -42,6 +50,16 @@ internal fun renameSpeechModeLabelRes(mode: RenameSpeechMode): Int { } } +internal fun renameDescriptionInputConfig(): RenameDescriptionInputConfig { + return RenameDescriptionInputConfig( + hintRes = R.string.floating_rename_description_hint, + visibleLines = 1, + minimumHeightPx = 0, + verticalPaddingPx = 0, + clearDefaultMinimumHeight = true, + ) +} + internal fun buildRenameOverlaySaveRequest( originalName: String, originalDescription: String, diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt index 982fb57d..311ba4d1 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayService.kt @@ -529,13 +529,22 @@ class FloatingRecorderOverlayService : Service() { backgroundTintList = ColorStateList.valueOf(style.textColor) } val descriptionInput = EditText(this).apply { + val config = renameDescriptionInputConfig() setText(record.description) setTextColor(style.textColor) setHintTextColor(if (prefs.isDarkTheme) 0x99FFFFFF.toInt() else 0x99000000.toInt()) backgroundTintList = ColorStateList.valueOf(style.textColor) - hint = getString(R.string.rec_description_hint) - minLines = RENAME_DESCRIPTION_VISIBLE_LINES - maxLines = RENAME_DESCRIPTION_VISIBLE_LINES + hint = getString(config.hintRes) + setLines(config.visibleLines) + minLines = config.visibleLines + maxLines = config.visibleLines + if (config.clearDefaultMinimumHeight) { + minHeight = config.minimumHeightPx + minimumHeight = config.minimumHeightPx + } + setPadding(paddingLeft, config.verticalPaddingPx, paddingRight, config.verticalPaddingPx) + includeFontPadding = false + isVerticalScrollBarEnabled = false gravity = Gravity.TOP or Gravity.START filters = arrayOf(InputFilter.LengthFilter(RECORD_DESCRIPTION_MAX_LENGTH)) inputType = InputType.TYPE_CLASS_TEXT or @@ -1080,7 +1089,6 @@ class FloatingRecorderOverlayService : Service() { private const val RENAME_PANEL_MIN_WIDTH_DP = 240 private const val RENAME_PANEL_MAX_WIDTH_DP = 360 private const val RENAME_SPEECH_BUTTON_MIN_HEIGHT_DP = 72 - private const val RENAME_DESCRIPTION_VISIBLE_LINES = 1 private const val RENAME_RESET_BUTTON_MIN_WIDTH_DP = 72 private const val RENAME_RESET_BUTTON_MIN_HEIGHT_DP = 40 private const val RENAME_SAVE_BUTTON_MIN_WIDTH_DP = 132 diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 56a0834e..7b2b765a 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -178,6 +178,7 @@ Местоположение на файла: Описание Добавете описание за този запис + Добави описание Редактиране на описанието Добави описание Запишете описанието в аудио файла diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 7ea45ef7..a0820dde 100755 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -193,6 +193,7 @@ Ubicació del fitxer: Descripció Afegiu una descripció per a aquesta gravació + Afegir una descripció Editar descripció Afegir descripció Escriu la descripció al fitxer d\'àudio diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bd4781ca..65cdaa6f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -196,6 +196,7 @@ Dateispeicherort: Beschreibung Beschreibung für diese Aufnahme hinzufügen + Beschreibung hinzufügen Beschreibung bearbeiten Beschreibung hinzufügen Beschreibung in die Audiodatei schreiben diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 340fb112..dfc5c3aa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -195,6 +195,7 @@ Ubicación de archivo: Descripción Añadir una descripción para esta grabación + Añadir una descripción Editar descripción Añadir descripción Escribir descripción en el archivo de audio diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a00fd927..0198472a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -177,6 +177,7 @@ Chemin du fichier : Description Ajouter une description pour cet enregistrement + Ajouter une description Modifier la description Ajouter une description Écrire la description dans le fichier audio diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f226e2a6..03f59192 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -196,6 +196,7 @@ Posizione file: Descrizione Aggiungi una descrizione per questa registrazione + Aggiungi una descrizione Modifica descrizione Aggiungi descrizione Scrivi la descrizione nel file audio diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index db4d47b9..c1cf3123 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -197,6 +197,7 @@ ファイルの場所: 説明 この録音に説明を追加 + 説明を追加 説明を編集 説明を追加 オーディオファイルに説明を書き込む diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f17e79f6..307b0958 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -179,6 +179,7 @@ 파일 위치: 설명 이 녹음에 설명을 추가하세요 + 설명 추가 설명 편집 설명 추가 오디오 파일에 설명 쓰기 @@ -438,4 +439,4 @@ 뒤로 가기 메뉴 - \ No newline at end of file + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e94d29a1..6945f7da 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -215,6 +215,7 @@ Lokalizacja pliku: Opis Dodaj opis do tego nagrania + Dodaj opis Edytuj opis Dodaj opis Zapisz opis w pliku audio diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 518577fd..46f5133f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -194,6 +194,7 @@ Local do arquivo: Descrição Adicionar uma descrição para esta gravação + Adicionar descrição Editar descrição Adicionar descrição Escrever descrição no arquivo de áudio diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 95279b5a..2dc928a4 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -197,6 +197,7 @@ Localização do ficheiro: Descrição Adicionar uma descrição para esta gravação + Adicionar descrição Editar descrição Adicionar descrição Escrever descrição no ficheiro de áudio diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2547309c..073328b5 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -194,6 +194,7 @@ Расположение файла: Описание Добавить описание к этой записи + Добавить описание Редактировать описание Добавить описание Записать описание в аудиофайл diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b3dd0df8..1cee9d1d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -194,6 +194,7 @@ Dosya konumu: Açıklama Bu kayıt için bir açıklama ekleyin + Açıklama ekle Açıklamayı düzenle Açıklama ekle Açıklamayı ses dosyasına yaz diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 82b41f2d..b5982393 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -195,6 +195,7 @@ Розташування файлу: Опис Додати опис для цього запису + Додати опис Редагувати опис Додати опис Записати опис в аудіофайл diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f89827d0..f159b09a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -197,6 +197,7 @@ 位置: 描述 為此錄音新增描述 + 新增描述 編輯描述 新增描述 將描述寫入音訊檔 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index a00050fe..0d98f33b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -167,6 +167,7 @@ 文件位置: 描述 为此录音添加描述 + 添加描述 编辑描述 添加描述 将描述写入音频文件 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd7f6926..75978877 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,7 @@ Append to filename Replace filename Append to description + Add a description Listening... Speech recognition is not available on this device. No speech recognized. diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index a4fd07d9..d5104c8d 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -175,6 +175,17 @@ class FloatingRecorderOverlayGeometryTest { assertFalse(policy.focusInputAfterReset) } + @Test + fun `renameDescriptionInputConfig keeps description field compact`() { + val config = renameDescriptionInputConfig() + + assertEquals(R.string.floating_rename_description_hint, config.hintRes) + assertEquals(1, config.visibleLines) + assertEquals(0, config.minimumHeightPx) + assertEquals(0, config.verticalPaddingPx) + assertTrue(config.clearDefaultMinimumHeight) + } + @Test fun `calculateOverlaySizeBounds uses default size as minimum and half smaller screen as maximum`() { val bounds = calculateOverlaySizeBounds(defaultSize = 56, screenWidth = 1080, screenHeight = 1920) From 8647760c444114818df1635486229869d69e7f1b Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Mon, 22 Jun 2026 04:31:43 +0200 Subject: [PATCH 12/13] fix: sanitize overlay filename speech Speech transcripts used for overlay filename append or replace could still contain characters that are invalid on common desktop filesystems, causing non-portable generated filenames. Sanitize filename speech against cross-platform reserved characters, trailing Windows-invalid dots or spaces, and reserved Windows device names while preserving punctuation for audio-note transcripts. Agentic harness: OpenCode, OpenAI GPT-5.5. --- .../FloatingRecorderOverlayGeometry.kt | 63 +++++++++++++++++-- .../FloatingRecorderOverlayGeometryTest.kt | 30 +++++++-- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index b985d358..4b043252 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -3,6 +3,7 @@ package com.dimowner.audiorecorder.v2.app.overlay import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH import com.dimowner.audiorecorder.R import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode +import java.util.Locale import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt @@ -85,10 +86,7 @@ internal fun applyRenameSpeechTranscription( mode: RenameSpeechMode, maxVisibleNameCharacters: Int = 251, ): String { - val normalizedTranscript = transcript - .filterNot { it == '/' || it == '\\' || Character.isISOControl(it) } - .trim() - .replace(Regex("\\s+"), " ") + val normalizedTranscript = sanitizeFilenameSpeechTranscript(transcript) if (normalizedTranscript.isBlank()) return currentName val combined = when (mode) { @@ -101,6 +99,26 @@ internal fun applyRenameSpeechTranscription( return combined.take(maxVisibleNameCharacters.coerceAtLeast(0)) } +private fun sanitizeFilenameSpeechTranscript(transcript: String): String { + val normalized = transcript + .filterNot(::isForbiddenCrossPlatformFilenameCharacter) + .trim() + .replace(Regex("\\s+"), " ") + // Windows disallows filenames ending with a space or period; trim them from speech + // results so a dictated replacement remains portable to common desktop filesystems. + .trimEnd(' ', '.') + if (normalized == "." || normalized == "..") return "" + + val windowsDeviceName = normalized.substringBefore('.').uppercase(Locale.ROOT) + if (windowsDeviceName in WINDOWS_RESERVED_DEVICE_NAMES) return "" + + return normalized +} + +private fun isForbiddenCrossPlatformFilenameCharacter(character: Char): Boolean { + return Character.isISOControl(character) || character in CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS +} + internal fun applyRenameSpeechTranscriptionToAudioNote( currentDescription: String, transcript: String, @@ -276,3 +294,40 @@ private fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int { (green.coerceIn(0, 255) shl 8) or blue.coerceIn(0, 255) } + +private val CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS = setOf( + '<', + '>', + ':', + '"', + '/', + '\\', + '|', + '?', + '*', +) + +private val WINDOWS_RESERVED_DEVICE_NAMES = setOf( + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +) diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index d5104c8d..0dd089c9 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -68,11 +68,33 @@ class FloatingRecorderOverlayGeometryTest { fun `applyRenameSpeechTranscription removes filename hostile characters`() { val result = applyRenameSpeechTranscription( currentName = "Existing", - transcript = "road / bridge \\ tunnel\u0000", + transcript = "road / bridge \\ tunnel: |right? *today* \"quote\"\u0000", mode = RenameSpeechMode.Replace, ) - assertEquals("road bridge tunnel", result) + assertEquals("road bridge tunnel leftright today quote", result) + } + + @Test + fun `applyRenameSpeechTranscription removes trailing dots from filename speech`() { + val result = applyRenameSpeechTranscription( + currentName = "Existing", + transcript = "trip notes...", + mode = RenameSpeechMode.Replace, + ) + + assertEquals("trip notes", result) + } + + @Test + fun `applyRenameSpeechTranscription ignores reserved Windows device filename`() { + val result = applyRenameSpeechTranscription( + currentName = "Existing", + transcript = "CON", + mode = RenameSpeechMode.Replace, + ) + + assertEquals("Existing", result) } @Test @@ -97,10 +119,10 @@ class FloatingRecorderOverlayGeometryTest { fun `applyRenameSpeechTranscriptionToAudioNote appends transcript on a new line`() { val result = applyRenameSpeechTranscriptionToAudioNote( currentDescription = "First thought", - transcript = " second thought ", + transcript = " second: thought? keep / punctuation ", ) - assertEquals("First thought\nsecond thought", result) + assertEquals("First thought\nsecond: thought? keep / punctuation", result) } @Test From 0f9c6e6be1a5c557f9f7b2f99d283a5f0ccc0c1f Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 23 Jun 2026 21:49:51 +0200 Subject: [PATCH 13/13] fix: clean record filenames on save Manual in-app and floating-overlay rename saves could still pass unsafe filename characters through different paths, even after speech input was sanitized. Add a shared V2 filename cleaner and call it from overlay save, overlay speech filename drafts, home active-record rename, and records-list rename so invalid characters are silently removed at save time. Agentic harness: OpenCode, OpenAI GPT-5.5. --- .../v2/app/RecordFileNameCleaner.kt | 61 ++++++++++++++++++ .../v2/app/home/HomeViewModel.kt | 19 ++++-- .../FloatingRecorderOverlayGeometry.kt | 63 +------------------ .../v2/app/records/RecordsViewModel.kt | 24 +++++-- .../v2/app/RecordFileNameCleanerTest.kt | 30 +++++++++ .../FloatingRecorderOverlayGeometryTest.kt | 28 +++++++++ 6 files changed, 154 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleaner.kt create mode 100644 app/src/test/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleanerTest.kt diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleaner.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleaner.kt new file mode 100644 index 00000000..19d18fe8 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleaner.kt @@ -0,0 +1,61 @@ +package com.dimowner.audiorecorder.v2.app + +import java.util.Locale + +internal fun cleanRecordFileNameForSave(inputName: String): String { + val normalized = inputName + .filterNot(::isForbiddenCrossPlatformFilenameCharacter) + .trim() + .replace(Regex("\\s+"), " ") + // Android can persist many names that later fail on Windows, macOS, Linux, or common + // sync tools. Saving through this helper keeps every rename path portable while allowing + // the editing UI to stay quiet and simply drop invalid punctuation at save time. + .trimEnd(' ', '.') + if (normalized == "." || normalized == "..") return "" + + val windowsDeviceName = normalized.substringBefore('.').uppercase(Locale.ROOT) + if (windowsDeviceName in WINDOWS_RESERVED_DEVICE_NAMES) return "" + + return normalized +} + +private fun isForbiddenCrossPlatformFilenameCharacter(character: Char): Boolean { + return Character.isISOControl(character) || character in CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS +} + +private val CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS = setOf( + '<', + '>', + ':', + '"', + '/', + '\\', + '|', + '?', + '*', +) + +private val WINDOWS_RESERVED_DEVICE_NAMES = setOf( + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt index b91669b5..25a0cae7 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/home/HomeViewModel.kt @@ -54,6 +54,7 @@ import com.dimowner.audiorecorder.util.TimeUtils import com.dimowner.audiorecorder.v2.app.adjustWaveformHeights import com.dimowner.audiorecorder.v2.app.calculateGridStep import com.dimowner.audiorecorder.v2.app.calculateScale +import com.dimowner.audiorecorder.v2.app.cleanRecordFileNameForSave import com.dimowner.audiorecorder.v2.app.components.WaveformState import com.dimowner.audiorecorder.v2.app.info.RecordInfoState import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState @@ -852,16 +853,24 @@ class HomeViewModel @Inject constructor( } private suspend fun performRenameActiveRecord(newName: String, activeRecord: Record) { + val cleanedName = cleanRecordFileNameForSave(newName) + if (cleanedName.isEmpty()) { + val context: Context = getApplication().applicationContext + emitEvent(HomeScreenEvent.ShowErrorSnack(context.getString(R.string.msg_name_cannot_be_empty))) + showLoadingProgress(false) + return + } + val currentFile = File(activeRecord.path) // Skip rename if the name hasn't changed - if (currentFile.nameWithoutExtension == newName) { + if (currentFile.nameWithoutExtension == cleanedName) { showLoadingProgress(false) return } // Check if a file with the new name already exists on disk val extension = currentFile.extension - val targetFile = File(currentFile.parentFile, "$newName.$extension") - if (targetFile.exists() && currentFile.nameWithoutExtension != newName) { + val targetFile = File(currentFile.parentFile, "$cleanedName.$extension") + if (targetFile.exists() && currentFile.nameWithoutExtension != cleanedName) { val context: Context = getApplication().applicationContext emitEvent( HomeScreenEvent.ShowErrorSnack( @@ -871,11 +880,11 @@ class HomeViewModel @Inject constructor( showLoadingProgress(false) return } else { - recordsDataSource.renameRecord(activeRecord, newName) + recordsDataSource.renameRecord(activeRecord, cleanedName) val context: Context = getApplication().applicationContext emitEvent( HomeScreenEvent.ShowInfoSnack( - context.getString(R.string.msg_record_renamed, newName) + context.getString(R.string.msg_record_renamed, cleanedName) ) ) } diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt index 4b043252..8060178f 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometry.kt @@ -2,8 +2,8 @@ package com.dimowner.audiorecorder.v2.app.overlay import com.dimowner.audiorecorder.AppConstantsV2.RECORD_DESCRIPTION_MAX_LENGTH import com.dimowner.audiorecorder.R +import com.dimowner.audiorecorder.v2.app.cleanRecordFileNameForSave import com.dimowner.audiorecorder.v2.data.model.RenameSpeechMode -import java.util.Locale import kotlin.math.abs import kotlin.math.min import kotlin.math.roundToInt @@ -68,7 +68,7 @@ internal fun buildRenameOverlaySaveRequest( inputDescription: String, maxDescriptionCharacters: Int = RECORD_DESCRIPTION_MAX_LENGTH, ): RenameOverlaySaveRequest { - val trimmedName = inputName.trim() + val trimmedName = cleanRecordFileNameForSave(inputName) val boundedDescription = inputDescription.take(maxDescriptionCharacters.coerceAtLeast(0)) return RenameOverlaySaveRequest( @@ -86,7 +86,7 @@ internal fun applyRenameSpeechTranscription( mode: RenameSpeechMode, maxVisibleNameCharacters: Int = 251, ): String { - val normalizedTranscript = sanitizeFilenameSpeechTranscript(transcript) + val normalizedTranscript = cleanRecordFileNameForSave(transcript) if (normalizedTranscript.isBlank()) return currentName val combined = when (mode) { @@ -99,26 +99,6 @@ internal fun applyRenameSpeechTranscription( return combined.take(maxVisibleNameCharacters.coerceAtLeast(0)) } -private fun sanitizeFilenameSpeechTranscript(transcript: String): String { - val normalized = transcript - .filterNot(::isForbiddenCrossPlatformFilenameCharacter) - .trim() - .replace(Regex("\\s+"), " ") - // Windows disallows filenames ending with a space or period; trim them from speech - // results so a dictated replacement remains portable to common desktop filesystems. - .trimEnd(' ', '.') - if (normalized == "." || normalized == "..") return "" - - val windowsDeviceName = normalized.substringBefore('.').uppercase(Locale.ROOT) - if (windowsDeviceName in WINDOWS_RESERVED_DEVICE_NAMES) return "" - - return normalized -} - -private fun isForbiddenCrossPlatformFilenameCharacter(character: Char): Boolean { - return Character.isISOControl(character) || character in CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS -} - internal fun applyRenameSpeechTranscriptionToAudioNote( currentDescription: String, transcript: String, @@ -294,40 +274,3 @@ private fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int { (green.coerceIn(0, 255) shl 8) or blue.coerceIn(0, 255) } - -private val CROSS_PLATFORM_FORBIDDEN_FILENAME_CHARACTERS = setOf( - '<', - '>', - ':', - '"', - '/', - '\\', - '|', - '?', - '*', -) - -private val WINDOWS_RESERVED_DEVICE_NAMES = setOf( - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - "COM2", - "COM3", - "COM4", - "COM5", - "COM6", - "COM7", - "COM8", - "COM9", - "LPT1", - "LPT2", - "LPT3", - "LPT4", - "LPT5", - "LPT6", - "LPT7", - "LPT8", - "LPT9", -) diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt index 1042a000..70da1d13 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/app/records/RecordsViewModel.kt @@ -30,6 +30,7 @@ import com.dimowner.audiorecorder.audio.player.PlayerContractNew.PlayerCallback import com.dimowner.audiorecorder.exception.AppException import com.dimowner.audiorecorder.util.AndroidUtils import com.dimowner.audiorecorder.util.TimeUtils +import com.dimowner.audiorecorder.v2.app.cleanRecordFileNameForSave import com.dimowner.audiorecorder.v2.app.info.RecordInfoState import com.dimowner.audiorecorder.v2.app.info.toRecordInfoState import com.dimowner.audiorecorder.v2.app.isDescriptionFileWriteSupported @@ -426,9 +427,20 @@ internal class RecordsViewModel @Inject constructor( fun renameRecord(recordId: Long, newName: String) { viewModelScope.launch(ioDispatcher) { recordsDataSource.getRecord(recordId)?.let { record -> + val cleanedName = cleanRecordFileNameForSave(newName) + if (cleanedName.isEmpty()) { + val context: Context = getApplication().applicationContext + emitEvent(RecordsScreenEvent.ShowErrorSnack(context.getString(R.string.msg_name_cannot_be_empty))) + _state.value = _state.value.copy( + showRenameDialog = false, + operationSelectedRecord = null + ) + return@let + } + val currentFile = File(record.path) // Skip rename if the name hasn't changed - if (currentFile.nameWithoutExtension == newName) { + if (currentFile.nameWithoutExtension == cleanedName) { _state.value = _state.value.copy( showRenameDialog = false, operationSelectedRecord = null @@ -437,8 +449,8 @@ internal class RecordsViewModel @Inject constructor( } // Check if a file with the new name already exists on disk val extension = currentFile.extension - val targetFile = File(currentFile.parentFile, "$newName.$extension") - if (targetFile.exists() && currentFile.nameWithoutExtension != newName) { + val targetFile = File(currentFile.parentFile, "$cleanedName.$extension") + if (targetFile.exists() && currentFile.nameWithoutExtension != cleanedName) { val context: Context = getApplication().applicationContext emitEvent( RecordsScreenEvent.ShowErrorSnack( @@ -449,11 +461,11 @@ internal class RecordsViewModel @Inject constructor( showRenameDialog = false, operationSelectedRecord = null ) - } else if (recordsDataSource.renameRecord(record, newName)) { + } else if (recordsDataSource.renameRecord(record, cleanedName)) { val context: Context = getApplication().applicationContext emitEvent( RecordsScreenEvent.ShowInfoSnack( - context.getString(R.string.msg_record_renamed, newName) + context.getString(R.string.msg_record_renamed, cleanedName) ) ) _state.value = _state.value.copy( @@ -461,7 +473,7 @@ internal class RecordsViewModel @Inject constructor( operationSelectedRecord = null, recordsMap = _state.value.recordsMap.mapRecordInMap(recordId) { oldRecord -> if (recordId == record.id) { - oldRecord.copy(name = newName) + oldRecord.copy(name = cleanedName) } else { oldRecord } diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleanerTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleanerTest.kt new file mode 100644 index 00000000..850b476b --- /dev/null +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/RecordFileNameCleanerTest.kt @@ -0,0 +1,30 @@ +package com.dimowner.audiorecorder.v2.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecordFileNameCleanerTest { + + @Test + fun `cleanRecordFileNameForSave deletes characters that common filesystems reject`() { + val result = cleanRecordFileNameForSave( + " road / bridge \\ tunnel: |right? *today* \"quote\"\u0000 " + ) + + assertEquals("road bridge tunnel leftright today quote", result) + } + + @Test + fun `cleanRecordFileNameForSave trims Windows-invalid trailing dots and spaces`() { + val result = cleanRecordFileNameForSave("trip notes... ") + + assertEquals("trip notes", result) + } + + @Test + fun `cleanRecordFileNameForSave rejects Windows reserved device names`() { + val result = cleanRecordFileNameForSave("CON.txt") + + assertEquals("", result) + } +} diff --git a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt index 0dd089c9..58713574 100644 --- a/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt +++ b/app/src/test/java/com/dimowner/audiorecorder/v2/app/overlay/FloatingRecorderOverlayGeometryTest.kt @@ -162,6 +162,34 @@ class FloatingRecorderOverlayGeometryTest { assertFalse(request.showNameEmptyError) } + @Test + fun `buildRenameOverlaySaveRequest silently cleans filename before saving`() { + val request = buildRenameOverlaySaveRequest( + originalName = "Original name", + originalDescription = "Original description", + inputName = " bad / name: |right? *today* \"quote\"... ", + inputDescription = "Original description", + ) + + assertEquals("bad name leftright today quote", request.name) + assertTrue(request.shouldRename) + assertFalse(request.showNameEmptyError) + } + + @Test + fun `buildRenameOverlaySaveRequest rejects filename that becomes empty after cleaning`() { + val request = buildRenameOverlaySaveRequest( + originalName = "Original name", + originalDescription = "Original description", + inputName = " / \\ : * ? | < > \" \u0000 ", + inputDescription = "Original description", + ) + + assertEquals("", request.name) + assertFalse(request.shouldRename) + assertTrue(request.showNameEmptyError) + } + @Test fun `buildRenameOverlaySaveRequest rejects blank filename without dropping description draft`() { val request = buildRenameOverlaySaveRequest(