refactor: Migrate foreground services to new architecture

This commit is contained in:
Myzel394 2023-08-08 19:07:54 +02:00
parent bc42f35eba
commit 0bfed18314
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
13 changed files with 164 additions and 123 deletions

View File

@ -37,7 +37,7 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service android:name=".services.RecorderService" android:foregroundServiceType="microphone" />
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone|camera" />
</application>
</manifest>

View File

@ -4,11 +4,13 @@ import android.media.MediaRecorder
import android.os.Build
class AudioRecorderService: IntervalRecorderService() {
var amplitudesAmount = 1000
var recorder: MediaRecorder? = null
private set
val filePath: String
get() = "$folder/$counter.${settings.fileExtension}"
get() = "$folder/$counter.${settings!!.fileExtension}"
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@ -18,10 +20,10 @@ class AudioRecorderService: IntervalRecorderService() {
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings.outputFormat)
setAudioEncoder(settings.encoder)
setAudioEncodingBitRate(settings.bitRate)
setAudioSamplingRate(settings.samplingRate)
setOutputFormat(settings!!.outputFormat)
setAudioEncoder(settings!!.encoder)
setAudioEncodingBitRate(settings!!.bitRate)
setAudioSamplingRate(settings!!.samplingRate)
}
}
@ -47,9 +49,13 @@ class AudioRecorderService: IntervalRecorderService() {
recorder = newRecorder
}
override fun getAmplitudeAmount(): Int {
return 100
override fun stop() {
super.stop()
resetRecorder()
}
override fun getAmplitudeAmount(): Int = amplitudesAmount
override fun getAmplitude(): Int = recorder?.maxAmplitude ?: 0
}

View File

