diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b37280e..e0d1be5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,18 +84,13 @@ jobs: fi fi - build: - name: Test and build signed APK + validate: + name: Validate source needs: version runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "17" - - uses: subosito/flutter-action@v2 with: channel: stable @@ -110,6 +105,31 @@ jobs: flutter analyze flutter test + build: + name: Build signed APK + needs: version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + cache-dependency-path: | + pubspec.lock + android/gradle.properties + android/*.gradle.kts + android/app/*.gradle.kts + android/gradle/wrapper/gradle-wrapper.properties + + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: 3.41.1 + cache: true + - name: Configure the persistent release signing key env: KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -155,11 +175,18 @@ jobs: path: release-assets/*.apk if-no-files-found: error retention-days: 1 + # APK files are already compressed ZIP containers. + compression-level: 0 assemble: - name: Assemble release - needs: [version, build] + name: Assemble and publish release + needs: [version, validate, build] runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.version.outputs.tag }} + CHANNEL: ${{ needs.version.outputs.channel }} + REPO: ${{ github.repository }} steps: - uses: actions/checkout@v5 @@ -180,12 +207,10 @@ jobs: - name: Upload the APK and manifest to the versioned release if: needs.version.outputs.publish == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release upload "${{ needs.version.outputs.tag }}" \ + gh release upload "$TAG" \ release-assets/* android-update*.json \ - --repo "$GITHUB_REPOSITORY" --clobber + --repo "$REPO" --clobber - name: Keep an unpublished build as a workflow artifact if: needs.version.outputs.publish != 'true' @@ -197,32 +222,8 @@ jobs: android-update*.json if-no-files-found: error - publish: - name: Publish release - needs: [version, assemble] - if: needs.version.outputs.publish == 'true' - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ needs.version.outputs.tag }} - CHANNEL: ${{ needs.version.outputs.channel }} - REPO: ${{ github.repository }} - steps: - - uses: actions/checkout@v5 - - - uses: actions/download-artifact@v5 - if: needs.version.outputs.channel == 'testing' - with: - name: openflow-${{ needs.version.outputs.channel }}-${{ needs.version.outputs.version }} - path: release-assets - - - name: Download the beta manifest - if: needs.version.outputs.channel == 'testing' - run: | - gh release download "$TAG" --repo "$REPO" --pattern android-update-beta.json - - name: Update the permanent beta channel pointer - if: needs.version.outputs.channel == 'testing' + if: needs.version.outputs.publish == 'true' && needs.version.outputs.channel == 'testing' run: | if ! gh release view "$BETA_POINTER_TAG" --repo "$REPO" >/dev/null 2>&1; then gh release create "$BETA_POINTER_TAG" --repo "$REPO" --prerelease \ @@ -234,4 +235,5 @@ jobs: --repo "$REPO" --clobber - name: Make the completed release visible + if: needs.version.outputs.publish == 'true' run: gh release edit "$TAG" --repo "$REPO" --draft=false diff --git a/README.md b/README.md index 575eeec..30a8d15 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ flowchart LR E --> H[Campo selecionado] ``` -O modelo atual é `microsoft/mai-transcribe-1.5`. O áudio é enviado somente quando uma transcrição é solicitada. O histórico de texto e as preferências continuam no aparelho. +O modelo inicial é `microsoft/mai-transcribe-1.5`. Nas configurações, você pode pesquisar e escolher qualquer modelo de transcrição disponível na OpenRouter; a escolha fica salva no aparelho. O áudio é enviado somente quando uma transcrição é solicitada. O histórico de texto e as preferências continuam no aparelho. ## Instalação diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f78101e..26d8d1b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -69,4 +69,5 @@ flutter { dependencies { implementation("androidx.core:core-ktx:1.17.0") + testImplementation("junit:junit:4.13.2") } diff --git a/android/app/src/main/kotlin/com/jubar/voxora/FloatingOverlayService.kt b/android/app/src/main/kotlin/com/jubar/voxora/FloatingOverlayService.kt index d089df3..641f5af 100644 --- a/android/app/src/main/kotlin/com/jubar/voxora/FloatingOverlayService.kt +++ b/android/app/src/main/kotlin/com/jubar/voxora/FloatingOverlayService.kt @@ -8,14 +8,14 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.content.res.Configuration import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.Path import android.graphics.PixelFormat -import android.graphics.RectF import android.os.Build import android.os.IBinder +import android.os.SystemClock import android.provider.Settings import android.view.GestureDetector import android.view.Gravity @@ -24,7 +24,9 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.WindowManager +import androidx.core.content.ContextCompat import kotlin.math.abs +import kotlin.math.exp import kotlin.math.max import kotlin.math.min import kotlin.math.sin @@ -33,12 +35,13 @@ class FloatingOverlayService : Service() { private lateinit var windowManager: WindowManager private var bubble: FloatingWaveView? = null private var layoutParams: WindowManager.LayoutParams? = null - private var removalMenu: RemovalMenuView? = null - private var removalMenuParams: WindowManager.LayoutParams? = null + private var removalTarget: RemovalTargetView? = null + private var removalTargetParams: WindowManager.LayoutParams? = null override fun onCreate() { super.onCreate() isRunning = true + instance = this createNotificationChannel() val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -54,7 +57,7 @@ class FloatingOverlayService : Service() { stopSelf() return } - showBubble() + if (!showBubble()) stopSelf() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -62,17 +65,34 @@ class FloatingOverlayService : Service() { stopSelf() return START_NOT_STICKY } + if (!Settings.canDrawOverlays(this) || !showBubble()) { + stopSelf() + return START_NOT_STICKY + } return START_STICKY } override fun onBind(intent: Intent?): IBinder? = null - private fun showBubble() { - if (bubble != null) return + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + // Saved overlay coordinates are absolute pixels. Rotation, display scaling, + // split screen and foldable posture changes can otherwise leave the bubble + // outside the new display bounds indefinitely. + bubble?.post { ensureBubbleOnScreen() } + } + + private fun showBubble(): Boolean { + // addView() registers the window synchronously, but View attachment happens + // later. Using isAttachedToWindow here creates a race where onStartCommand + // adds a second window immediately after onCreate added the first one. + if (bubble != null) { + return ensureBubbleOnScreen() + } windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - val size = dp(64) + val size = dp(58) val preferences = getSharedPreferences("openflow_overlay", Context.MODE_PRIVATE) - layoutParams = WindowManager.LayoutParams( + val newLayoutParams = WindowManager.LayoutParams( size, size, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -88,30 +108,45 @@ class FloatingOverlayService : Service() { gravity = Gravity.TOP or Gravity.START x = preferences.getInt("x", resources.displayMetrics.widthPixels - size - dp(18)) y = preferences.getInt("y", dp(180)) + clampToDisplay(this) } - bubble = FloatingWaveView( + val newBubble = FloatingWaveView( this, + onDragStarted = { showRemovalTarget() }, onMove = { deltaX, deltaY -> - hideRemovalMenu() moveBubble(deltaX, deltaY) + updateRemovalTarget() + }, + onMoveFinished = { cancelled -> + if (!cancelled && isBubbleInsideRemovalTarget()) { + removeBubbleUntilNextOpen() + } else { + hideRemovalTarget() + savePosition() + } }, - onMoveFinished = { savePosition() }, onAction = { event -> - hideRemovalMenu() sendOverlayEvent(event) }, - onLongPress = { showRemovalMenu() }, ) if (pendingKeepScreenOn) { - layoutParams?.flags = layoutParams?.flags?.or( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, - ) ?: 0 + newLayoutParams.flags = newLayoutParams.flags or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON } - windowManager.addView(bubble, layoutParams) - instance = this + try { + windowManager.addView(newBubble, newLayoutParams) + } catch (_: RuntimeException) { + return false + } + // These references represent ownership of exactly one registered window. + // They are only published after addView succeeds. + bubble = newBubble + layoutParams = newLayoutParams + savePosition() pendingState?.let { snapshot -> bubble?.update(snapshot.state, snapshot.level, snapshot.bands) } + return true } private fun moveBubble(deltaX: Int, deltaY: Int) { @@ -133,6 +168,64 @@ class FloatingOverlayService : Service() { .apply() } + private fun ensureBubbleOnScreen(): Boolean { + val params = layoutParams ?: return false + val view = bubble ?: return false + val moved = clampToDisplay(params) + if (!moved) return true + try { + windowManager.updateViewLayout(view, params) + savePosition() + return true + } catch (_: RuntimeException) { + // The WindowManager may have detached the old view during a display + // transition. Explicitly unregister it before creating a replacement, + // so a stale window can never be left behind on screen. + removeBubbleView() + return showBubble() + } + } + + private fun removeBubbleView() { + val view = bubble + // Clear ownership before asking WindowManager to remove the view. This + // keeps callbacks and repeated stop/start requests idempotent. + bubble = null + layoutParams = null + if (view == null) return + try { + windowManager.removeViewImmediate(view) + } catch (_: RuntimeException) { + // The window was already removed by Android. + } + } + + private fun clampToDisplay(params: WindowManager.LayoutParams): Boolean { + val width: Int + val height: Int + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bounds = windowManager.currentWindowMetrics.bounds + width = bounds.width() + height = bounds.height() + } else { + @Suppress("DEPRECATION") + val metrics = resources.displayMetrics + width = metrics.widthPixels + height = metrics.heightPixels + } + val oldPosition = OverlayPosition(params.x, params.y) + val position = clampOverlayPosition( + position = oldPosition, + overlayWidth = params.width, + overlayHeight = params.height, + displayWidth = width, + displayHeight = height, + ) + params.x = position.x + params.y = position.y + return position != oldPosition + } + private fun sendOverlayEvent(event: String, onDelivered: (() -> Unit)? = null) { if (OpenFlowEngine.dispatchOverlayAction(event, onDelivered)) return sendBroadcast( @@ -144,28 +237,17 @@ class FloatingOverlayService : Service() { } private fun updateBubble(state: String, level: Double, bands: DoubleArray) { - if (state != "idle") hideRemovalMenu() bubble?.update(state, level, bands) } - private fun showRemovalMenu() { - if (removalMenu != null) return - val bubbleParams = layoutParams ?: return - val width = dp(136) - val height = dp(46) + private fun showRemovalTarget() { + if (removalTarget != null) return + val size = dp(96) val displayWidth = resources.displayMetrics.widthPixels val displayHeight = resources.displayMetrics.heightPixels - val menuX = (bubbleParams.x + (bubbleParams.width - width) / 2) - .coerceIn(dp(8), max(dp(8), displayWidth - width - dp(8))) - val menuY = if (bubbleParams.y >= height + dp(12)) { - bubbleParams.y - height - dp(8) - } else { - (bubbleParams.y + bubbleParams.height + dp(8)) - .coerceAtMost(displayHeight - height - dp(8)) - } - removalMenuParams = WindowManager.LayoutParams( - width, - height, + removalTargetParams = WindowManager.LayoutParams( + size, + size, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { @@ -173,33 +255,56 @@ class FloatingOverlayService : Service() { WindowManager.LayoutParams.TYPE_PHONE }, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT, ).apply { gravity = Gravity.TOP or Gravity.START - x = menuX - y = menuY - } - removalMenu = RemovalMenuView(this) { - OpenFlowFeedback.play(applicationContext, "close") - sendOverlayEvent("dismiss") { - hideRemovalMenu() - stopSelf() - } + x = (displayWidth - size) / 2 + // Keep the target clear of Android's gesture/three-button navigation. + // This places it visually above Home instead of on top of the system bar. + y = max(dp(12), displayHeight - size - navigationBarHeight() - dp(16)) + } + removalTarget = RemovalTargetView(this) + windowManager.addView(removalTarget, removalTargetParams) + updateRemovalTarget() + } + + private fun updateRemovalTarget() { + removalTarget?.setHighlighted(isBubbleInsideRemovalTarget()) + } + + private fun isBubbleInsideRemovalTarget(): Boolean { + val bubbleParams = layoutParams ?: return false + val targetParams = removalTargetParams ?: return false + val bubbleCenterX = bubbleParams.x + bubbleParams.width / 2f + val bubbleCenterY = bubbleParams.y + bubbleParams.height / 2f + val targetCenterX = targetParams.x + targetParams.width / 2f + val targetCenterY = targetParams.y + targetParams.height / 2f + val radius = targetParams.width * 0.46f + val deltaX = bubbleCenterX - targetCenterX + val deltaY = bubbleCenterY - targetCenterY + return deltaX * deltaX + deltaY * deltaY <= radius * radius + } + + private fun removeBubbleUntilNextOpen() { + OpenFlowFeedback.play(applicationContext, "close") + sendOverlayEvent("dismiss") { + hideRemovalTarget() + stopSelf() } - windowManager.addView(removalMenu, removalMenuParams) } - private fun hideRemovalMenu() { - removalMenu?.let { view -> + private fun hideRemovalTarget() { + removalTarget?.let { view -> try { windowManager.removeView(view) } catch (_: Throwable) { - // The menu may already have been removed with the service window. + // The target may already have been removed with the service window. } } - removalMenu = null - removalMenuParams = null + removalTarget = null + removalTargetParams = null } private fun updateKeepScreenOn(active: Boolean) { @@ -247,17 +352,14 @@ class FloatingOverlayService : Service() { private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + private fun navigationBarHeight(): Int { + val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(48) + } + override fun onDestroy() { - hideRemovalMenu() - bubble?.let { view -> - try { - windowManager.removeView(view) - } catch (_: Throwable) { - // The window may already have been removed. - } - } - bubble = null - layoutParams = null + hideRemovalTarget() + removeBubbleView() if (instance === this) instance = null isRunning = false stopForeground(STOP_FOREGROUND_REMOVE) @@ -294,6 +396,11 @@ class FloatingOverlayService : Service() { context.stopService(Intent(context, FloatingOverlayService::class.java)) } + // A non-null bubble means this service owns a WindowManager registration. + // Attachment is asynchronous and must not be used to decide whether a + // replacement window should be created. + fun isBubbleVisible(): Boolean = instance?.bubble != null + fun update(state: String, level: Double, bands: DoubleArray) { val snapshot = OverlaySnapshot(state, level, bands.copyOf()) pendingState = snapshot @@ -311,6 +418,19 @@ class FloatingOverlayService : Service() { } } +internal data class OverlayPosition(val x: Int, val y: Int) + +internal fun clampOverlayPosition( + position: OverlayPosition, + overlayWidth: Int, + overlayHeight: Int, + displayWidth: Int, + displayHeight: Int, +): OverlayPosition = OverlayPosition( + x = position.x.coerceIn(0, max(0, displayWidth - overlayWidth)), + y = position.y.coerceIn(0, max(0, displayHeight - overlayHeight)), +) + private data class OverlaySnapshot( val state: String, val level: Double, @@ -319,10 +439,10 @@ private data class OverlaySnapshot( private class FloatingWaveView( context: Context, + private val onDragStarted: () -> Unit, private val onMove: (Int, Int) -> Unit, - private val onMoveFinished: () -> Unit, + private val onMoveFinished: (Boolean) -> Unit, private val onAction: (String) -> Unit, - private val onLongPress: () -> Unit, ) : View(context) { private val density = resources.displayMetrics.density private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop @@ -332,11 +452,15 @@ private class FloatingWaveView( strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND } + private val appIcon = ContextCompat.getDrawable(context, R.mipmap.ic_launcher)?.mutate() private val targetBands = DoubleArray(11) private val currentBands = DoubleArray(11) private var visualState = "idle" private var level = 0.0 - private var phase = 0.0 + private var displayedLevel = 0f + private val animationStartedAt = SystemClock.uptimeMillis() + private var lastFrameAt = animationStartedAt + private var stateChangedAt = animationStartedAt private var errorUntil = 0L private var downRawX = 0f private var downRawY = 0f @@ -360,12 +484,6 @@ private class FloatingWaveView( return true } - override fun onLongPress(event: MotionEvent) { - if (!dragged && visualState == "idle") { - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - onLongPress() - } - } }, ) @@ -374,6 +492,9 @@ private class FloatingWaveView( } fun update(state: String, newLevel: Double, bands: DoubleArray) { + if (state != visualState) { + stateChangedAt = SystemClock.uptimeMillis() + } visualState = state level = newLevel.coerceIn(0.0, 1.0) for (index in targetBands.indices) { @@ -383,7 +504,7 @@ private class FloatingWaveView( } fun showError() { - errorUntil = System.currentTimeMillis() + 900 + errorUntil = SystemClock.uptimeMillis() + 900 postInvalidateOnAnimation() } @@ -404,6 +525,8 @@ private class FloatingWaveView( abs(event.rawY - downRawY) > touchSlop) ) { dragged = true + performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + onDragStarted() } if (dragged) { pressed = false @@ -414,7 +537,7 @@ private class FloatingWaveView( } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { pressed = false - if (dragged) onMoveFinished() + if (dragged) onMoveFinished(event.actionMasked == MotionEvent.ACTION_CANCEL) postInvalidateOnAnimation() } } @@ -424,20 +547,28 @@ private class FloatingWaveView( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - phase += 0.075 + val now = SystemClock.uptimeMillis() + val deltaSeconds = ((now - lastFrameAt).coerceIn(1L, 48L) / 1000f) + lastFrameAt = now + val smoothing = 1f - exp((-deltaSeconds * 12f).toDouble()).toFloat() for (index in currentBands.indices) { - currentBands[index] += (targetBands[index] - currentBands[index]) * 0.32 + currentBands[index] += (targetBands[index] - currentBands[index]) * smoothing } + displayedLevel += (level.toFloat() - displayedLevel) * smoothing + val phase = (now - animationStartedAt) / 1000.0 + val stateProgress = ((now - stateChangedAt) / 240f).coerceIn(0f, 1f) + val stateEase = 1f - (1f - stateProgress) * (1f - stateProgress) * (1f - stateProgress) val centerX = width / 2f val centerY = height / 2f canvas.save() - if (pressed) canvas.scale(0.94f, 0.94f, centerX, centerY) + val pressScale = if (pressed) 0.94f else 1f + canvas.scale(pressScale, pressScale, centerX, centerY) val radius = min(width, height) / 2f - 3f * density paint.style = Paint.Style.FILL paint.color = Color.rgb(17, 17, 16) + paint.alpha = 255 canvas.drawCircle(centerX, centerY, radius, paint) - val now = System.currentTimeMillis() val ringColor = when { now < errorUntil -> Color.rgb(239, 68, 68) visualState == "recording" -> Color.rgb(16, 185, 129) @@ -445,49 +576,72 @@ private class FloatingWaveView( else -> Color.rgb(69, 69, 63) } stroke.color = ringColor - stroke.strokeWidth = if (visualState == "recording") 2.4f * density else 1.2f * density + stroke.alpha = 255 + stroke.strokeWidth = if (visualState == "recording") 2.2f * density else 1.2f * density canvas.drawCircle(centerX, centerY, radius, stroke) + if (visualState == "recording" || visualState == "transcribing") { + val pulse = ( + (sin(phase * if (visualState == "recording") 4.4 else 3.1) + 1.0) * 0.5 + ).toFloat() + stroke.alpha = (34 + pulse * 46).toInt() + stroke.strokeWidth = (1.2f + pulse * 0.9f) * density + canvas.drawCircle(centerX, centerY, radius - 3.2f * density + pulse * density, stroke) + stroke.alpha = 255 + } + + canvas.save() + canvas.scale(0.82f + stateEase * 0.18f, 0.82f + stateEase * 0.18f, centerX, centerY) when { now < errorUntil -> drawError(canvas, centerX, centerY) - visualState == "recording" -> drawRecording(canvas, centerX, centerY) - visualState == "transcribing" -> drawLoading(canvas, centerX, centerY) + visualState == "recording" -> drawRecording(canvas, centerX, centerY, phase) + visualState == "transcribing" -> drawLoading(canvas, centerX, centerY, phase) else -> drawIdle(canvas, centerX, centerY) } canvas.restore() - if (visualState != "idle" || now < errorUntil) postInvalidateOnAnimation() + canvas.restore() + if (visualState != "idle" || now < errorUntil || stateProgress < 1f) { + postInvalidateOnAnimation() + } } private fun drawIdle(canvas: Canvas, centerX: Float, centerY: Float) { - val path = Path().apply { - moveTo(14f * density, 32f * density) - lineTo(21f * density, 32f * density) - lineTo(26f * density, 24f * density) - lineTo(32f * density, 41f * density) - lineTo(38f * density, 19f * density) - lineTo(44f * density, 38f * density) - lineTo(49f * density, 29f * density) - lineTo(53f * density, 29f * density) - } - val bounds = RectF() - path.computeBounds(bounds, true) - path.offset(centerX - bounds.centerX(), centerY - bounds.centerY()) - stroke.color = Color.WHITE - stroke.strokeWidth = 3.6f * density - canvas.drawPath(path, stroke) - } - - private fun drawRecording(canvas: Canvas, centerX: Float, centerY: Float) { + val icon = appIcon ?: return + val halfSize = 19.5f * density + icon.setBounds( + (centerX - halfSize).toInt(), + (centerY - halfSize).toInt(), + (centerX + halfSize).toInt(), + (centerY + halfSize).toInt(), + ) + icon.draw(canvas) + } + + private fun drawRecording(canvas: Canvas, centerX: Float, centerY: Float, phase: Double) { val indexes = intArrayOf(0, 2, 4, 5, 6, 8, 10) - val barWidth = 2.7f * density - val gap = 3.2f * density + val barWidth = 2.5f * density + val gap = 2.8f * density val total = indexes.size * barWidth + (indexes.size - 1) * gap var x = centerX - total / 2f + paint.color = Color.rgb(6, 78, 59) + paint.alpha = 78 + canvas.drawRoundRect( + centerX - 21.5f * density, + centerY - 14f * density, + centerX + 21.5f * density, + centerY + 14f * density, + 14f * density, + 14f * density, + paint, + ) paint.color = Color.rgb(16, 185, 129) - for (sourceIndex in indexes) { + for ((barIndex, sourceIndex) in indexes.withIndex()) { val band = currentBands[sourceIndex].toFloat() - val animated = (sin(phase + sourceIndex * 0.7) + 1.0).toFloat() * 0.5f - val height = (5f + min(1f, band * 1.4f + level.toFloat() * 0.25f) * 24f + animated * band * 3f) * density + val breathing = ((sin(phase * 5.2 + sourceIndex * 0.74) + 1.0) * 0.5).toFloat() + val centerWeight = 1f - abs(barIndex - 3) * 0.08f + val energy = min(1f, band * 1.35f + displayedLevel * 0.34f) + val height = (5f + energy * 19f * centerWeight + breathing * (2.2f + energy * 2.6f)) * density + paint.alpha = (172 + energy * 83f).toInt() canvas.drawRoundRect( x, centerY - height / 2f, @@ -499,19 +653,36 @@ private class FloatingWaveView( ) x += barWidth + gap } + paint.alpha = 255 } - private fun drawLoading(canvas: Canvas, centerX: Float, centerY: Float) { + private fun drawLoading(canvas: Canvas, centerX: Float, centerY: Float, phase: Double) { + paint.color = Color.rgb(120, 53, 15) + paint.alpha = 72 + canvas.drawRoundRect( + centerX - 21f * density, + centerY - 10.5f * density, + centerX + 21f * density, + centerY + 10.5f * density, + 10.5f * density, + 10.5f * density, + paint, + ) paint.color = Color.rgb(245, 158, 11) for (index in 0 until 5) { - val x = centerX + (index - 2) * 7f * density - val y = centerY + sin(phase * 1.8 - index * 0.85).toFloat() * 4f * density - canvas.drawCircle(x, y, 2.1f * density, paint) + val wave = ((sin(phase * 5.1 - index * 0.82) + 1.0) * 0.5).toFloat() + val x = centerX + (index - 2) * 7.2f * density + val y = centerY + (wave - 0.5f) * 5.2f * density + val dotRadius = (1.65f + wave * 1.05f) * density + paint.alpha = (105 + wave * 150f).toInt() + canvas.drawCircle(x, y, dotRadius, paint) } + paint.alpha = 255 } private fun drawError(canvas: Canvas, centerX: Float, centerY: Float) { paint.color = Color.rgb(239, 68, 68) + paint.alpha = 255 canvas.drawRoundRect( centerX - 2f * density, centerY - 11f * density, @@ -525,72 +696,63 @@ private class FloatingWaveView( } } -private class RemovalMenuView( - context: Context, - private val onRemove: () -> Unit, -) : View(context) { +private class RemovalTargetView(context: Context) : View(context) { private val density = resources.displayMetrics.density private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(32, 32, 29) - setShadowLayer(10f * density, 0f, 3f * density, 0x77000000) + setShadowLayer(12f * density, 0f, 4f * density, 0x88000000.toInt()) } private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE - strokeWidth = density - color = Color.rgb(79, 79, 72) + strokeWidth = 2f * density + color = Color.rgb(248, 113, 113) } - private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val crossPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(248, 113, 113) - textSize = 14f * density - typeface = android.graphics.Typeface.create( - android.graphics.Typeface.DEFAULT, - android.graphics.Typeface.BOLD, - ) - textAlign = Paint.Align.CENTER + style = Paint.Style.STROKE + strokeWidth = 4f * density + strokeCap = Paint.Cap.ROUND } + private var highlighted = false init { contentDescription = "Remover círculo flutuante até abrir o OpenFlow novamente" - isClickable = true - isFocusable = true setLayerType(LAYER_TYPE_SOFTWARE, null) } - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - val inset = 3f * density - val bounds = RectF(inset, inset, width - inset, height - inset) - val radius = 13f * density - canvas.drawRoundRect(bounds, radius, radius, backgroundPaint) - canvas.drawRoundRect(bounds, radius, radius, borderPaint) - val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f - canvas.drawText("Remover", width / 2f, baseline, textPaint) + fun setHighlighted(value: Boolean) { + if (highlighted == value) return + highlighted = value + invalidate() } - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - alpha = 0.72f - return true - } - MotionEvent.ACTION_UP -> { - alpha = 1f - if (event.x in 0f..width.toFloat() && event.y in 0f..height.toFloat()) { - performClick() - } - return true - } - MotionEvent.ACTION_CANCEL -> { - alpha = 1f - return true - } + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val centerX = width / 2f + val centerY = height / 2f + val radius = if (highlighted) 43f * density else 36f * density + backgroundPaint.color = if (highlighted) { + Color.rgb(127, 29, 29) + } else { + Color.rgb(32, 32, 29) } - return super.onTouchEvent(event) - } - - override fun performClick(): Boolean { - super.performClick() - onRemove() - return true + borderPaint.strokeWidth = if (highlighted) 3f * density else 2f * density + canvas.drawCircle(centerX, centerY, radius, backgroundPaint) + canvas.drawCircle(centerX, centerY, radius, borderPaint) + val crossRadius = if (highlighted) 14f * density else 12f * density + canvas.drawLine( + centerX - crossRadius, + centerY - crossRadius, + centerX + crossRadius, + centerY + crossRadius, + crossPaint, + ) + canvas.drawLine( + centerX + crossRadius, + centerY - crossRadius, + centerX - crossRadius, + centerY + crossRadius, + crossPaint, + ) } } diff --git a/android/app/src/main/kotlin/com/jubar/voxora/MainActivity.kt b/android/app/src/main/kotlin/com/jubar/voxora/MainActivity.kt index e9e5fe6..1f1bb2b 100644 --- a/android/app/src/main/kotlin/com/jubar/voxora/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jubar/voxora/MainActivity.kt @@ -5,6 +5,10 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build +import android.os.Bundle +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -18,6 +22,29 @@ class MainActivity : FlutterActivity() { private var updateExecutor: ExecutorService? = null private lateinit var appUpdater: AppUpdater + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideNavigationControls() + } + + override fun onPostResume() { + super.onPostResume() + hideNavigationControls() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideNavigationControls() + } + + private fun hideNavigationControls() { + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.navigationBars()) + } + } + override fun provideFlutterEngine(context: Context): FlutterEngine? = OpenFlowEngine.cached() diff --git a/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowAccessibilityService.kt b/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowAccessibilityService.kt index 9cc1d05..b01ec20 100644 --- a/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowAccessibilityService.kt +++ b/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowAccessibilityService.kt @@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityService import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.os.Build import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -25,7 +26,7 @@ class OpenFlowAccessibilityService : AccessibilityService() { @Volatile private var instance: OpenFlowAccessibilityService? = null - fun pasteText(text: String): Boolean { + fun pasteText(text: String, keepInClipboard: Boolean): Boolean { val service = instance ?: return false if (text.isBlank()) return false val focused = service.rootInActiveWindow @@ -33,8 +34,25 @@ class OpenFlowAccessibilityService : AccessibilityService() { ?: return false if (!focused.isEditable) return false val clipboard = service.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val previousClip = if (keepInClipboard) { + null + } else { + runCatching { clipboard.primaryClip }.getOrNull() + } clipboard.setPrimaryClip(ClipData.newPlainText("OpenFlow", text)) - return focused.performAction(AccessibilityNodeInfo.ACTION_PASTE) + return try { + focused.performAction(AccessibilityNodeInfo.ACTION_PASTE) + } finally { + if (!keepInClipboard) { + if (previousClip != null) { + clipboard.setPrimaryClip(previousClip) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + clipboard.clearPrimaryClip() + } else { + clipboard.setPrimaryClip(ClipData.newPlainText("", "")) + } + } + } } } } diff --git a/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowEngine.kt b/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowEngine.kt index a8169b6..29a47ec 100644 --- a/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowEngine.kt +++ b/android/app/src/main/kotlin/com/jubar/voxora/OpenFlowEngine.kt @@ -112,7 +112,9 @@ object OpenFlowEngine { FloatingOverlayService.stop(context) result.success(null) } - "isOverlayRunning" -> result.success(FloatingOverlayService.isRunning) + "isOverlayRunning" -> result.success( + FloatingOverlayService.isBubbleVisible(), + ) "hasRecordAudioPermission" -> result.success( ContextCompat.checkSelfPermission( context, @@ -195,6 +197,7 @@ object OpenFlowEngine { "pasteText" -> result.success( OpenFlowAccessibilityService.pasteText( call.argument("text").orEmpty(), + call.argument("keepInClipboard") != false, ), ) "updateOverlay" -> { diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c61e081..d506366 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -4,11 +4,11 @@ + android:inset="16%" /> + android:inset="16%" /> diff --git a/android/app/src/test/kotlin/com/jubar/voxora/OverlayPositionTest.kt b/android/app/src/test/kotlin/com/jubar/voxora/OverlayPositionTest.kt new file mode 100644 index 0000000..d9ca59c --- /dev/null +++ b/android/app/src/test/kotlin/com/jubar/voxora/OverlayPositionTest.kt @@ -0,0 +1,45 @@ +package com.jubar.voxora + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OverlayPositionTest { + @Test + fun `moves a saved landscape position back onto a portrait display`() { + val result = clampOverlayPosition( + position = OverlayPosition(x = 2200, y = 900), + overlayWidth = 58, + overlayHeight = 58, + displayWidth = 1080, + displayHeight = 2400, + ) + + assertEquals(OverlayPosition(x = 1022, y = 900), result) + } + + @Test + fun `keeps an already visible position unchanged`() { + val result = clampOverlayPosition( + position = OverlayPosition(x = 240, y = 480), + overlayWidth = 58, + overlayHeight = 58, + displayWidth = 1080, + displayHeight = 2400, + ) + + assertEquals(OverlayPosition(x = 240, y = 480), result) + } + + @Test + fun `recovers negative saved coordinates`() { + val result = clampOverlayPosition( + position = OverlayPosition(x = -120, y = -40), + overlayWidth = 58, + overlayHeight = 58, + displayWidth = 1080, + displayHeight = 2400, + ) + + assertEquals(OverlayPosition(x = 0, y = 0), result) + } +} diff --git a/android/gradle.properties b/android/gradle.properties index 23bd6e3..b79d8ba 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,5 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.caching=true +org.gradle.parallel=true android.useAndroidX=true kotlin.incremental=false diff --git a/lib/src/controller/voxora_controller.dart b/lib/src/controller/voxora_controller.dart index a8243b0..63100f7 100644 --- a/lib/src/controller/voxora_controller.dart +++ b/lib/src/controller/voxora_controller.dart @@ -43,6 +43,7 @@ class VoxoraController extends ChangeNotifier { bool _pendingOverlayEnable = false; bool _recordingFromOverlay = false; UsageStats _usageStats = const UsageStats(); + final List _transcriptionModels = []; VoxoraActivity activity = VoxoraActivity.idle; bool autoCopy = true; @@ -54,6 +55,9 @@ class VoxoraController extends ChangeNotifier { bool soundEffectsEnabled = true; bool silenceWhileRecording = true; String languageHint = 'auto'; + String transcriptionModel = OpenRouterService.defaultModel; + bool isLoadingTranscriptionModels = false; + String? transcriptionModelsError; int recordingDurationMs = 0; double amplitude = 0; List audioBands = List.filled(11, 0); @@ -63,12 +67,20 @@ class VoxoraController extends ChangeNotifier { int feedbackSerial = 0; List get history => List.unmodifiable(_history); + List get transcriptionModels => + List.unmodifiable(_transcriptionModels); UsageStats get usageStats => _usageStats; TranscriptEntry? get latest => _history.isEmpty ? null : _history.first; bool get hasApiKey => _apiKey?.isNotEmpty ?? false; bool get isRecording => activity == VoxoraActivity.recording; bool get isTranscribing => activity == VoxoraActivity.transcribing; bool get isBusy => activity != VoxoraActivity.idle; + String get transcriptionModelLabel { + for (final model in _transcriptionModels) { + if (model.id == transcriptionModel) return model.name; + } + return transcriptionModel; + } Future initialize() async { await _floatingOverlay.initialize(_handleOverlayAction); @@ -83,6 +95,7 @@ class VoxoraController extends ChangeNotifier { _storage.loadSoundEffects(), _storage.loadSilenceWhileRecording(), _storage.loadUsageStats(), + _storage.loadTranscriptionModel(), ]); _history ..clear() @@ -94,6 +107,7 @@ class VoxoraController extends ChangeNotifier { soundEffectsEnabled = values[6] as bool; silenceWhileRecording = values[7] as bool; _usageStats = values[8] as UsageStats? ?? UsageStats.fromHistory(_history); + transcriptionModel = values[9] as String; if (values[8] == null && _history.isNotEmpty) { await _storage.saveUsageStats(_usageStats); } @@ -155,6 +169,41 @@ class VoxoraController extends ChangeNotifier { await _storage.saveLanguageHint(value); } + Future setTranscriptionModel(String value) async { + final normalized = value.trim(); + if (normalized.isEmpty || isBusy) return; + transcriptionModel = normalized; + transcriptionModelsError = null; + notifyListeners(); + await _storage.saveTranscriptionModel(normalized); + _setFeedback('Modelo de transcrição atualizado.'); + } + + Future loadTranscriptionModels({bool force = false}) async { + if (isLoadingTranscriptionModels || + (!force && _transcriptionModels.isNotEmpty)) { + return; + } + isLoadingTranscriptionModels = true; + transcriptionModelsError = null; + notifyListeners(); + try { + final models = await _openRouter.listTranscriptionModels(apiKey: _apiKey); + _transcriptionModels + ..clear() + ..addAll(models); + if (models.isEmpty) { + transcriptionModelsError = + 'Nenhum modelo de transcrição está disponível agora.'; + } + } catch (error) { + transcriptionModelsError = _cleanError(error); + } finally { + isLoadingTranscriptionModels = false; + notifyListeners(); + } + } + Future setFloatingOverlayEnabled(bool value) async { if (value) { if (!_ensureApiKey()) return; @@ -441,6 +490,8 @@ class VoxoraController extends ChangeNotifier { apiKey: apiKey, format: format, languageHint: languageHint, + modelId: transcriptionModel, + expectedDurationMs: recordedDurationMs, ); final entry = TranscriptEntry( id: DateTime.now().microsecondsSinceEpoch.toString(), @@ -460,7 +511,7 @@ class VoxoraController extends ChangeNotifier { _usageStats = _usageStats.add(entry); await _storage.saveUsageStats(_usageStats); - var copied = autoCopy || _recordingFromOverlay; + var copied = autoCopy; if (copied && _recordingFromOverlay) { copied = await _floatingOverlay.copyText(entry.text); } else if (copied) { @@ -471,7 +522,10 @@ class VoxoraController extends ChangeNotifier { if (_recordingFromOverlay && autoPaste) { accessibilityEnabled = await _floatingOverlay.isAccessibilityEnabled(); if (accessibilityEnabled) { - pasted = await _floatingOverlay.pasteText(entry.text); + pasted = await _floatingOverlay.pasteText( + entry.text, + keepInClipboard: autoCopy, + ); } } _setFeedback( @@ -550,7 +604,8 @@ class VoxoraController extends ChangeNotifier { if (isRecording) await cancelRecording(); case 'dismiss': floatingOverlayEnabled = false; - await _storage.saveFloatingOverlay(false); + // Keep the saved preference enabled: dragging to the target only hides + // this service instance, so the overlay returns when the app is opened. notifyListeners(); } } diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 7b99520..2bb47ab 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -258,6 +259,7 @@ class _ApiKeyNotice extends StatelessWidget { color: VoxoraColors.surfaceSoft, borderRadius: BorderRadius.circular(10), child: InkWell( + key: const Key('transcription-model-field'), onTap: onTap, borderRadius: BorderRadius.circular(10), child: Container( @@ -315,7 +317,7 @@ class _RecorderStage extends StatelessWidget { controller.isRecording ? _formatDuration(controller.recordingDurationMs) : controller.isTranscribing - ? 'MAI-Transcribe 1.5' + ? controller.transcriptionModelLabel : 'PRONTO', key: ValueKey(controller.activity), style: TextStyle( @@ -752,14 +754,52 @@ class _TranscriptCard extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Text( - '${_sourceLabel(entry.source)} • ${_relativeTime(entry.createdAt)}', - style: const TextStyle( - color: VoxoraColors.muted, - fontSize: 10.5, + Expanded( + child: Text( + '${_sourceLabel(entry.source)} • ${_relativeTime(entry.createdAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: VoxoraColors.muted, + fontSize: 10.5, + ), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: 'Custo desta transcrição na OpenRouter', + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 4, + ), + decoration: BoxDecoration( + color: VoxoraColors.surfaceRaised, + borderRadius: BorderRadius.circular(99), + border: Border.all(color: VoxoraColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.payments_outlined, + size: 12, + color: VoxoraColors.muted, + ), + const SizedBox(width: 4), + Text( + _formatCostUsd(entry.costUsd), + style: const TextStyle( + color: VoxoraColors.mutedStrong, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), ), - const Spacer(), + const SizedBox(width: 2), IconButton( onPressed: onCopy, tooltip: 'Copiar', @@ -828,11 +868,28 @@ class _SettingsSheetState extends State<_SettingsSheet> { super.dispose(); } + Future _openModelPicker() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (context) => + _TranscriptionModelPicker(controller: widget.controller), + ); + } + @override Widget build(BuildContext context) { final bottomInset = MediaQuery.viewInsetsOf(context).bottom; return AnimatedBuilder( - animation: widget.controller, + // Update checks and downloads run independently from the main + // controller. Listen to both so the open settings sheet reflects each + // update phase immediately, without requiring it to be closed/reopened. + animation: Listenable.merge([ + widget.controller, + widget.controller.updates, + ]), builder: (context, _) { final controller = widget.controller; return Container( @@ -869,6 +926,7 @@ class _SettingsSheetState extends State<_SettingsSheet> { const Divider(height: 1), Expanded( child: ListView( + key: const Key('settings-list'), padding: EdgeInsets.fromLTRB(16, 16, 16, 24 + bottomInset), children: [ const _SettingsLabel('OPENROUTER'), @@ -938,6 +996,11 @@ class _SettingsSheetState extends State<_SettingsSheet> { ], ), ), + const SizedBox(height: 10), + _TranscriptionModelField( + controller: controller, + onTap: controller.isBusy ? null : _openModelPicker, + ), const SizedBox(height: 22), const _SettingsLabel('CÍRCULO FLUTUANTE'), const SizedBox(height: 8), @@ -1102,7 +1165,8 @@ class _SettingsSheetState extends State<_SettingsSheet> { _SettingsSwitch( icon: Icons.copy_all_outlined, title: 'Copiar automaticamente', - subtitle: 'Ao concluir uma transcrição', + subtitle: + 'Mantém a transcrição na área de transferência', value: controller.autoCopy, onChanged: controller.setAutoCopy, ), @@ -1163,7 +1227,10 @@ class _SettingsSheetState extends State<_SettingsSheet> { const SizedBox(height: 22), const _SettingsLabel('SOBRE E ATUALIZAÇÕES'), const SizedBox(height: 8), - _AboutAndUpdates(updates: controller.updates), + _AboutAndUpdates( + updates: controller.updates, + transcriptionModel: controller.transcriptionModel, + ), if (controller.history.isNotEmpty) ...[ const SizedBox(height: 22), const _SettingsLabel('DADOS LOCAIS'), @@ -1203,10 +1270,335 @@ class _SettingsSheetState extends State<_SettingsSheet> { } } +class _TranscriptionModelField extends StatelessWidget { + const _TranscriptionModelField({ + required this.controller, + required this.onTap, + }); + + final VoxoraController controller; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final selected = controller.transcriptionModels + .where((model) => model.id == controller.transcriptionModel) + .firstOrNull; + return Semantics( + button: true, + label: 'Modelo de transcrição', + value: selected?.name ?? controller.transcriptionModel, + child: Material( + color: VoxoraColors.surfaceSoft, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: VoxoraColors.border), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + child: Row( + children: [ + const Icon( + Icons.model_training_outlined, + color: VoxoraColors.muted, + size: 21, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Modelo de transcrição', + style: TextStyle( + color: VoxoraColors.muted, + fontSize: 11.5, + ), + ), + const SizedBox(height: 3), + Text( + selected?.name ?? controller.transcriptionModel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (selected != null && selected.id != selected.name) ...[ + const SizedBox(height: 2), + Text( + selected.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: VoxoraColors.muted, + fontSize: 10.5, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.unfold_more_rounded, + color: VoxoraColors.muted, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TranscriptionModelPicker extends StatefulWidget { + const _TranscriptionModelPicker({required this.controller}); + + final VoxoraController controller; + + @override + State<_TranscriptionModelPicker> createState() => + _TranscriptionModelPickerState(); +} + +class _TranscriptionModelPickerState extends State<_TranscriptionModelPicker> { + late final TextEditingController _searchController; + String _query = ''; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(widget.controller.loadTranscriptionModels()); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final controller = widget.controller; + final normalizedQuery = _query.trim().toLowerCase(); + final models = controller.transcriptionModels + .where((model) { + if (normalizedQuery.isEmpty) return true; + return model.name.toLowerCase().contains(normalizedQuery) || + model.id.toLowerCase().contains(normalizedQuery) || + model.description.toLowerCase().contains(normalizedQuery); + }) + .toList(growable: false); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + + return Container( + height: MediaQuery.sizeOf(context).height * 0.82, + padding: EdgeInsets.only(bottom: keyboardInset), + decoration: const BoxDecoration( + color: VoxoraColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + border: Border(top: BorderSide(color: VoxoraColors.border)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 10, 8), + child: Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Modelo de transcrição', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 2), + Text( + 'Modelos disponíveis na OpenRouter', + style: TextStyle( + color: VoxoraColors.muted, + fontSize: 11.5, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: TextField( + key: const Key('transcription-model-search'), + controller: _searchController, + autofocus: true, + autocorrect: false, + textInputAction: TextInputAction.search, + onChanged: (value) => setState(() => _query = value), + decoration: InputDecoration( + hintText: 'Pesquisar por nome ou provedor', + prefixIcon: const Icon(Icons.search_rounded, size: 20), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: 'Limpar pesquisa', + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + icon: const Icon(Icons.close_rounded, size: 18), + ), + ), + ), + ), + const Divider(height: 1), + Expanded(child: _modelList(context, controller, models)), + ], + ), + ); + }, + ); + } + + Widget _modelList( + BuildContext context, + VoxoraController controller, + List models, + ) { + if (controller.isLoadingTranscriptionModels && models.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(strokeWidth: 2.5), + SizedBox(height: 14), + Text( + 'Carregando modelos…', + style: TextStyle(color: VoxoraColors.mutedStrong), + ), + ], + ), + ); + } + + if (controller.transcriptionModelsError != null && models.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_outlined, + color: VoxoraColors.muted, + size: 30, + ), + const SizedBox(height: 12), + Text( + controller.transcriptionModelsError!, + textAlign: TextAlign.center, + style: const TextStyle(color: VoxoraColors.mutedStrong), + ), + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: () => + controller.loadTranscriptionModels(force: true), + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Tentar novamente'), + ), + ], + ), + ), + ); + } + + if (models.isEmpty) { + return const Center( + child: Text( + 'Nenhum modelo encontrado.', + style: TextStyle(color: VoxoraColors.mutedStrong), + ), + ); + } + + return RefreshIndicator( + onRefresh: () => controller.loadTranscriptionModels(force: true), + child: ListView.separated( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(8, 6, 8, 24), + itemCount: models.length, + separatorBuilder: (_, _) => const Divider(height: 1, indent: 54), + itemBuilder: (context, index) { + final model = models[index]; + final selected = model.id == controller.transcriptionModel; + return ListTile( + selected: selected, + selectedTileColor: VoxoraColors.accent.withValues(alpha: 0.07), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + leading: Icon( + selected ? Icons.check_circle_rounded : Icons.circle_outlined, + color: selected ? VoxoraColors.accent : VoxoraColors.muted, + size: 21, + ), + title: Text( + model.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + subtitle: Text( + model.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: VoxoraColors.muted, fontSize: 11), + ), + onTap: () async { + await controller.setTranscriptionModel(model.id); + if (context.mounted) Navigator.pop(context); + }, + ); + }, + ), + ); + } +} + class _AboutAndUpdates extends StatelessWidget { - const _AboutAndUpdates({required this.updates}); + const _AboutAndUpdates({ + required this.updates, + required this.transcriptionModel, + }); final AppUpdateService updates; + final String transcriptionModel; @override Widget build(BuildContext context) { @@ -1303,7 +1695,7 @@ class _AboutAndUpdates extends StatelessWidget { ), const SizedBox(height: 3), Text( - 'v${build.versionName} • ${OpenRouterService.model}', + 'v${build.versionName} • $transcriptionModel', overflow: TextOverflow.ellipsis, style: const TextStyle( color: VoxoraColors.muted, @@ -1497,6 +1889,12 @@ String _formatDuration(int milliseconds) { return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; } +String _formatCostUsd(double value) { + final normalized = value.isFinite && value > 0 ? value : 0.0; + final decimals = normalized > 0 && normalized < 0.0001 ? 6 : 4; + return '\$${normalized.toStringAsFixed(decimals)}'; +} + String _sourceLabel(String source) { if (source.toLowerCase().contains('grava')) return 'Gravação'; return source.length > 22 ? '${source.substring(0, 20)}…' : source; diff --git a/lib/src/services/floating_overlay_service.dart b/lib/src/services/floating_overlay_service.dart index d2f2919..f500fed 100644 --- a/lib/src/services/floating_overlay_service.dart +++ b/lib/src/services/floating_overlay_service.dart @@ -92,9 +92,12 @@ class FloatingOverlayService { await _channel.invokeMethod('playFeedback', {'sound': sound}); } - Future pasteText(String text) async { + Future pasteText(String text, {bool keepInClipboard = true}) async { if (!Platform.isAndroid) return false; - return await _channel.invokeMethod('pasteText', {'text': text}) ?? + return await _channel.invokeMethod('pasteText', { + 'text': text, + 'keepInClipboard': keepInClipboard, + }) ?? false; } diff --git a/lib/src/services/local_storage_service.dart b/lib/src/services/local_storage_service.dart index 7ce8544..4d6ac72 100644 --- a/lib/src/services/local_storage_service.dart +++ b/lib/src/services/local_storage_service.dart @@ -5,11 +5,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/transcript_entry.dart'; import '../models/usage_stats.dart'; +import 'openrouter_service.dart'; class LocalStorageService { static const _historyKey = 'voxora.history.v1'; static const _autoCopyKey = 'voxora.autoCopy'; static const _languageKey = 'voxora.languageHint'; + static const _transcriptionModelKey = 'openrouter.transcriptionModel'; static const _floatingOverlayKey = 'openflow.floatingOverlay'; static const _autoPasteKey = 'openflow.autoPaste'; static const _soundEffectsKey = 'openflow.soundEffects'; @@ -70,6 +72,17 @@ class LocalStorageService { await prefs.setString(_languageKey, value); } + Future loadTranscriptionModel() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_transcriptionModelKey) ?? + OpenRouterService.defaultModel; + } + + Future saveTranscriptionModel(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_transcriptionModelKey, value); + } + Future loadFloatingOverlay() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_floatingOverlayKey) ?? false; diff --git a/lib/src/services/openrouter_service.dart b/lib/src/services/openrouter_service.dart index 5eaa51c..e459844 100644 --- a/lib/src/services/openrouter_service.dart +++ b/lib/src/services/openrouter_service.dart @@ -20,6 +20,30 @@ class TranscriptionResult { final double costUsd; } +class TranscriptionModel { + const TranscriptionModel({ + required this.id, + required this.name, + this.description = '', + }); + + final String id; + final String name; + final String description; + + String get provider => id.contains('/') ? id.split('/').first : ''; + + factory TranscriptionModel.fromJson(Map json) { + final id = (json['id'] as String? ?? '').trim(); + final rawName = (json['name'] as String? ?? '').trim(); + return TranscriptionModel( + id: id, + name: rawName.isEmpty ? id : rawName, + description: (json['description'] as String? ?? '').trim(), + ); + } +} + class OpenRouterException implements Exception { const OpenRouterException(this.message); final String message; @@ -32,9 +56,13 @@ class OpenRouterService { OpenRouterService({http.Client? client}) : _client = client ?? http.Client(); static const model = 'microsoft/mai-transcribe-1.5'; + static const defaultModel = model; static final endpoint = Uri.parse( 'https://openrouter.ai/api/v1/audio/transcriptions', ); + static final modelsEndpoint = Uri.parse( + 'https://openrouter.ai/api/v1/models?output_modalities=transcription', + ); final http.Client _client; @@ -43,19 +71,22 @@ class OpenRouterService { required String apiKey, required String format, String languageHint = 'auto', + String modelId = defaultModel, + int expectedDurationMs = 0, }) async { final bytes = await file.readAsBytes(); - if (bytes.isEmpty) { - throw const OpenRouterException('O arquivo de áudio está vazio.'); + if (!_hasAudioPayload(bytes, format, expectedDurationMs)) { + throw const OpenRouterException( + 'O microfone não capturou áudio suficiente. Verifique se outro app está usando o microfone e tente novamente.', + ); } final body = { - 'model': model, + 'model': modelId, 'input_audio': { 'data': base64Encode(bytes), 'format': format, }, - 'temperature': 0, }; if (languageHint != 'auto') body['language'] = languageHint; @@ -95,12 +126,76 @@ class OpenRouterService { return decodeResponse( response, elapsedMs: DateTime.now().difference(startedAt).inMilliseconds, + fallbackModel: modelId, ); } + Future> listTranscriptionModels({ + String? apiKey, + }) async { + late http.Response response; + try { + response = await _client + .get( + modelsEndpoint, + headers: { + 'Accept': 'application/json', + 'X-OpenRouter-Title': 'OpenFlow Mobile', + if (apiKey != null && apiKey.trim().isNotEmpty) + 'Authorization': 'Bearer ${apiKey.trim()}', + }, + ) + .timeout(const Duration(seconds: 30)); + } on TimeoutException { + throw const OpenRouterException( + 'A lista de modelos demorou demais para carregar.', + ); + } on SocketException { + throw const OpenRouterException( + 'Sem conexão para carregar os modelos da OpenRouter.', + ); + } on http.ClientException { + throw const OpenRouterException( + 'Não foi possível carregar os modelos da OpenRouter.', + ); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OpenRouterException( + response.statusCode == 401 + ? 'A chave da OpenRouter não permitiu consultar os modelos.' + : 'Não foi possível carregar os modelos (HTTP ${response.statusCode}).', + ); + } + + try { + final payload = jsonDecode(response.body); + if (payload is! Map || payload['data'] is! List) { + throw const FormatException(); + } + final models = (payload['data'] as List) + .whereType() + .map( + (item) => + TranscriptionModel.fromJson(Map.from(item)), + ) + .where((item) => item.id.isNotEmpty) + .toList(); + models.sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + return models; + } on FormatException { + throw const OpenRouterException( + 'A OpenRouter retornou uma lista de modelos inválida.', + ); + } + } + static TranscriptionResult decodeResponse( http.Response response, { required int elapsedMs, + String fallbackModel = defaultModel, }) { Map payload = const {}; try { @@ -111,7 +206,10 @@ class OpenRouterService { } if (response.statusCode < 200 || response.statusCode >= 300) { - final apiMessage = _errorMessageFrom(payload); + final apiMessage = _errorMessageFrom( + payload, + statusCode: response.statusCode, + ); final friendly = switch (response.statusCode) { 401 => 'Chave da OpenRouter inválida ou expirada.', 402 => 'Saldo insuficiente na OpenRouter.', @@ -138,17 +236,91 @@ class OpenRouterService { return TranscriptionResult( text: text, - model: payload['model'] as String? ?? model, + model: payload['model'] as String? ?? fallbackModel, transcriptionMs: elapsedMs, audioDurationMs: (seconds * 1000).round(), costUsd: (usage['cost'] as num?)?.toDouble() ?? 0, ); } - static String? _errorMessageFrom(Map payload) { + static bool _hasAudioPayload( + List bytes, + String format, + int expectedDurationMs, + ) { + if (bytes.length < 64) return false; + if (format.toLowerCase() != 'wav') return true; + + if (_ascii(bytes, 0, 4) != 'RIFF' || _ascii(bytes, 8, 4) != 'WAVE') { + return false; + } + + var byteRate = 0; + var audioFormat = 0; + var bitsPerSample = 0; + for (var index = 12; index + 8 <= bytes.length;) { + final chunk = _ascii(bytes, index, 4); + final length = _littleEndian32(bytes, index + 4); + final dataStart = index + 8; + final dataEnd = dataStart + length; + if (length < 0 || dataEnd > bytes.length) return false; + + if (chunk == 'fmt ' && length >= 16) { + audioFormat = _littleEndian16(bytes, dataStart); + byteRate = _littleEndian32(bytes, dataStart + 8); + bitsPerSample = _littleEndian16(bytes, dataStart + 14); + } else if (chunk == 'data') { + if (length <= 0) return false; + + // A file much shorter than the duration shown in the UI means Android + // interrupted/paused capture; sending it only produces empty text. + if (expectedDurationMs >= 1000 && byteRate > 0) { + final actualDurationMs = (length * 1000) ~/ byteRate; + if (actualDurationMs + 500 < expectedDurationMs ~/ 2) return false; + } + + // This recorder writes 16-bit PCM. Exact zero samples mean that no + // microphone input reached the encoder (normal room noise is non-zero). + if (audioFormat == 1 && bitsPerSample == 16) { + for (var sample = dataStart; sample + 1 < dataEnd; sample += 2) { + if (bytes[sample] != 0 || bytes[sample + 1] != 0) return true; + } + return false; + } + return true; + } + index += 8 + length + (length.isOdd ? 1 : 0); + } + return false; + } + + static String _ascii(List bytes, int offset, int length) => + String.fromCharCodes(bytes.sublist(offset, offset + length)); + + static int _littleEndian16(List bytes, int offset) => + bytes[offset] | (bytes[offset + 1] << 8); + + static int _littleEndian32(List bytes, int offset) => + bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24); + + static String? _errorMessageFrom( + Map payload, { + required int statusCode, + }) { final error = payload['error']; if (error is Map && error['message'] is String) { - return (error['message'] as String).trim(); + final message = (error['message'] as String).trim(); + if (statusCode == 400 && + RegExp( + r'^provider returned(?: an error| 400)?$', + caseSensitive: false, + ).hasMatch(message)) { + return 'O provedor recusou o áudio. Use WAV, MP3 ou FLAC e tente novamente.'; + } + return message; } if (payload['message'] is String) { return (payload['message'] as String).trim(); diff --git a/lib/src/services/recording_service.dart b/lib/src/services/recording_service.dart index 6855aaa..dc8db9b 100644 --- a/lib/src/services/recording_service.dart +++ b/lib/src/services/recording_service.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; @@ -21,32 +21,25 @@ class AudioVisualizationFrame { class RecordingService { static const _sampleRate = 16000; - static const _bandFrequencies = [ - 110, - 170, - 250, - 380, - 570, - 850, - 1280, - 1900, - 2850, - 4250, - 6200, + static const _bandWeights = [ + 0.58, + 0.72, + 0.86, + 0.96, + 1.0, + 0.94, + 0.88, + 0.80, + 0.72, + 0.64, + 0.56, ]; final AudioRecorder _recorder = AudioRecorder(); final StreamController _visualization = StreamController.broadcast(sync: true); - StreamSubscription? _pcmSubscription; StreamSubscription? _amplitudeSubscription; - IOSink? _pcmSink; - Completer? _pcmDone; - String? _streamPath; - String? _pcmRawPath; - int _pcmBytes = 0; - DateTime _lastAnalysis = DateTime.fromMillisecondsSinceEpoch(0); Stream get visualizationStream => _visualization.stream; @@ -56,36 +49,24 @@ class RecordingService { Future start() async { final temp = await getTemporaryDirectory(); final stamp = DateTime.now().millisecondsSinceEpoch; - final supportsPcm = await _recorder.isEncoderSupported( - AudioEncoder.pcm16bits, - ); - - if (supportsPcm) { - final path = '${temp.path}${Platform.pathSeparator}openflow_$stamp.wav'; - await _startPcmStream(path); - return RecordingStart(path: path, format: 'wav'); - } - - final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); - final encoder = supportsWav ? AudioEncoder.wav : AudioEncoder.aacLc; - final format = supportsWav ? 'wav' : 'm4a'; + // MAI-Transcribe 1.5 accepts WAV, MP3, or FLAC. Android's native AAC + // recorder writes an M4A container, which Azure rejects with HTTP 400. + // PCM/WAV is supported natively by the record package and also gives the + // transcription provider the least ambiguous input possible. + const encoder = AudioEncoder.wav; + const format = 'wav'; final path = '${temp.path}${Platform.pathSeparator}openflow_$stamp.$format'; - await _recorder.start(_recordConfig(encoder), path: path); + await _recorder.start(debugRecordConfig(encoder), path: path); _amplitudeSubscription = _recorder .onAmplitudeChanged(const Duration(milliseconds: 90)) - .listen((value) { - final level = ((value.current + 55) / 55).clamp(0.0, 1.0); - _visualization.add( - AudioVisualizationFrame( - level: level, - bands: List.filled(_bandFrequencies.length, level), - ), - ); - }); + .listen( + (value) => _visualization.add(frameFromDecibels(value.current)), + ); return RecordingStart(path: path, format: format); } - RecordConfig _recordConfig(AudioEncoder encoder) => RecordConfig( + @visibleForTesting + static RecordConfig debugRecordConfig(AudioEncoder encoder) => RecordConfig( encoder: encoder, sampleRate: _sampleRate, numChannels: 1, @@ -93,189 +74,59 @@ class RecordingService { autoGain: true, echoCancel: true, noiseSuppress: true, + // The app's silencer deliberately takes exclusive audio focus shortly + // after capture starts. The package default (`pause`) would therefore + // pause our own recorder and leave a silent/header-only WAV. + audioInterruption: AudioInterruptionMode.none, streamBufferSize: 2048, - androidConfig: const AndroidRecordConfig( - service: AndroidService( + androidConfig: AndroidRecordConfig( + useLegacy: false, + audioSource: AndroidAudioSource.mic, + service: const AndroidService( title: 'OpenFlow está gravando', content: 'Toque no círculo para finalizar.', ), ), ); - Future _startPcmStream(String path) async { - final rawFile = File('$path.pcm'); - _pcmSink = rawFile.openWrite(); - _streamPath = path; - _pcmRawPath = rawFile.path; - _pcmBytes = 0; - _pcmDone = Completer(); - - try { - final stream = await _recorder.startStream( - _recordConfig(AudioEncoder.pcm16bits), - ); - _pcmSubscription = stream.listen( - (chunk) { - _pcmSink?.add(chunk); - _pcmBytes += chunk.length; - _analyzePcm(chunk); - }, - onError: (_) { - if (!(_pcmDone?.isCompleted ?? true)) _pcmDone!.complete(); - }, - onDone: () { - if (!(_pcmDone?.isCompleted ?? true)) _pcmDone!.complete(); - }, - ); - } catch (_) { - await _pcmSink?.close(); - _pcmSink = null; - _streamPath = null; - _pcmRawPath = null; - if (await rawFile.exists()) await rawFile.delete(); - rethrow; - } - } - - void _analyzePcm(Uint8List chunk) { - final now = DateTime.now(); - if (now.difference(_lastAnalysis).inMilliseconds < 55) return; - _lastAnalysis = now; - - final availableSamples = chunk.length ~/ 2; - if (availableSamples < 64) return; - final sampleCount = math.min(512, availableSamples); - final startByte = chunk.length - sampleCount * 2; - final data = ByteData.sublistView(chunk, startByte); - final samples = List.filled(sampleCount, 0); - var squareSum = 0.0; - for (var index = 0; index < sampleCount; index++) { - final sample = data.getInt16(index * 2, Endian.little) / 32768.0; - final window = - 0.5 - 0.5 * math.cos(2 * math.pi * index / (sampleCount - 1)); - samples[index] = sample * window; - squareSum += sample * sample; - } - - final rms = math.sqrt(squareSum / sampleCount); - final db = 20 * math.log(math.max(rms, 1e-8)) / math.ln10; - final level = ((db + 55) / 55).clamp(0.0, 1.0); - final energies = _bandFrequencies - .map((frequency) => _goertzel(samples, frequency)) - .toList(growable: false); - final peak = energies.fold(0, math.max); - final compressedLevel = math.sqrt(level); - final bands = energies - .map((energy) { - if (peak <= 1e-12) return 0.0; - final relative = math.pow(energy / peak, 0.34).toDouble(); - return (relative * compressedLevel).clamp(0.0, 1.0); - }) - .toList(growable: false); - - _visualization.add(AudioVisualizationFrame(level: level, bands: bands)); - } - - double _goertzel(List samples, double frequency) { - final omega = 2 * math.pi * frequency / _sampleRate; - final coefficient = 2 * math.cos(omega); - var first = 0.0; - var second = 0.0; - for (final sample in samples) { - final next = sample + coefficient * first - second; - second = first; - first = next; - } - return math.max( - 0, - first * first + second * second - coefficient * first * second, + static AudioVisualizationFrame frameFromDecibels(double decibels) { + final level = ((decibels + 55) / 55).clamp(0.0, 1.0); + final visualLevel = math.sqrt(level); + return AudioVisualizationFrame( + level: level, + bands: _bandWeights + .map((weight) => (visualLevel * weight).clamp(0.0, 1.0)) + .toList(growable: false), ); } Future stop() async { - final fallbackPath = await _recorder.stop(); + final path = await _recorder.stop(); await _amplitudeSubscription?.cancel(); _amplitudeSubscription = null; - if (_streamPath == null) return fallbackPath; - return _finishPcmStream(delete: false); + _emitSilence(); + return path; } Future cancel() async { await _recorder.cancel(); await _amplitudeSubscription?.cancel(); _amplitudeSubscription = null; - if (_streamPath != null) await _finishPcmStream(delete: true); - } - - Future _finishPcmStream({required bool delete}) async { - final path = _streamPath; - final rawPath = _pcmRawPath; - if (path == null) return null; - try { - await _pcmDone?.future.timeout( - const Duration(seconds: 2), - onTimeout: () {}, - ); - await _pcmSubscription?.cancel(); - await _pcmSink?.flush(); - await _pcmSink?.close(); - if (delete) { - if (rawPath != null) { - final rawFile = File(rawPath); - if (await rawFile.exists()) await rawFile.delete(); - } - return null; - } - if (rawPath == null) return null; - final output = File(path).openWrite(); - output.add(_wavHeader(_pcmBytes)); - await File(rawPath).openRead().pipe(output); - await File(rawPath).delete(); - return path; - } finally { - _pcmSubscription = null; - _pcmSink = null; - _pcmDone = null; - _streamPath = null; - _pcmRawPath = null; - _pcmBytes = 0; - _visualization.add( - AudioVisualizationFrame( - level: 0, - bands: List.filled(_bandFrequencies.length, 0), - ), - ); - } + _emitSilence(); } - Uint8List _wavHeader(int dataLength) { - final header = ByteData(44); - void ascii(int offset, String value) { - for (var index = 0; index < value.length; index++) { - header.setUint8(offset + index, value.codeUnitAt(index)); - } - } - - ascii(0, 'RIFF'); - header.setUint32(4, 36 + dataLength, Endian.little); - ascii(8, 'WAVE'); - ascii(12, 'fmt '); - header.setUint32(16, 16, Endian.little); - header.setUint16(20, 1, Endian.little); - header.setUint16(22, 1, Endian.little); - header.setUint32(24, _sampleRate, Endian.little); - header.setUint32(28, _sampleRate * 2, Endian.little); - header.setUint16(32, 2, Endian.little); - header.setUint16(34, 16, Endian.little); - ascii(36, 'data'); - header.setUint32(40, dataLength, Endian.little); - return header.buffer.asUint8List(); + void _emitSilence() { + if (_visualization.isClosed) return; + _visualization.add( + AudioVisualizationFrame( + level: 0, + bands: List.filled(_bandWeights.length, 0), + ), + ); } Future dispose() async { - await _pcmSubscription?.cancel(); await _amplitudeSubscription?.cancel(); - await _pcmSink?.close(); await _recorder.dispose(); await _visualization.close(); } diff --git a/pubspec.yaml b/pubspec.yaml index d10367e..5b09faf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,7 +73,9 @@ flutter_launcher_icons: image_path: assets/icon/openflow_icon.png adaptive_icon_background: "#FFFFFF" adaptive_icon_foreground: assets/icon/openflow_foreground.png - adaptive_icon_foreground_inset: 0 + # Keep the waveform inside Android's safe zone. The previous 0% inset made + # Samsung and other adaptive launchers zoom the mark until it touched the mask. + adaptive_icon_foreground_inset: 16 adaptive_icon_monochrome: assets/icon/openflow_foreground.png min_sdk_android: 24 diff --git a/test/openflow_ui_test.dart b/test/openflow_ui_test.dart index e105e8c..3638ed1 100644 --- a/test/openflow_ui_test.dart +++ b/test/openflow_ui_test.dart @@ -4,12 +4,110 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:voxora/src/controller/voxora_controller.dart'; import 'package:voxora/src/screens/home_screen.dart'; import 'package:voxora/src/services/floating_overlay_service.dart'; +import 'package:voxora/src/services/app_update_service.dart'; import 'package:voxora/src/services/local_storage_service.dart'; import 'package:voxora/src/services/openrouter_service.dart'; import 'package:voxora/src/services/recording_service.dart'; import 'package:voxora/src/theme.dart'; +class _FakeAppUpdateService extends AppUpdateService { + @override + bool get isSupported => true; + + @override + Future check() async { + phase = AppUpdatePhase.checking; + notifyListeners(); + } + + void finishCheck() { + available = AvailableAppUpdate( + versionName: '2.1.0', + versionCode: build.versionCode + 1, + channel: build.channel, + apkUrl: 'https://example.com/openflow.apk', + sha256: List.filled(64, 'a').join(), + ); + phase = AppUpdatePhase.available; + notifyListeners(); + } + + @override + Future downloadAndInstall() async { + phase = AppUpdatePhase.downloading; + progress = 0.42; + notifyListeners(); + } +} + +class _FakeOpenRouterService extends OpenRouterService { + @override + Future> listTranscriptionModels({ + String? apiKey, + }) async { + return const [ + TranscriptionModel( + id: 'openai/gpt-4o-mini-transcribe', + name: 'OpenAI: GPT-4o Mini Transcribe', + ), + ]; + } +} + void main() { + testWidgets('settings shows update check and download feedback immediately', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(412, 915); + addTearDown(tester.view.reset); + + final updates = _FakeAppUpdateService(); + addTearDown(updates.dispose); + final controller = VoxoraController( + storage: LocalStorageService(), + openRouter: OpenRouterService(), + recording: RecordingService(), + floatingOverlay: FloatingOverlayService(), + updates: updates, + ); + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + theme: VoxoraTheme.dark, + home: HomeScreen(controller: controller), + ), + ); + await tester.tap(find.byTooltip('Configurações')); + await tester.pump(const Duration(seconds: 1)); + for (var step = 0; step < 3; step++) { + await tester.dragFrom(const Offset(206, 700), const Offset(0, -600)); + await tester.pump(const Duration(milliseconds: 250)); + } + final checkButton = find.text('Verificar atualizações'); + await tester.ensureVisible(checkButton); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(checkButton); + await tester.pump(); + expect(find.text('Verificando…'), findsOneWidget); + expect(find.text('Consultando o canal stable…'), findsOneWidget); + + updates.finishCheck(); + await tester.pump(); + expect(find.text('Baixar e instalar'), findsOneWidget); + expect( + find.text('OpenFlow 2.1.0 está disponível para este canal.'), + findsOneWidget, + ); + + await tester.tap(find.text('Baixar e instalar')); + await tester.pump(); + expect(find.text('Baixando…'), findsOneWidget); + expect(find.text('Baixando e verificando o APK… 42%'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + }); + testWidgets( 'OpenFlow home keeps recorder and transcripts in one split view', (tester) async { @@ -69,4 +167,42 @@ void main() { expect(find.text('Verificar atualizações'), findsOneWidget); }, ); + + testWidgets('transcribing state shows the selected transcription model', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(412, 915); + addTearDown(tester.view.reset); + + final controller = VoxoraController( + storage: LocalStorageService(), + openRouter: _FakeOpenRouterService(), + recording: RecordingService(), + floatingOverlay: FloatingOverlayService(), + ); + addTearDown(controller.dispose); + controller.transcriptionModel = 'openai/gpt-4o-mini-transcribe'; + controller.activity = VoxoraActivity.transcribing; + + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + theme: VoxoraTheme.dark, + home: HomeScreen(controller: controller), + ), + ); + + expect(find.text('openai/gpt-4o-mini-transcribe'), findsOneWidget); + expect(find.text('MAI-Transcribe 1.5'), findsNothing); + + await controller.loadTranscriptionModels(); + await tester.pump(); + + expect(find.text('OpenAI: GPT-4o Mini Transcribe'), findsOneWidget); + expect(find.text('MAI-Transcribe 1.5'), findsNothing); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); } diff --git a/test/overlay_output_test.dart b/test/overlay_output_test.dart new file mode 100644 index 0000000..567a2b3 --- /dev/null +++ b/test/overlay_output_test.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:voxora/src/controller/voxora_controller.dart'; +import 'package:voxora/src/models/transcript_entry.dart'; +import 'package:voxora/src/models/usage_stats.dart'; +import 'package:voxora/src/services/floating_overlay_service.dart'; +import 'package:voxora/src/services/local_storage_service.dart'; +import 'package:voxora/src/services/openrouter_service.dart'; +import 'package:voxora/src/services/recording_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const recordChannel = MethodChannel('com.llfbandit.record/messages'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, (_) async => null); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(recordChannel, null); + }); + + for (final scenario in <({bool autoCopy, bool autoPaste})>[ + (autoCopy: false, autoPaste: false), + (autoCopy: false, autoPaste: true), + (autoCopy: true, autoPaste: false), + (autoCopy: true, autoPaste: true), + ]) { + test( + 'overlay output respects copy=${scenario.autoCopy} and paste=${scenario.autoPaste}', + () async { + final directory = await Directory.systemTemp.createTemp( + 'openflow_overlay_output_', + ); + final audio = File( + '${directory.path}${Platform.pathSeparator}recording.wav', + ); + await audio.writeAsBytes(List.filled(64, 1)); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + final overlay = _FakeOverlay(); + final controller = VoxoraController( + storage: _MemoryStorage(), + openRouter: _FakeOpenRouter(), + recording: _FakeRecording(audio.path), + floatingOverlay: overlay, + ); + addTearDown(controller.dispose); + + expect( + await controller.saveApiKey('test-key-with-enough-characters'), + isTrue, + ); + await controller.setAutoCopy(scenario.autoCopy); + await controller.setAutoPaste(scenario.autoPaste); + await controller.startRecording(fromOverlay: true); + await controller.stopAndTranscribe(); + + expect(overlay.copiedTexts, hasLength(scenario.autoCopy ? 1 : 0)); + expect(overlay.pastedTexts, hasLength(scenario.autoPaste ? 1 : 0)); + if (scenario.autoPaste) { + expect(overlay.keepInClipboard.single, scenario.autoCopy); + } + }, + ); + } +} + +class _MemoryStorage extends LocalStorageService { + @override + Future saveApiKey(String apiKey) async {} + + @override + Future saveAutoCopy(bool value) async {} + + @override + Future saveAutoPaste(bool value) async {} + + @override + Future saveHistory(List entries) async {} + + @override + Future saveUsageStats(UsageStats stats) async {} +} + +class _FakeOpenRouter extends OpenRouterService { + @override + Future transcribe({ + required File file, + required String apiKey, + required String format, + String languageHint = 'auto', + String modelId = OpenRouterService.defaultModel, + int expectedDurationMs = 0, + }) async => const TranscriptionResult( + text: 'Texto vindo da bolinha.', + model: 'test-model', + transcriptionMs: 10, + ); +} + +class _FakeRecording extends RecordingService { + _FakeRecording(this.path); + + final String path; + + @override + Stream get visualizationStream => + const Stream.empty(); + + @override + Future start() async => + RecordingStart(path: path, format: 'wav'); + + @override + Future stop() async => path; + + @override + Future dispose() async {} +} + +class _FakeOverlay extends FloatingOverlayService { + final List copiedTexts = []; + final List pastedTexts = []; + final List keepInClipboard = []; + + @override + Future hasRecordAudioPermission() async => true; + + @override + Future isAccessibilityEnabled() async => true; + + @override + Future copyText(String text) async { + copiedTexts.add(text); + return true; + } + + @override + Future pasteText(String text, {bool keepInClipboard = true}) async { + pastedTexts.add(text); + this.keepInClipboard.add(keepInClipboard); + return true; + } + + @override + Future playFeedback(String sound) async {} + + @override + Future setRecordingActive({ + required bool active, + required bool silence, + }) async {} + + @override + Future update({ + required String state, + required double level, + required List bands, + }) async {} +} diff --git a/test/voxora_test.dart b/test/voxora_test.dart index 6787c3f..e1bf9ef 100644 --- a/test/voxora_test.dart +++ b/test/voxora_test.dart @@ -1,8 +1,14 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:record/record.dart'; import 'package:voxora/src/models/transcript_entry.dart'; import 'package:voxora/src/models/usage_stats.dart'; import 'package:voxora/src/services/openrouter_service.dart'; +import 'package:voxora/src/services/recording_service.dart'; void main() { test('transcript entry survives local JSON round-trip', () { @@ -56,6 +62,161 @@ void main() { ); }); + test('OpenRouter explains a generic provider format failure', () { + final response = http.Response( + '{"error":{"message":"Provider returned 400"}}', + 400, + ); + + expect( + () => OpenRouterService.decodeResponse(response, elapsedMs: 10), + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf(contains('recusou o áudio'), contains('WAV')), + ), + ), + ); + }); + + test( + 'OpenRouter receives a valid WAV request without optional noise', + () async { + late http.Request captured; + final client = MockClient((request) async { + captured = request; + return http.Response('{"text":"Funcionou.","model":"test-model"}', 200); + }); + final directory = await Directory.systemTemp.createTemp('openflow_wav_'); + final file = File('${directory.path}${Platform.pathSeparator}speech.wav'); + await file.writeAsBytes(_wavWithOneSample()); + final service = OpenRouterService(client: client); + addTearDown(() async { + service.dispose(); + await directory.delete(recursive: true); + }); + + final result = await service.transcribe( + file: file, + apiKey: 'test-key', + format: 'wav', + modelId: 'openai/gpt-4o-mini-transcribe', + ); + final body = jsonDecode(captured.body) as Map; + final inputAudio = body['input_audio'] as Map; + + expect(captured.url, OpenRouterService.endpoint); + expect(captured.headers['authorization'], 'Bearer test-key'); + expect(body['model'], 'openai/gpt-4o-mini-transcribe'); + expect(body, isNot(contains('language'))); + expect(body, isNot(contains('temperature'))); + expect(inputAudio['format'], 'wav'); + expect(inputAudio['data'], isNot(startsWith('data:'))); + expect(result.text, 'Funcionou.'); + }, + ); + + test('OpenRouter lists and sorts transcription models', () async { + late http.Request captured; + final client = MockClient((request) async { + captured = request; + return http.Response( + '{"data":[' + '{"id":"microsoft/mai-transcribe-1.5","name":"Microsoft: MAI Transcribe"},' + '{"id":"openai/gpt-4o-mini-transcribe","name":"OpenAI: GPT-4o Mini Transcribe"}' + ']}', + 200, + ); + }); + final service = OpenRouterService(client: client); + addTearDown(service.dispose); + + final models = await service.listTranscriptionModels(apiKey: 'test-key'); + + expect(captured.url, OpenRouterService.modelsEndpoint); + expect(captured.headers['authorization'], 'Bearer test-key'); + expect(models, hasLength(2)); + expect(models.first.id, 'microsoft/mai-transcribe-1.5'); + expect(models.last.id, 'openai/gpt-4o-mini-transcribe'); + }); + + test('recording ignores the app silencer audio-focus interruption', () { + final config = RecordingService.debugRecordConfig(AudioEncoder.wav); + + expect(config.audioInterruption, AudioInterruptionMode.none); + }); + + test('header-only audio is rejected before reaching OpenRouter', () async { + final directory = await Directory.systemTemp.createTemp('openflow_test_'); + final file = File('${directory.path}${Platform.pathSeparator}empty.wav'); + await file.writeAsBytes(List.filled(44, 0)); + final service = OpenRouterService(); + addTearDown(() async { + service.dispose(); + await directory.delete(recursive: true); + }); + + await expectLater( + service.transcribe(file: file, apiKey: 'unused', format: 'wav'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('não capturou áudio'), + ), + ), + ); + }); + + test('silent PCM audio is rejected before reaching OpenRouter', () async { + final directory = await Directory.systemTemp.createTemp('openflow_silent_'); + final file = File('${directory.path}${Platform.pathSeparator}silent.wav'); + await file.writeAsBytes(_wavWithSamples(List.filled(1600, 0))); + final service = OpenRouterService(); + addTearDown(() async { + service.dispose(); + await directory.delete(recursive: true); + }); + + await expectLater( + service.transcribe(file: file, apiKey: 'unused', format: 'wav'), + throwsA(isA()), + ); + }); + + test('truncated recording is rejected using its expected duration', () async { + final directory = await Directory.systemTemp.createTemp('openflow_short_'); + final file = File('${directory.path}${Platform.pathSeparator}short.wav'); + await file.writeAsBytes(_wavWithSamples(List.filled(3200, 12))); + final service = OpenRouterService(); + addTearDown(() async { + service.dispose(); + await directory.delete(recursive: true); + }); + + await expectLater( + service.transcribe( + file: file, + apiKey: 'unused', + format: 'wav', + expectedDurationMs: 5000, + ), + throwsA(isA()), + ); + }); + + test('native microphone amplitude produces visible recorder bands', () { + final silence = RecordingService.frameFromDecibels(-160); + final speech = RecordingService.frameFromDecibels(-18); + + expect(silence.level, 0); + expect(silence.bands, everyElement(0)); + expect(speech.level, greaterThan(0)); + expect(speech.bands, hasLength(11)); + expect(speech.bands, everyElement(greaterThan(0))); + }); + test('usage stats preserve desktop metrics and daily totals', () { final entries = [ TranscriptEntry( @@ -93,3 +254,36 @@ void main() { expect(restored.dailyWords['2026-08-18'], 2); }); } + +List _wavWithOneSample() { + return _wavWithSamples([16, 0, ...List.filled(20, 0)]); +} + +List _wavWithSamples(List samples) { + final bytes = List.filled(44 + samples.length, 0); + void ascii(int offset, String value) { + bytes.setRange(offset, offset + value.length, value.codeUnits); + } + + void littleEndian(int offset, int value, int length) { + for (var index = 0; index < length; index += 1) { + bytes[offset + index] = (value >> (index * 8)) & 0xff; + } + } + + ascii(0, 'RIFF'); + littleEndian(4, bytes.length - 8, 4); + ascii(8, 'WAVE'); + ascii(12, 'fmt '); + littleEndian(16, 16, 4); + littleEndian(20, 1, 2); + littleEndian(22, 1, 2); + littleEndian(24, 16000, 4); + littleEndian(28, 32000, 4); + littleEndian(32, 2, 2); + littleEndian(34, 16, 2); + ascii(36, 'data'); + littleEndian(40, samples.length, 4); + bytes.setRange(44, bytes.length, samples); + return bytes; +}