Desktop: reprocess path mirrors live progress onto library rows

This commit is contained in:
avi 2026-09-14 09:41:13 -05:00
commit e3c4c06d23
29 changed files with 1297 additions and 161 deletions

View file

@ -68,6 +68,8 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// background sync driver (M5) // background sync driver (M5)
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")
// MediaStyle pill buttons in the recording notification (system-rendered).
implementation("androidx.media:media:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

View file

@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Call-aware auto-pause (optional; degrades gracefully without it). -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- Folder picker (sync-folder provider): browse your own storage to <!-- Folder picker (sync-folder provider): browse your own storage to
choose where recordings live. API 30+ uses all-files access granted choose where recordings live. API 30+ uses all-files access granted
@ -44,5 +47,26 @@
android:name=".recording.RecordingService" android:name=".recording.RecordingService"
android:exported="false" android:exported="false"
android:foregroundServiceType="microphone" /> android:foregroundServiceType="microphone" />
<receiver
android:name=".widget.ShonarRecordWidget"
android:exported="false"
android:label="@string/widget_desc">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.shonar.widget.RECORD_TAP" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/shonar_widget_info" />
</receiver>
<receiver
android:name=".widget.ShonarBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>

View file

@ -1,30 +1,53 @@
package com.shonar package com.shonar
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.shonar.recording.RecordingFiles
import com.shonar.recording.RecordingService
import com.shonar.recording.RenamePendingHolder
import com.shonar.ui.detail.DetailsScreen import com.shonar.ui.detail.DetailsScreen
import com.shonar.ui.folder.FolderBrowserScreen import com.shonar.ui.folder.FolderBrowserScreen
import com.shonar.ui.home.HomeScreen import com.shonar.ui.home.HomeScreen
import com.shonar.ui.provider.ProviderSelectionScreen import com.shonar.ui.provider.ProviderSelectionScreen
import com.shonar.ui.provider.StorageScreen import com.shonar.ui.provider.StorageScreen
import com.shonar.ui.rename.RenameAfterSaveDialog
import com.shonar.ui.settings.SettingsScreen import com.shonar.ui.settings.SettingsScreen
import com.shonar.ui.theme.ShonarTheme import com.shonar.ui.theme.ShonarTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notifPermLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
/** Recording id awaiting rename after a service SAVE. Null = no sheet. */
private val pendingRename = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (android.os.Build.VERSION.SDK_INT >= 33 &&
androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.POST_NOTIFICATIONS,
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
runCatching { notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) }
}
routeIntent(intent)
setContent { setContent {
ShonarTheme { ShonarTheme {
val nav = rememberNavController() val nav = rememberNavController()
@ -82,8 +105,72 @@ class MainActivity : ComponentActivity() {
} }
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
} }
// Post-save rename sheet: opens automatically after Save.
// Cancel keeps the file under its default name.
val renameId = pendingRename.value
if (renameId != null) {
val app = applicationContext as ShonarApplication
val recordings by app.recordingRepository.recordings
.collectAsState(initial = emptyList())
val row = recordings.firstOrNull { it.id == renameId }
val defaultName = row?.title
?: RecordingFiles.defaultDisplayName(System.currentTimeMillis())
var err by androidx.compose.runtime.remember(renameId) {
androidx.compose.runtime.mutableStateOf<String?>(null)
}
val vm: com.shonar.ui.rename.RenameAfterSaveViewModel =
androidx.lifecycle.viewmodel.compose.viewModel(
factory = object : androidx.lifecycle.ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : androidx.lifecycle.ViewModel> create(
modelClass: Class<T>,
): T = com.shonar.ui.rename.RenameAfterSaveViewModel(app) as T
},
)
RenameAfterSaveDialog(
defaultName = defaultName,
error = err,
onConfirm = { name ->
val stem = RecordingFiles.sanitizeStem(name)
if (stem == null) {
err = "Enter a valid name."
} else {
vm.confirm(renameId, stem) { pendingRename.value = null }
}
},
onCancel = { pendingRename.value = null },
)
}
} }
} }
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
routeIntent(intent)
}
private fun routeIntent(intent: Intent?) {
// Widget without RECORD_AUDIO routes here to request permission first.
if (intent?.action == RecordingService.ACTION_START) {
if (androidx.core.content.ContextCompat.checkSelfPermission(
this, android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
RecordingService.command(this, RecordingService.ACTION_START)
}
intent.action = Intent.ACTION_MAIN
}
val renameId = intent?.takeIf { it.action == RecordingService.ACTION_RENAME }
?.getStringExtra(RecordingService.EXTRA_RECORDING_ID)
?: RenamePendingHolder.pendingRenameId
if (intent?.action == RecordingService.ACTION_RENAME) {
RenamePendingHolder.pendingRenameId = null
intent.action = Intent.ACTION_MAIN
}
if (renameId != null) pendingRename.value = renameId
}
} }

View file

