Desktop: reprocess path mirrors live progress onto library rows
This commit is contained in:
parent
f73f12056f
commit
e3c4c06d23
29 changed files with 1297 additions and 161 deletions
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
android/app/src/main/java/com/shonar/widget/ShonarWidget.kt
Normal file
118
android/app/src/main/java/com/shonar/widget/ShonarWidget.kt
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
android/app/src/main/res/drawable/ic_notif_check.xml
Normal file
8
android/app/src/main/res/drawable/ic_notif_check.xml
Normal 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>
|
||||||
8
android/app/src/main/res/drawable/ic_notif_close.xml
Normal file
8
android/app/src/main/res/drawable/ic_notif_close.xml
Normal 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>
|
||||||
7
android/app/src/main/res/drawable/ic_notif_pause.xml
Normal file
7
android/app/src/main/res/drawable/ic_notif_pause.xml
Normal 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>
|
||||||
7
android/app/src/main/res/drawable/ic_notif_play.xml
Normal file
7
android/app/src/main/res/drawable/ic_notif_play.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/ic_stat_mic.xml
Normal file
12
android/app/src/main/res/drawable/ic_stat_mic.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/ic_stat_rec_dot.xml
Normal file
12
android/app/src/main/res/drawable/ic_stat_rec_dot.xml
Normal 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>
|
||||||
13
android/app/src/main/res/drawable/ic_widget_badge_paused.xml
Normal file
13
android/app/src/main/res/drawable/ic_widget_badge_paused.xml
Normal 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>
|
||||||
13
android/app/src/main/res/drawable/ic_widget_badge_rec.xml
Normal file
13
android/app/src/main/res/drawable/ic_widget_badge_rec.xml
Normal 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>
|
||||||
29
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal file
29
android/app/src/main/res/drawable/ic_widget_mic.xml
Normal 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>
|
||||||
8
android/app/src/main/res/drawable/notif_pill.xml
Normal file
8
android/app/src/main/res/drawable/notif_pill.xml
Normal 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>
|
||||||
7
android/app/src/main/res/drawable/notif_pill_accent.xml
Normal file
7
android/app/src/main/res/drawable/notif_pill_accent.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
99
android/app/src/main/res/layout/notification_recording.xml
Normal file
99
android/app/src/main/res/layout/notification_recording.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
26
android/app/src/main/res/layout/shonar_widget.xml
Normal file
26
android/app/src/main/res/layout/shonar_widget.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
14
android/app/src/main/res/xml/shonar_widget_info.xml
Normal file
14
android/app/src/main/res/xml/shonar_widget_info.xml
Normal 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" />
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue