Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"
android:minSdkVersion="33" />
Expand All @@ -18,6 +25,7 @@
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
android:minSdkVersion="33" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

<uses-feature android:name="android.hardware.screen.portrait" android:required="false" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
Expand Down Expand Up @@ -71,6 +79,12 @@
android:name=".v2.app.HomeActivity"
android:launchMode="singleTask"
android:exported="false" />
<activity
android:name=".v2.app.overlay.FloatingRenameSpeechRecognitionActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Transparent" />
<activity
android:name=".app.settings.SettingsActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
Expand Down Expand Up @@ -143,6 +157,15 @@
android:exported="false"
android:foregroundServiceType="microphone" />

<service
android:name=".v2.app.overlay.FloatingRecorderOverlayService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="floating_recorder_overlay" />
</service>

<receiver android:name=".WidgetReceiver" android:exported="true" />
<receiver android:name=".app.RecordingService$StopRecordingReceiver" android:exported="false" />
<receiver android:name=".app.PlaybackService$StopPlaybackReceiver" android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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
Expand Down Expand Up @@ -79,6 +81,11 @@ class HomeActivity: ComponentActivity() {
}
}

override fun onStart() {
super.onStart()
reconcileFloatingRecorderOverlayService()
}

@Composable
fun RecorderApp(
coroutineScope: CoroutineScope
Expand Down Expand Up @@ -107,4 +114,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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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",
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,6 +65,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
Expand Down Expand Up @@ -382,7 +384,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.stoppedFromFloatingOverlay,
)
}
is AudioRecordingServiceEvent.NewRecordingPartStarted -> {
handleNewRecordingPartStarted(event.recordId)
Expand Down Expand Up @@ -452,12 +458,21 @@ class HomeViewModel @Inject constructor(
})
}

private suspend fun handleRecordingStopped(recordedRecordId: Long, recordName: String?) {
private suspend fun handleRecordingStopped(
recordedRecordId: Long,
recordName: String?,
stoppedFromFloatingOverlay: Boolean,
) {
withContext(ioDispatcher) {
if (recordedRecordId >= 0) {
if (_state.value.isDeleteRecordingProgressRequested) {
moveRecordToRecycle(recordedRecordId, false)
} else if (prefs.askToRenameAfterRecordingStopped) {
} else if (recordingStoppedRenamePolicy(
askToRenameAfterRecordingStopped = prefs.askToRenameAfterRecordingStopped,
recordId = recordedRecordId,
stoppedFromFloatingOverlay = stoppedFromFloatingOverlay,
).showInAppRenameDialog
) {
updateState()
val record = recordsDataSource.getRecord(recordedRecordId)
withContext(mainDispatcher) {
Expand Down Expand Up @@ -838,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<Application>().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<Application>().applicationContext
emitEvent(
HomeScreenEvent.ShowErrorSnack(
Expand All @@ -857,11 +880,11 @@ class HomeViewModel @Inject constructor(
showLoadingProgress(false)
return
} else {
recordsDataSource.renameRecord(activeRecord, newName)
recordsDataSource.renameRecord(activeRecord, cleanedName)
val context: Context = getApplication<Application>().applicationContext
emitEvent(
HomeScreenEvent.ShowInfoSnack(
context.getString(R.string.msg_record_renamed, newName)
context.getString(R.string.msg_record_renamed, cleanedName)
)
)
}
Expand Down Expand Up @@ -1419,4 +1442,4 @@ private class LongEvaluator : TypeEvaluator<Long> {
override fun evaluate(fraction: Float, startValue: Long, endValue: Long): Long {
return startValue + ((endValue - startValue) * fraction).toLong()
}
}
}
Loading