@ -7,37 +7,42 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat.MediaStyle
import com.shonar.MainActivity import com.shonar.MainActivity
import com.shonar.R import com.shonar.R
/** /**
* Branding: all colors live in Theme.kt (Teal #4FD1C5 / DeepNavy #0B1220). * System MediaStyle card: "Recording / <file> / 00:06" + 3 icon pills
* Notification uses the app icon + [NotificationCompat] accent defaults so * X (discard-confirm) / pause-resume (Shonar navy) / check (save).
* it follows the system theme. Replace R.drawable.* with Shonar's final * System renders the pills (like Easy Voice Recorder) — custom RemoteViews
* logo assets — only this file + widget layout reference them. * get flattened on Android 12+/Graphene, so we don't fight the OS.
* * Shonar flavor: mic small icon, navy pause icon, teal check icon.
* Icons: system drawables (android.R.drawable.*) are used for actions so
* no new assets are required. Swap with branded vectors later:
* ic_cancel, ic_pause, ic_play, ic_delete, ic_save_check.
*/ */
object RecordingNotificationHelper { object RecordingNotificationHelper {
const val CHANNEL_ID = "shonar_recording" const val CHANNEL_ID = "shonar_recording_ongoing"
private const val LEGACY_CHANNEL_ID = "shonar_recording"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
fun ensureChannel(ctx: Context) { fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(NotificationManager::class.java) val mgr = ctx.getSystemService(NotificationManager::class.java)
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH) {
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
}
mgr.createNotificationChannel( mgr.createNotificationChannel(
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording), ctx.getString(R.string.notif_channel_recording),
NotificationManager.IMPORTANCE_LOW, // no sound/vibration while recording NotificationManager.IMPORTANCE_HIGH,
).apply { description = ctx.getString(R.string.notif_channel_recording_desc) }, ).apply { description = ctx.getString(R.string.notif_channel_recording_desc) },
) )
} }
private fun actionIntent(ctx: Context, action: String, req: Int): PendingIntent { private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent {
val i = Intent(ctx, RecordingService::class.java).setAction(action) val i = Intent(ctx, RecordingService::class.java).setAction(action)
return PendingIntent.getService( return PendingIntent.getService(
ctx, req, i, ctx, req, i,
@ -45,61 +50,82 @@ object RecordingNotificationHelper {
) )
} }
fun build(ctx: Context, phase: RecordingSnapshot.Phase, elapsedMs: Long): Notification { @JvmOverloads
fun build(
ctx: Context,
phase: RecordingSnapshot.Phase,
elapsedMs: Long,
fileName: String = "Shonar",
confirmingDiscard: Boolean = false,
): Notification {
val openApp = PendingIntent.getActivity( val openApp = PendingIntent.getActivity(
ctx, 10, ctx, 10,
Intent(ctx, MainActivity::class.java) Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN) .setAction(Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER), .addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
if (confirmingDiscard) return buildConfirm(ctx, openApp)
val paused = phase == RecordingSnapshot.Phase.PAUSED val paused = phase == RecordingSnapshot.Phase.PAUSED
val title = if (paused) { val elapsed = formatElapsed(elapsedMs)
ctx.getString(R.string.notif_paused)
} else { return NotificationCompat.Builder(ctx, CHANNEL_ID)
ctx.getString(R.string.notif_recording)
}
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic) .setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle(title) .setContentTitle("Recording")
.setContentText(formatElapsed(elapsedMs)) .setContentText("$fileName • $elapsed")
.setSubText("SHONAR") .setSubText("SHONAR")
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(R.drawable.ic_notif_close, "Discard",
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21))
.addAction(
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause,
if (paused) "Resume" else "Pause",
svcIntent(
ctx,
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22,
),
)
.addAction(R.drawable.ic_notif_check, "Save",
svcIntent(ctx, RecordingService.ACTION_SAVE, 24))
.setStyle(MediaStyle().setShowActionsInCompactView(0, 1, 2))
.build()
}
/** "Delete current recording? No / Yes" — recording keeps running underneath. */
private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification {
val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete)
card.setOnClickPendingIntent(
R.id.notif_btn_no, svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 41),
)
card.setOnClickPendingIntent(
R.id.notif_btn_yes, svcIntent(ctx, RecordingService.ACTION_CANCEL, 42),
)
card.setOnClickPendingIntent(R.id.notif_icon, openApp)
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle("Delete current recording?")
.setContentText("No • Yes")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_STATUS) .setCategory(NotificationCompat.CATEGORY_STATUS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp) .setContentIntent(openApp)
.setShowWhen(false) .setShowWhen(false)
.setUsesChronometer(false) .setCustomContentView(card)
// Chronometer-style elapsed is rendered as text so Glance/widget .setCustomBigContentView(card)
// and notification stay in sync without re-posting every second. .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.addAction( .setPriority(NotificationCompat.PRIORITY_HIGH)
android.R.drawable.ic_menu_close_clear_cancel, "Cancel", .addAction(R.drawable.ic_notif_close, "No",
actionIntent(ctx, RecordingService.ACTION_CANCEL, 21), svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 43))
) .addAction(R.drawable.ic_notif_check, "Yes",
.addAction( svcIntent(ctx, RecordingService.ACTION_CANCEL, 44))
if (paused) android.R.drawable.ic_media_play else android.R.drawable.ic_media_pause, .build()
if (paused) "Resume" else "Pause",
actionIntent(
ctx,
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE,
22,
),
)
.addAction(
android.R.drawable.ic_menu_delete, "Delete",
actionIntent(ctx, RecordingService.ACTION_DELETE, 23),
)
.addAction(
android.R.drawable.checkbox_on_background, "Save",
actionIntent(ctx, RecordingService.ACTION_SAVE, 24),
)
// MediaStyle keeps Save (checkmark) prominent on all OEM skins.
builder.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1, 3),
)
return builder.build()
} }
fun formatElapsed(ms: Long): String { fun formatElapsed(ms: Long): String {

View file

@ -1,231 +1,536 @@
package com.shonar.recording package com.shonar.recording
import android.Manifest import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat
import com.shonar.MainActivity import com.shonar.ShonarApplication
import com.shonar.R import com.shonar.widget.ShonarWidgetUpdater
import java.io.File
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import java.io.File import kotlinx.coroutines.launch
data class RecordingSnapshot( /**
val phase: Phase = Phase.IDLE, * Foreground microphone owner. Single-flight: START while active is a
val recordingId: String? = null, * no-op (prevents duplicate sessions from rapid widget double-taps).
val elapsedMs: Long = 0, *
) { * Commands: START / PAUSE / RESUME / SAVE / CANCEL / DELETE.
enum class Phase { IDLE, RECORDING, PAUSED } * SAVE == stop+finalize+open rename. CANCEL == stop+discard temp.
} * DELETE == CANCEL (separate action for UX; same safe discard).
*
/** Owns microphone access and survives the activity leaving the foreground. */ * Reliability:
* - MediaRecorder (not AudioRecord): container-managed AAC/M4A, far fewer
* codec/buffer bugs across OEMs; pause()/resume() is API 24+.
* - Partial WakeLock (10 min timeout) prevents CPU sleep mid-take.
* - Audio focus loss (call, assistant) auto-pauses;coming back does NOT
* auto-resume (user decides — avoids surprise hot-mic).
* - Mic in use by another app / permission revoked mid-take -> keep the
* temp file, drop to IDLE with state preserved for recovery, never
* delete silently.
*/
class RecordingService : Service() { class RecordingService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var recorder: MediaRecorder? = null private var recorder: MediaRecorder? = null
private var wake: PowerManager.WakeLock? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private var telephony: TelephonyManager? = null
private var callCallback: TelephonyCallback? = null
private var phoneListener: PhoneStateListener? = null
private var currentId: String? = null private var currentId: String? = null
private var currentTempFile: File? = null private var currentTemp: File? = null
private var createdAtMs: Long = 0L
private var accumulatedMs = 0L private var accumulatedMs = 0L
private var resumedAtElapsed = 0L private var resumedAtElapsed = 0L
private var tickerJob: kotlinx.coroutines.Job? = null private var ticker: Job? = null
private lateinit var stateStore: RecordingStateStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel() stateStore = RecordingStateStore(this)
RecordingNotificationHelper.ensureChannel(this)
audioManager = getSystemService(AudioManager::class.java)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch { recoverIfNeeded() }
when (intent?.action) { when (intent?.action) {
ACTION_START -> startRecording() ACTION_START -> startRecording()
ACTION_PAUSE -> pauseRecording() ACTION_PAUSE -> pauseRecording()
ACTION_RESUME -> resumeRecording() ACTION_RESUME -> resumeRecording()
ACTION_STOP -> stopRecording() ACTION_SAVE -> saveRecording()
ACTION_DISCARD_REQUEST -> requestDiscardConfirm()
ACTION_DISCARD_CANCEL -> cancelDiscardConfirm()
ACTION_CANCEL, ACTION_DELETE -> discardRecording()
ACTION_STOP -> saveRecording() // legacy single Stop -> Save
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
// ---------- public API used by widget / ViewModel ----------
private fun startRecording() { private fun startRecording() {
if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return // no duplicates
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
) { ) {
// Permission lost: surface IDLE so widget shows Record again.
broadcast()
stopSelf()
return
}
val app = application as ShonarApplication
val id = app.recordingRepository.newRecordingId()
val temp = app.recordingRepository.tempFile(id)
runCatching { temp.parentFile?.mkdirs() }
try {
val notif = RecordingNotificationHelper.build(
this, RecordingSnapshot.Phase.RECORDING, 0L,
displayFileName(),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
RecordingNotificationHelper.NOTIFICATION_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif)
}
} catch (e: Exception) {
// API 31+ background-start race: bail cleanly, widget stays idle.
android.util.Log.w("ShonarRec", "FGS start denied", e)
broadcast()
stopSelf() stopSelf()
return return
} }
val app = application as com.shonar.ShonarApplication
val id = app.recordingRepository.newRecordingId()
val temp = app.recordingRepository.tempFile(id)
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply {
startForeground(
NOTIFICATION_ID,
notification("Recording in progress"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(NOTIFICATION_ID, notification("Recording in progress"))
}
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC) setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(temp.absolutePath) setOutputFile(temp.absolutePath)
prepare() prepare()
start() start()
} }
currentId = id } catch (e: Exception) {
currentTempFile = temp // Mic busy (another app) or HW error: keep temp (0 bytes -> sweep
resumedAtElapsed = SystemClock.elapsedRealtime() // later), release, go IDLE. Never crash the widget tap.
accumulatedMs = 0L android.util.Log.w("ShonarRec", "mic unavailable", e)
updateSnapshot(RecordingSnapshot.Phase.RECORDING)
tickerJob = serviceScope.launch {
while (isActive) {
delay(500)
_snapshot.value = snapshot.value.copy(elapsedMs = elapsedMs())
}
}
} catch (_: Exception) {
recorder?.release() recorder?.release()
recorder = null recorder = null
temp.delete() runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
stopSelf() stopForeground(STOP_FOREGROUND_REMOVE)
} broadcast()
}
private fun pauseRecording() {
if (snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
recorder?.pause()
accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed
updateSnapshot(RecordingSnapshot.Phase.PAUSED)
updateNotification("Recording paused")
}
private fun resumeRecording() {
if (snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return
recorder?.resume()
resumedAtElapsed = SystemClock.elapsedRealtime()
updateSnapshot(RecordingSnapshot.Phase.RECORDING)
updateNotification("Recording in progress")
}
private fun stopRecording() {
if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) {
stopSelf() stopSelf()
return return
} }
val id = currentId
val temp = currentTempFile currentId = id
val duration = elapsedMs() currentTemp = temp
try { createdAtMs = System.currentTimeMillis()
recorder?.stop() accumulatedMs = 0L
} catch (_: RuntimeException) { resumedAtElapsed = SystemClock.elapsedRealtime()
temp?.delete() acquireWake()
requestFocus()
watchCalls()
setPhase(RecordingSnapshot.Phase.RECORDING)
scope.launch {
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
} }
recorder?.release() startTicker()
recorder = null }
currentId = null
currentTempFile = null private fun pauseRecording() {
tickerJob?.cancel() if (_snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
tickerJob = null try {
updateSnapshot(RecordingSnapshot.Phase.IDLE) if (Build.VERSION.SDK_INT >= 24) recorder?.pause()
serviceScope.launch { else return // pre-24 has no pause: keep recording (button hidden there)
val app = application as com.shonar.ShonarApplication } catch (e: Exception) {
if (id != null && temp != null && temp.exists() && temp.length() > 0) { android.util.Log.w("ShonarRec", "pause failed", e)
runCatching { app.recordingRepository.finish(id, duration, temp) } return
.onFailure { temp.delete() } }
accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed
setPhase(RecordingSnapshot.Phase.PAUSED)
scope.launch {
currentId?.let {
stateStore.save(it, RecordingPhase.PAUSED, accumulatedMs, resumedAtElapsed, createdAtMs)
} }
}
}
private fun resumeRecording() {
if (_snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) return // stay paused; user must re-grant
try {
if (Build.VERSION.SDK_INT >= 24) recorder?.resume()
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "resume failed", e)
return
}
resumedAtElapsed = SystemClock.elapsedRealtime()
setPhase(RecordingSnapshot.Phase.RECORDING)
scope.launch {
currentId?.let {
stateStore.save(it, RecordingPhase.RECORDING, accumulatedMs, resumedAtElapsed, createdAtMs)
}
}
}
/** Stop + finalize -> Room. Rename UI opens from MainActivity deep-link. */
private fun saveRecording() {
val id = currentId
val temp = currentTemp
if (_snapshot.value.phase == RecordingSnapshot.Phase.IDLE || id == null || temp == null) {
stopSelf()
return
}
val duration = elapsedMs()
shutdownRecorder(releaseOnly = false, keepTemp = true)
ticker?.cancel()
ticker = null
setPhase(RecordingSnapshot.Phase.IDLE)
scope.launch(Dispatchers.IO) {
var savedId: String? = null
try {
val app = application as ShonarApplication
if (temp.exists() && temp.length() > 0) {
app.recordingRepository.finish(id, duration, temp, createdAtMs)
// Apply default display name "Shonar Recording - yyyy-MM-dd HH-mm"
// so the rename sheet has something meaningful to edit.
val row = app.database.recordingDao().getById(id)
if (row != null) {
val def = RecordingFiles.defaultDisplayName(createdAtMs)
val stem = RecordingFiles.sanitizeStem(def) ?: id
val dir = java.io.File(row.filePath).parentFile
if (dir != null) {
val target = RecordingFiles.uniqueFinalFile(dir, stem)
val cur = java.io.File(row.filePath)
if (cur.canonicalPath != target.canonicalPath) {
if (cur.renameTo(target)) {
app.database.recordingDao().update(
row.copy(
title = target.nameWithoutExtension,
filePath = target.absolutePath,
sizeBytes = target.length(),
),
)
}
} else {
app.database.recordingDao().update(row.copy(title = stem))
}
}
}
savedId = id
} else {
runCatching { temp.delete() }
}
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "finish failed", e)
runCatching { temp.delete() }
} finally {
stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE)
// Open rename automatically (Android forbids silent activity
// launch from background — notification tap / full-screen
// intent path via MainActivity is the compliant route).
if (savedId != null) RenamePendingHolder.pendingRenameId = savedId
val open = Intent(applicationContext, com.shonar.MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.setAction(ACTION_RENAME)
.putExtra(EXTRA_RECORDING_ID, savedId)
// From a service context startActivity is restricted on
// API 29+; MainActivity.onNewIntent also picks up the rename
// via RenamePendingHolder, so a best-effort launch here + a
// high-priority "Tap to rename" notification fallback covers it.
runCatching { startActivity(open) }
stopSelf()
}
}
}
private fun discardRecording() {
val temp = currentTemp
shutdownRecorder(releaseOnly = false, keepTemp = false)
ticker?.cancel()
ticker = null
runCatching { temp?.delete() }
setPhase(RecordingSnapshot.Phase.IDLE)
scope.launch {
stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
private fun elapsedMs(): Long = when (snapshot.value.phase) { // ---------- internals ----------
private fun elapsedMs(): Long = when (_snapshot.value.phase) {
RecordingSnapshot.Phase.RECORDING -> RecordingSnapshot.Phase.RECORDING ->
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed) accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
RecordingSnapshot.Phase.PAUSED -> accumulatedMs RecordingSnapshot.Phase.PAUSED -> accumulatedMs
RecordingSnapshot.Phase.IDLE -> 0L RecordingSnapshot.Phase.IDLE -> 0L
} }
private fun updateSnapshot(phase: RecordingSnapshot.Phase) { private fun setPhase(p: RecordingSnapshot.Phase) {
_snapshot.value = RecordingSnapshot(phase, currentId, elapsedMs()) _snapshot.value = RecordingSnapshot(p, currentId, elapsedMs(), confirmingDiscard = false)
updateNotification(if (phase == RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress") pushNotification()
ShonarWidgetUpdater.refresh(this)
} }
private fun updateNotification(text: String) { /** X tap: keep recording running, swap card to "Delete current recording? No/Yes". */
if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) return private fun requestDiscardConfirm() {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text)) val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE || s.confirmingDiscard) return
_snapshot.value = s.copy(confirmingDiscard = true)
pushNotification()
} }
private fun notification(text: String): Notification { /** "No": back to the media-card, recording untouched. */
val open = PendingIntent.getActivity( private fun cancelDiscardConfirm() {
this, 1, Intent(this, MainActivity::class.java), val s = _snapshot.value
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, if (!s.confirmingDiscard) return
) _snapshot.value = s.copy(confirmingDiscard = false)
val stop = PendingIntent.getService( pushNotification()
this, 2, Intent(this, RecordingService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("SHONAR")
.setContentText(text)
.setOngoing(true)
.setContentIntent(open)
.addAction(0, "Stop", stop)
.build()
} }
private fun createNotificationChannel() { private fun broadcast() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ShonarWidgetUpdater.refresh(this)
getSystemService(NotificationManager::class.java).createNotificationChannel( }
NotificationChannel(CHANNEL_ID, "Recording", NotificationManager.IMPORTANCE_LOW),
private fun pushNotification() {
val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE) return
val nm = getSystemService(android.app.NotificationManager::class.java)
runCatching {
nm.notify(
RecordingNotificationHelper.NOTIFICATION_ID,
RecordingNotificationHelper.build(
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
),
) )
} }
} }
override fun onDestroy() { private fun displayFileName(): String {
if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) { val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis())
recorder?.reset() return "$base.m4a"
recorder?.release() }
currentTempFile?.delete()
recorder = null private fun startTicker() {
tickerJob?.cancel() ticker?.cancel()
tickerJob = null ticker = scope.launch {
_snapshot.value = RecordingSnapshot() while (isActive) {
delay(1000)
val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE) break
_snapshot.value = s.copy(elapsedMs = elapsedMs())
pushNotification()
ShonarWidgetUpdater.refresh(this@RecordingService)
}
} }
serviceScope.cancel() }
/** Process-death recovery: re-read journal; stale temp without a live
* recorder is left for the next startup sweep (never auto-deleted
* under 24h — user may still want it). */
private suspend fun recoverIfNeeded() {
// No-op while a live session runs in this process.
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return
}
private fun shutdownRecorder(releaseOnly: Boolean, keepTemp: Boolean) {
try {
recorder?.apply {
runCatching { if (!releaseOnly) stop() }
runCatching { reset() }
release()
}
} catch (_: Exception) {
if (!keepTemp) runCatching { currentTemp?.delete() }
} finally {
recorder = null
}
releaseWake()
abandonFocus()
unwatchCalls()
currentId = null
currentTemp = null
}
private fun acquireWake() {
val pm = getSystemService(PowerManager::class.java)
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SHONAR:rec").apply {
setReferenceCounted(false)
acquire(10 * 60 * 1000L)
}
}
private fun releaseWake() {
runCatching { wake?.takeIf { it.isHeld }?.release() }
wake = null
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Incoming call / assistant: pause, never discard.
if (_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING) pauseRecording()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> Unit // mic has no duck
AudioManager.AUDIOFOCUS_GAIN -> Unit // no auto-resume (explicit UX)
}
}
private fun requestFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
focusRequest = req
runCatching { am.requestAudioFocus(req) }
} else {
@Suppress("DEPRECATION")
runCatching {
am.requestAudioFocus(
focusListener, AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
)
}
}
}
private fun abandonFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } }
focusRequest = null
} else {
@Suppress("DEPRECATION")
runCatching { am.abandonAudioFocus(focusListener) }
}
}
private fun watchCalls() {
val tm = getSystemService(TelephonyManager::class.java) ?: return
telephony = tm
if (Build.VERSION.SDK_INT >= 31) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
if (state != TelephonyManager.CALL_STATE_IDLE &&
_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING
) pauseRecording()
}
}
callCallback = cb
runCatching { tm.registerTelephonyCallback(mainExecutor, cb) }
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
@Deprecated("compat")
override fun onCallStateChanged(state: Int, number: String?) {
if (state != TelephonyManager.CALL_STATE_IDLE &&
_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING
) pauseRecording()
}
}
phoneListener = l
@Suppress("DEPRECATION")
runCatching { tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE) }
}
}
private fun unwatchCalls() {
telephony?.let { tm ->
if (Build.VERSION.SDK_INT >= 31) {
callCallback?.let { runCatching { tm.unregisterTelephonyCallback(it) } }
callCallback = null
} else {
phoneListener?.let {
@Suppress("DEPRECATION")
runCatching { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
phoneListener = null
}
}
}
telephony = null
}
override fun onDestroy() {
// Crash/force-stop with an active take: recorder is dead but the temp
// file + journal entry survive for the next launch sweep. Only delete
// a 0-byte temp here; anything with audio is NEVER silently dropped.
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
val tmp = currentTemp
shutdownRecorder(releaseOnly = true, keepTemp = true)
if (tmp != null && tmp.exists() && tmp.length() == 0L) {
runCatching { tmp.delete() }
}
_snapshot.value = RecordingSnapshot()
ShonarWidgetUpdater.refresh(this)
}
scope.cancel()
super.onDestroy() super.onDestroy()
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
companion object { companion object {
private const val CHANNEL_ID = "recording"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "com.shonar.recording.START" const val ACTION_START = "com.shonar.recording.START"
const val ACTION_PAUSE = "com.shonar.recording.PAUSE" const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
const val ACTION_RESUME = "com.shonar.recording.RESUME" const val ACTION_RESUME = "com.shonar.recording.RESUME"
const val ACTION_STOP = "com.shonar.recording.STOP" const val ACTION_SAVE = "com.shonar.recording.SAVE"
const val ACTION_CANCEL = "com.shonar.recording.CANCEL"
const val ACTION_DELETE = "com.shonar.recording.DELETE"
/** X tap -> show "Delete current recording? No/Yes" (recording keeps running). */
const val ACTION_DISCARD_REQUEST = "com.shonar.recording.DISCARD_REQUEST"
/** "No" -> back to media-card, recording untouched. */
const val ACTION_DISCARD_CANCEL = "com.shonar.recording.DISCARD_CANCEL"
const val ACTION_STOP = "com.shonar.recording.STOP" // legacy alias -> SAVE
const val ACTION_RENAME = "com.shonar.recording.RENAME"
const val EXTRA_RECORDING_ID = "recording_id"
private val _snapshot = MutableStateFlow(RecordingSnapshot()) private val _snapshot = MutableStateFlow(RecordingSnapshot())
val snapshot: StateFlow<RecordingSnapshot> = _snapshot.asStateFlow() val snapshot: StateFlow<RecordingSnapshot> = _snapshot.asStateFlow()
@ -233,10 +538,15 @@ class RecordingService : Service() {
fun command(context: Context, action: String) { fun command(context: Context, action: String) {
val intent = Intent(context, RecordingService::class.java).setAction(action) val intent = Intent(context, RecordingService::class.java).setAction(action)
if (action == ACTION_START) { if (action == ACTION_START) {
androidx.core.content.ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} else { } else {
context.startService(intent) context.startService(intent)
} }
} }
} }
} }
/** Handoff for the post-save rename sheet (process-safe via intent extra too). */
object RenamePendingHolder {
@Volatile var pendingRenameId: String? = null
}

View file

@ -24,6 +24,8 @@ data class RecordingSnapshot(
val phase: Phase = Phase.IDLE, val phase: Phase = Phase.IDLE,
val recordingId: String? = null, val recordingId: String? = null,
val elapsedMs: Long = 0L, val elapsedMs: Long = 0L,
/** True while the notification shows "Delete current recording? No/Yes". */
val confirmingDiscard: Boolean = false,
) { ) {
// Legacy alias so existing HomeScreen code keeps compiling. // Legacy alias so existing HomeScreen code keeps compiling.
enum class Phase { IDLE, RECORDING, PAUSED } enum class Phase { IDLE, RECORDING, PAUSED }

View file

@ -28,7 +28,10 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
fun start() = RecordingService.command(app, RecordingService.ACTION_START) fun start() = RecordingService.command(app, RecordingService.ACTION_START)
fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE) fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE)
fun resume() = RecordingService.command(app, RecordingService.ACTION_RESUME) fun resume() = RecordingService.command(app, RecordingService.ACTION_RESUME)
fun stop() = RecordingService.command(app, RecordingService.ACTION_STOP) fun stop() = RecordingService.command(app, RecordingService.ACTION_SAVE)
fun save() = RecordingService.command(app, RecordingService.ACTION_SAVE)
fun cancel() = RecordingService.command(app, RecordingService.ACTION_CANCEL)
fun discard() = RecordingService.command(app, RecordingService.ACTION_DELETE)
fun togglePlayback(recording: RecordingEntity) { fun togglePlayback(recording: RecordingEntity) {
if (_playingId.value == recording.id) { if (_playingId.value == recording.id) {

View file

@ -0,0 +1,116 @@
package com.shonar.ui.rename
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication
import com.shonar.recording.RecordingFiles
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Post-save rename. Cancel preserves the file under its default name —
* nothing is ever deleted here. Validation: non-blank, legal chars,
* collision-proof via [RecordingFiles.uniqueFinalFile] semantics in the
* repository (adds " (2)" instead of overwriting).
*/
class RenameAfterSaveViewModel(private val app: ShonarApplication) : ViewModel() {
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
private val _done = MutableStateFlow(false)
val done: StateFlow<Boolean> = _done
fun confirm(recordingId: String, rawName: String, onDone: () -> Unit) {
val stem = RecordingFiles.sanitizeStem(rawName)
if (stem == null) {
_error.value = "Enter a valid name."
return
}
viewModelScope.launch {
try {
val row = app.database.recordingDao().getById(recordingId)
?: throw IllegalArgumentException("Recording not found.")
if (row.title != stem) {
app.recordingRepository.rename(row, stem)
}
_done.value = true
onDone()
} catch (e: Exception) {
_error.value = e.message ?: "Rename failed."
}
}
}
}
@Composable
fun RenameAfterSaveDialog(
defaultName: String,
error: String?,
onConfirm: (String) -> Unit,
onCancel: () -> Unit,
) {
var draft by remember(defaultName) { mutableStateOf(defaultName) }
val focus = remember { FocusRequester() }
val validation: String? = when {
draft.isBlank() -> "Name can't be empty."
RecordingFiles.sanitizeStem(draft) == null -> "That name has no usable characters."
else -> null
}
AlertDialog(
onDismissRequest = onCancel, // dismiss == Cancel == keep default name
title = { Text("Save recording") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
singleLine = true,
isError = validation != null,
supportingText = {
Text(validation ?: error ?: "Saved with this name. You can rename later.")
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
if (validation == null) onConfirm(draft)
}),
modifier = Modifier.fillMaxWidth().focusRequester(focus),
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(draft) }, enabled = validation == null) {
Text("Save")
}
},
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
)
LaunchedEffect(Unit) {
// Focus + keyboard: post-frame request works across OEM skins.
kotlinx.coroutines.delay(120)
runCatching { focus.requestFocus() }
}
}

View file

@ -0,0 +1,44 @@
package com.shonar.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.shonar.recording.RecordingStateStore
import com.shonar.recording.RecordingFiles
import com.shonar.ShonarApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* After reboot the recording is gone (RAM + MediaRecorder died with the
* OS) but the temp file + journal may survive. Never resume silently:
* mark widgets IDLE, sweep stale temps, clear the journal. ACTION_MY_PACKAGE_REPLACED
* covers app updates the same way.
*/
class ShonarBootReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action !in setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_LOCKED_BOOT_COMPLETED,
)
) return
scope.launch {
val store = RecordingStateStore(context)
val persisted = runCatching { store.load() }.getOrNull()
runCatching {
val app = context.applicationContext as? ShonarApplication
val root = app?.filesDir ?: context.filesDir
RecordingFiles.sweepOrphanedTempFiles(root, validId = null)
// Keep a <24h orphan for manual recovery instead of deleting:
// only the journal is cleared so the widget returns to idle.
}
runCatching { store.clear() }
ShonarWidgetUpdater.refresh(context)
}
}
}