@ -2,6 +2,7 @@ package app.myzel394.alibi.services
import android.os.Handler
import android.os.Looper
import app.myzel394.alibi.enums.RecorderState
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.Executors
@ -12,12 +13,12 @@ abstract class ExtraRecorderInformationService: RecorderService() {
abstract fun getAmplitudeAmount(): Int
abstract fun getAmplitude(): Int
private var recordingTime = 0L
var recordingTime = 0L
private set
private lateinit var recordingTimeTimer: ScheduledExecutorService
var amplitudes = mutableListOf<Int>()
private set
private lateinit var amplitudesTimer: Timer
private val handler = Handler(Looper.getMainLooper())
@ -29,6 +30,7 @@ abstract class ExtraRecorderInformationService: RecorderService() {
it.scheduleAtFixedRate(
{
recordingTime += 1000
onRecordingTimeChange?.invoke(recordingTime)
},
0,
1000,
@ -38,7 +40,12 @@ abstract class ExtraRecorderInformationService: RecorderService() {
}
private fun updateAmplitude() {
if (state !== RecorderState.RECORDING) {
return
}
amplitudes.add(getAmplitude())
onAmplitudeChange?.invoke(amplitudes)
// Delete old amplitudes
if (amplitudes.size > getAmplitudeAmount()) {
@ -49,17 +56,7 @@ abstract class ExtraRecorderInformationService: RecorderService() {
}
private fun createAmplitudesTimer() {
amplitudesTimer = Timer().also {
it.scheduleAtFixedRate(
object: TimerTask() {
override fun run() {
updateAmplitude()
}
},
0,
100,
)
}
handler.postDelayed(::updateAmplitude, 100)
}
override fun start() {
@ -69,7 +66,6 @@ abstract class ExtraRecorderInformationService: RecorderService() {
override fun pause() {
recordingTimeTimer.shutdown()
amplitudesTimer.cancel()
}
override fun resume() {
@ -79,7 +75,6 @@ abstract class ExtraRecorderInformationService: RecorderService() {
override fun stop() {
recordingTimeTimer.shutdown()
amplitudesTimer.cancel()
}
}

View File

@ -1,21 +1,33 @@
package app.myzel394.alibi.services
import android.content.Context
import android.media.MediaRecorder
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AudioRecorderSettings
import app.myzel394.alibi.db.LastRecording
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import java.time.LocalDateTime
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
private var job = SupervisorJob()
private var scope = CoroutineScope(Dispatchers.IO + job)
protected var counter = 0
private set
protected lateinit var folder: File
lateinit var settings: Settings
var settings: Settings? = null
protected set
private lateinit var cycleTimer: ScheduledExecutorService
@ -23,10 +35,10 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
fun createLastRecording(): LastRecording = LastRecording(
folderPath = folder.absolutePath,
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
fileExtension = settings.fileExtension,
intervalDuration = settings.intervalDuration,
forceExactMaxDuration = settings.forceExactMaxDuration,
maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension,
intervalDuration = settings!!.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
)
// Make overrideable
@ -41,32 +53,53 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
startNewCycle()
},
0,
settings.intervalDuration,
settings!!.intervalDuration,
TimeUnit.MILLISECONDS
)
}
}
private fun getRandomFileFolder(): String {
// uuid
val folder = UUID.randomUUID().toString()
return "${externalCacheDir!!.absolutePath}/$folder"
}
override fun start() {
super.start()
folder = File(getRandomFileFolder())
folder.mkdirs()
createTimer()
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
if (settings == null) {
settings = Settings.from(preferenceSettings.audioRecorderSettings)
createTimer()
}
}
}
}
override fun pause() {
super.pause()
cycleTimer.shutdown()
}
override fun resume() {
super.resume()
createTimer()
}
override fun stop() {
super.stop()
cycleTimer.shutdown()
}
private fun deleteOldRecordings() {
val timeMultiplier = settings.maxDuration / settings.intervalDuration
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier
folder.listFiles()?.forEach { file ->

View File

@ -26,7 +26,7 @@ abstract class RecorderService: Service() {
lateinit var recordingStart: LocalDateTime
private set
var state = RecorderState.RECORDING
var state = RecorderState.IDLE
private set
var onStateChange: ((RecorderState) -> Unit)? = null
@ -60,28 +60,34 @@ abstract class RecorderService: Service() {
pause()
isPaused = true
}
else -> throw IllegalStateException("$newState is not a valid state. Destroy or recreate the service instead.")
RecorderState.IDLE -> stop()
}
state = newState
onStateChange?.invoke(newState)
}
// Must be called immediately after the service is created
fun startRecording() {
recordingStart = LocalDateTime.now()
val notification = buildNotification()
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
// Start
changeState(RecorderState.RECORDING)
}
override fun onCreate() {
super.onCreate()
val notification = buildNotification()
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
recordingStart = LocalDateTime.now()
start()
startRecording()
}
override fun onDestroy() {
super.onDestroy()
stop()
changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()

View File

@ -11,11 +11,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.screens.AudioRecorder
import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen
@ -23,7 +25,9 @@ import app.myzel394.alibi.ui.screens.WelcomeScreen
const val SCALE_IN = 1.25f
@Composable
fun Navigation() {
fun Navigation(
audioRecorder: AudioRecorderModel = viewModel()
) {
val navController = rememberNavController()
val context = LocalContext.current
val settings = context
@ -32,6 +36,8 @@ fun Navigation() {
.collectAsState(initial = null)
.value ?: return
audioRecorder.BindToService(context)
NavHost(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
@ -53,7 +59,10 @@ fun Navigation() {
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
AudioRecorder(navController = navController)
AudioRecorder(
navController = navController,
audioRecorder = audioRecorder,
)
}
composable(
Screen.Settings.route,
@ -64,7 +73,10 @@ fun Navigation() {
scaleOut(targetScale = 1 / SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
SettingsScreen(navController = navController)
SettingsScreen(
navController = navController,
audioRecorder = audioRecorder,
)
}
}
}

View File

@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.MAX_AMPLITUDE
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.clamp
import kotlinx.coroutines.launch
import kotlin.math.ceil
@ -34,10 +35,10 @@ private const val GROW_END = BOX_DIFF * 4
@Composable
fun RealtimeAudioVisualizer(
service: RecorderService,
audioRecorder: AudioRecorderModel,
) {
val scope = rememberCoroutineScope()
val amplitudes = service.amplitudes
val amplitudes = audioRecorder.amplitudes!!
val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f)
@ -47,7 +48,7 @@ fun RealtimeAudioVisualizer(
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
service.setOnAmplitudeUpdateListener {
audioRecorder.onAmplitudeChange = {
scope.launch {
animationProgress.snapTo(0f)
animationProgress.animateTo(
@ -66,7 +67,7 @@ fun RealtimeAudioVisualizer(
LaunchedEffect(screenWidth) {
// Add 1 to allow the visualizer to overflow the screen
service.maxAmplitudes = ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
}
Canvas(

View File

@ -88,13 +88,9 @@ fun SaveRecordingButton(
.then(modifier),
onClick = {
isProcessingAudio = true
RecorderService.stopService(context)
scope.launch {
try {
val file = service.concatenateFiles()
onSaveFile(file)
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {

View File

@ -47,6 +47,7 @@ import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialo
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration
import kotlinx.coroutines.delay
@ -57,15 +58,12 @@ import java.time.ZoneId
@Composable
fun RecordingStatus(
service: RecorderService,
onSaveFile: (File) -> Unit,
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) }
val progress = service.recordingTime.value!! / (service.settings!!.maxDuration / 1000f)
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
@ -74,7 +72,7 @@ fun RecordingStatus(
}
// Only show animation when the recording has just started
val recordingJustStarted = service.recordingTime.value!! < 1
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
LaunchedEffect(Unit) {
progressVisible = true
@ -89,7 +87,7 @@ fun RecordingStatus(
verticalArrangement = Arrangement.SpaceBetween,
) {
Box {}
RealtimeAudioVisualizer(service = service)
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -97,7 +95,7 @@ fun RecordingStatus(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val distance = Duration.between(service.recordingStart!!, now).toMillis()
val distance = Duration.between(audioRecorder.recorderService!!.recordingStart, now).toMillis()
Pulsating {
Box(
@ -121,7 +119,7 @@ fun RecordingStatus(
)
) {
LinearProgressIndicator(
progress = progress,
progress = audioRecorder.progress,
modifier = Modifier
.width(300.dp)
)
@ -137,8 +135,7 @@ fun RecordingStatus(
},
onConfirm = {
showDeleteDialog = false
RecorderService.stopService(context)
service.reset()
audioRecorder.stopRecording(context)
},
)
}
@ -163,11 +160,5 @@ fun RecordingStatus(
}
}
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
SaveRecordingButton(
modifier = Modifier
.alpha(alpha),
service = service,
onSaveFile = onSaveFile,
)
}
}

View File

@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -52,15 +53,14 @@ import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.AudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun StartRecording(
connection: ServiceConnection,
service: RecorderService? = null,
onStart: () -> Unit,
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*")
@ -80,17 +80,7 @@ fun StartRecording(
)
},
onPermissionAvailable = {
RecorderService.startService(context, connection)
if (service == null) {
onStart()
} else {
// To avoid any leaks from the previous recording, we need to wait until it
// fully started
service.setOnStartedListener {
onStart()
}
}
audioRecorder.startRecording(context)
},
) { trigger ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
@ -140,21 +130,12 @@ fun StartRecording(
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
if (service?.hasRecordingAvailable == true)
if (false)
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
SaveRecordingButton(
service = service,
onSaveFile = saveFile,
label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(service.recordingStart),
),
)
}
else
Spacer(modifier = Modifier.weight(1f))

View File

@ -5,6 +5,8 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -19,11 +21,20 @@ class AudioRecorderModel: ViewModel() {
private set
var recordingTime by mutableStateOf<Long?>(null)
private set
var amplitudes by mutableStateOf<List<Int>?>(null)
var amplitudes by mutableStateOf<List<Int>>(emptyList())
private set
var onAmplitudeChange: () -> Unit = {}
val isInRecording: Boolean
get() = recorderState !== RecorderState.IDLE && recordingTime != null
val progress: Float
get() = (recordingTime!! / recorderService!!.settings!!.maxDuration).toFloat()
private var intent: Intent? = null
private var recorderService: RecorderService? = null
var recorderService: AudioRecorderService? = null
private set
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
@ -36,8 +47,12 @@ class AudioRecorderModel: ViewModel() {
}
recorder.onAmplitudeChange = { amps ->
amplitudes = amps
onAmplitudeChange()
}
}
recorderState = recorderService!!.state
recordingTime = recorderService!!.recordingTime
amplitudes = recorderService!!.amplitudes
}
override fun onServiceDisconnected(arg0: ComponentName) {
@ -49,7 +64,7 @@ class AudioRecorderModel: ViewModel() {
fun reset() {
recorderState = RecorderState.IDLE
recordingTime = null
amplitudes = null
amplitudes = emptyList()
}
fun startRecording(context: Context) {
@ -57,13 +72,30 @@ class AudioRecorderModel: ViewModel() {
context.unbindService(connection)
}
val intent = Intent(context, AudioRecorderService::class.java)
ContextCompat.startForegroundService(context, intent)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
intent = Intent(context, AudioRecorderService::class.java)
ContextCompat.startForegroundService(context, intent!!)
context.bindService(intent!!, connection, Context.BIND_AUTO_CREATE)
}
fun stopRecording(context: Context) {
context.stopService(intent)
context.unbindService(connection)
runCatching {
context.unbindService(connection)
context.stopService(intent)
}
reset()
}
fun setMaxAmplitudesAmount(amount: Int) {
recorderService?.amplitudesAmount = amount
}
@Composable
fun BindToService(context: Context) {
LaunchedEffect(Unit) {
Intent(context, AudioRecorderService::class.java).also { intent ->
context.bindService(intent, connection, 0)
}
}
}
}

View File

@ -14,26 +14,25 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import app.myzel394.alibi.services.bindToRecorderService
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.models.AudioRecorderModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorder(
navController: NavController,
audioRecorder: AudioRecorderModel
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/aac")
val (connection, service) = bindToRecorderService()
var showRecorderStatus by remember {
mutableStateOf(service?.isRecording ?: false)
}
Scaffold(
topBar = {
@ -61,22 +60,12 @@ fun AudioRecorder(
.fillMaxSize()
.padding(padding),
) {
if (showRecorderStatus && service?.recordingTime?.value != null)
if (audioRecorder.isInRecording)
RecordingStatus(
service = service,
onSaveFile = {
saveFile(it)
showRecorderStatus = false
}
audioRecorder = audioRecorder,
)
else
StartRecording(
connection = connection,
service = service,
onStart = {
showRecorderStatus = true
}
)
StartRecording(audioRecorder = audioRecorder)
}
}
}

View File

@ -32,7 +32,6 @@ import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.services.bindToRecorderService
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile
@ -43,15 +42,15 @@ import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.models.AudioRecorderModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavController
navController: NavController,
audioRecorder: AudioRecorderModel,
) {
val (_, service) = bindToRecorderService()
val isRecording = service?.isRecording ?: false
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState()
)
@ -91,7 +90,7 @@ fun SettingsScreen(
.value
// Show alert
if (isRecording)
if (audioRecorder.isInRecording)
Box(
modifier = Modifier
.padding(16.dp)