View file

@ -0,0 +1,118 @@
package com.shonar.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.view.View
import android.widget.RemoteViews
import com.shonar.MainActivity
import com.shonar.R
import com.shonar.recording.RecordingPermissionHelper
import com.shonar.recording.RecordingService
/**
* 1x1 icon widget: Shonar-style mic (navy disc + teal mic + sonar arcs),
* NOT the launcher ping-dot. Icon stays a mic; only the badge dot changes:
* gone = idle, red = recording, amber = paused. All transport controls live
* in the notification (pause / stop / disregard / save-checkmark) per spec.
*
* Tap: idle -> START. Active/paused -> open app (never one-tap-discard).
*/
class ShonarRecordWidget : AppWidgetProvider() {
override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
val s = RecordingService.snapshot.value
ids.forEach { mgr.updateAppWidget(it, views(ctx, s.phase)) }
}
override fun onReceive(ctx: Context, intent: Intent) {
super.onReceive(ctx, intent)
if (intent.action == ACTION_WIDGET_RECORD) {
if (!RecordingPermissionHelper.hasRecordAudio(ctx)) {
val open = Intent(ctx, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setAction(RecordingService.ACTION_START)
ctx.startActivity(open)
} else {
RecordingService.command(ctx, RecordingService.ACTION_START)
}
ShonarWidgetUpdater.refresh(ctx)
}
}
companion object {
const val ACTION_WIDGET_RECORD = "com.shonar.widget.RECORD_TAP"
fun views(
ctx: Context,
phase: com.shonar.recording.RecordingSnapshot.Phase,
): RemoteViews {
val v = RemoteViews(ctx.packageName, R.layout.shonar_widget)
v.setImageViewResource(R.id.widget_icon, R.drawable.ic_widget_mic)
when (phase) {
com.shonar.recording.RecordingSnapshot.Phase.RECORDING -> {
v.setImageViewResource(R.id.widget_badge, R.drawable.ic_widget_badge_rec)
v.setViewVisibility(R.id.widget_badge, View.VISIBLE)
v.setContentDescription(
R.id.widget_button, ctx.getString(R.string.widget_recording),
)
}
com.shonar.recording.RecordingSnapshot.Phase.PAUSED -> {
v.setImageViewResource(R.id.widget_badge, R.drawable.ic_widget_badge_paused)
v.setViewVisibility(R.id.widget_badge, View.VISIBLE)
v.setContentDescription(
R.id.widget_button, ctx.getString(R.string.widget_paused),
)
}
else -> {
v.setViewVisibility(R.id.widget_badge, View.GONE)
v.setContentDescription(
R.id.widget_button, ctx.getString(R.string.widget_record),
)
}
}
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
PendingIntent.getBroadcast(
ctx, 50,
Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
} else {
PendingIntent.getActivity(
ctx, 51,
Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
v.setOnClickPendingIntent(R.id.widget_button, tap)
return v
}
}
}
/** Push state to every widget instance. Safe after death/reboot (IDLE). */
object ShonarWidgetUpdater {
fun refresh(ctx: Context) {
val mgr = AppWidgetManager.getInstance(ctx)
val me = ComponentName(ctx, ShonarRecordWidget::class.java)
val ids = try {
mgr.getAppWidgetIds(me)
} catch (_: Exception) {
return
}
if (ids.isEmpty()) return
val s = RecordingService.snapshot.value
val views = ShonarRecordWidget.views(ctx, s.phase)
runCatching { mgr.updateAppWidget(me, views) }
if (Build.VERSION.SDK_INT >= 31) {
// requestPinAppWidget only on user action — no-op here.
}
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#2A9D94"
android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round"
android:pathData="M5,12.5l4.5,4.5L19,7.5" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#00000000" android:strokeColor="#10141A"
android:strokeWidth="2.4" android:strokeLineCap="round"
android:pathData="M6,6l12,12M18,6L6,18" />
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF"
android:pathData="M8,5h3.2v14H8zM12.8,5H16v14h-3.2z" />
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Notification + widget mic icon. Replace with Shonar logo vector. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#4FD1C5">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,15a3,3 0,0 0,3,-3L15,6a3,3 0,0 0,-6,0v6a3,3 0,0 0,3,3zM17.3,12a5.3,5.3 0,0 1,-10.6,0L5,12a7,7 0,0 0,6,6.9L11,21h2v-2.1a7,7 0,0 0,6,-6.9z" />
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Active-recording dot for the widget. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FF5252"
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
<path android:fillColor="#FFFFFF"
android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" />
</vector>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Paused badge dot (amber). Same placement as red recording dot. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="12dp"
android:viewportWidth="12"
android:viewportHeight="12">
<path android:fillColor="#FFB300"
android:pathData="M6,6m-5.2,0a5.2,5.2 0,1 1,10.4 0a5.2,5.2 0,1 1,-10.4 0" />
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF" android:strokeWidth="1"
android:pathData="M6,6m-5.2,0a5.2,5.2 0,1 1,10.4 0a5.2,5.2 0,1 1,-10.4 0" />
</vector>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Recording badge dot (red). 12dp overlay, top-end of icon. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="12dp"
android:viewportWidth="12"
android:viewportHeight="12">
<path android:fillColor="#FF5252"
android:pathData="M6,6m-5.2,0a5.2,5.2 0,1 1,10.4 0a5.2,5.2 0,1 1,-10.4 0" />
<path android:fillColor="#0B1220" android:fillAlpha="0.25"
android:pathData="M6,6m-5.2,0a5.2,5.2 0,1 1,10.4 0a5.2,5.2 0,1 1,-10.4 0"
android:strokeColor="#FFFFFF" android:strokeWidth="1" />
</vector>

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icon-size widget mic: Shonar style (navy disc + teal mic + sonar arcs).
Not identical to launcher (launcher = ping dot; this = mic). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- navy disc -->
<path android:fillColor="#0B1220"
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
<!-- sonar arcs, same teal family as launcher -->
<path android:fillColor="#00000000" android:strokeColor="#2A9D94"
android:strokeWidth="2" android:strokeLineCap="round"
android:pathData="M24,12 A12,12 0 0,1 36,24" />
<path android:fillColor="#00000000" android:strokeColor="#4FD1C5"
android:strokeWidth="2" android:strokeLineCap="round"
android:pathData="M24,36 A12,12 0 0,1 12,24" />
<!-- mic capsule -->
<path android:fillColor="#4FD1C5"
android:pathData="M24,11c-2.8,0 -5,2.2 -5,5v7c0,2.8 2.2,5 5,5s5,-2.2 5,-5v-7c0,-2.8 -2.2,-5 -5,-5z" />
<!-- mic cradle + stem -->
<path android:fillColor="#00000000" android:strokeColor="#4FD1C5"
android:strokeWidth="2.2" android:strokeLineCap="round"
android:pathData="M17.5,23.5a6.5,6.5 0,0 0,13 0" />
<path android:fillColor="#00000000" android:strokeColor="#4FD1C5"
android:strokeWidth="2.2" android:strokeLineCap="round"
android:pathData="M24,30v4.5M21,37.5h6" />
</vector>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Outlined pill for X / check (screenshot style, Shonar-tinted). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="24dp" />
<solid android:color="#E8EDF2" />
<stroke android:width="1dp" android:color="#B9C4D0" />
</shape>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Solid Shonar-navy pill for pause (matches screenshot's filled middle button). -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="24dp" />
<solid android:color="#0B1220" />
</shape>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Discard-confirm card: "Delete current recording? No / Yes".
Matches reference app; Shonar mic disc retained for identity. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="12dp">
<ImageView
android:id="@+id/notif_icon"
android:layout_width="44dp"
android:layout_height="44dp"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Shonar"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Delete current recording?"
android:textSize="14sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/notif_btn_no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="No"
android:textColor="#2A9D94"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_btn_yes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:padding="8dp"
android:text="Yes"
android:textColor="#2A9D94"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shonar media-card: mic | titles | timer, then X / pause / check pills.
RemoteViews-safe views only. System controls the card background;
Shonar flavor comes from the navy mic disc + navy pause pill + teal check. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/notif_icon"
android:layout_width="44dp"
android:layout_height="44dp"
android:contentDescription="@string/widget_record"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/notif_recording"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Shonar"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/notif_timer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="22sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/notif_btn_discard"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Discard"
android:scaleType="center"
android:src="@drawable/ic_notif_close" />
<ImageButton
android:id="@+id/notif_btn_pause"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill_accent"
android:contentDescription="Pause"
android:scaleType="center"
android:src="@drawable/ic_notif_pause" />
<ImageButton
android:id="@+id/notif_btn_save"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/notif_pill"
android:contentDescription="Save"
android:scaleType="center"
android:src="@drawable/ic_notif_check" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Collapsed row: mic | titles | timer. No buttons (expand for pills). -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:padding="10dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/notif_title_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/notif_recording"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/notif_subtitle_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="Shonar"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/notif_timer_small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:singleLine="true"
android:text="00:00"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 1x1 icon widget: mic only + badge-dot overlay. No text (no room). -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:padding="4dp">
<ImageView
android:id="@+id/widget_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/widget_record"
android:scaleType="fitCenter"
android:src="@drawable/ic_widget_mic" />
<ImageView
android:id="@+id/widget_badge"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_gravity="top|end"
android:contentDescription="@null"
android:src="@drawable/ic_widget_badge_rec"
android:visibility="gone" />
</FrameLayout>

View file

@ -1,3 +1,10 @@
<resources> <resources>
<color name="ic_launcher_background">#0B1220</color> <color name="ic_launcher_background">#0B1220</color>
<!-- Widget branding: replace with Shonar's final palette.
Widget bg must stay opaque-ish for launcher readability. -->
<color name="shonar_widget_bg">#E6121C2E</color>
<color name="shonar_accent">#4FD1C5</color>
<color name="shonar_widget_text">#E6EDF3</color>
<color name="shonar_widget_sub">#9FB3C8</color>
<color name="shonar_rec_red">#FF5252</color>
</resources> </resources>

View file

@ -1,3 +1,13 @@
<resources> <resources>
<string name="app_name">SHONAR</string> <string name="app_name">SHONAR</string>
<!-- Recording notification -->
<string name="notif_channel_recording">Recording</string>
<string name="notif_channel_recording_desc">Shonar recording controls. Kept silent so takes stay clean.</string>
<string name="notif_recording">Shonar is recording</string>
<string name="notif_paused">Shonar recording paused</string>
<!-- Widget -->
<string name="widget_record">Record</string>
<string name="widget_recording">Recording…</string>
<string name="widget_paused">Paused</string>
<string name="widget_desc">Shonar recording widget</string>
</resources> </resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:targetCellWidth="1"
android:targetCellHeight="1"
android:maxResizeWidth="40dp"
android:maxResizeHeight="40dp"
android:updatePeriodMillis="0"
android:initialLayout="@layout/shonar_widget"
android:previewLayout="@layout/shonar_widget"
android:description="@string/widget_desc"
android:widgetCategory="home_screen"
android:resizeMode="none" />

View file

@ -857,10 +857,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
.orEmpty() .orEmpty()
d0.jobs = jobs d0.jobs = jobs
jobLabel(jobs)?.let { d0.busy = it } jobLabel(jobs)?.let { d0.busy = it }
// Mirror live progress onto the library row too — otherwise
// reprocess looks like "nothing happened" after leaving the
// detail screen (the pump path already does this via setLive).
setLive(d0.file.name, jobProgress(jobs))
rescanStatuses()
_detail.value = d0.copy() _detail.value = d0.copy()
val st = jobs.firstOrNull { it.jobType == job }?.status val st = jobs.firstOrNull { it.jobType == job }?.status
if (st == null || st in TERMINAL) break if (st == null || st in TERMINAL) break
} }
setLive(d0.file.name, null)
rescanStatuses()
val failed = d0.jobs.firstOrNull { it.jobType == job && it.status == "failed" } val failed = d0.jobs.firstOrNull { it.jobType == job && it.status == "failed" }
if (failed != null) { if (failed != null) {
d0.busy = null d0.busy = null