Instant record banner from widget tap; fix ghost notification + main-thread recorder prep
- ensureForeground() posts the FGS banner at the top of onStartCommand on a cold-start START only (was: every action -> raced stopForeground(REMOVE) and re-flipped the discard-confirm card back to the plain card) - MediaRecorder prepare()/start() moved to Dispatchers.IO (StrictMode: disk I/O was on the main thread); start failure reverts UI - discardRecording() tears the banner down synchronously before stopSelf() so a scope cancel in onDestroy can't leave a ghost notification - ensureChannel() memoized + legacy channel cleanup kept (one IPC pass per process instead of per call) - restore migrateRestoreTimes() + runCatching around refreshRoot() in Application.onCreate (dropped accidentally during timing instrumentation) - Home screen: Column+verticalScroll -> LazyColumn with keyed items - Notification layouts: explicit textColor for lockscreen/shade legibility - Widget tap PendingIntent: FLAG_RECEIVER_FOREGROUND for prompt delivery - All ShonarTiming instrumentation probes removed On-device (Pixel 8 Pro): widget tap -> banner ~195ms; recording file grows in .in-progress/; Discard->No survives, Discard->Yes removes service, temp, and notification with nothing left live; no StrictMode recorder violation.
This commit is contained in:
parent
1d53d0f00c
commit
645e466b2f
8 changed files with 197 additions and 129 deletions
|
|
@ -4,6 +4,7 @@ import android.app.Application
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.shonar.recording.PlaybackController
|
import com.shonar.recording.PlaybackController
|
||||||
import com.shonar.recording.PlaybackOwner
|
import com.shonar.recording.PlaybackOwner
|
||||||
|
import com.shonar.recording.RecordingNotificationHelper
|
||||||
import com.shonar.recording.RecordingRepository
|
import com.shonar.recording.RecordingRepository
|
||||||
import com.shonar.recording.ShonarDatabase
|
import com.shonar.recording.ShonarDatabase
|
||||||
import com.shonar.settings.DataStoreSettingsStore
|
import com.shonar.settings.DataStoreSettingsStore
|
||||||
|
|
@ -69,6 +70,10 @@ class ShonarApplication : Application(), PlaybackOwner {
|
||||||
.detectCustomSlowCalls().penaltyLog().build()
|
.detectCustomSlowCalls().penaltyLog().build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
appScope.launch {
|
||||||
|
RecordingNotificationHelper.ensureChannel(this@ShonarApplication)
|
||||||
|
database
|
||||||
|
}
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
runCatching { recordingRepository.refreshRoot() }
|
runCatching { recordingRepository.refreshRoot() }
|
||||||
runCatching { recordingRepository.migrateRestoreTimes() }
|
runCatching { recordingRepository.migrateRestoreTimes() }
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,24 @@ object RecordingNotificationHelper {
|
||||||
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
|
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
|
||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var channelReady = false
|
||||||
|
|
||||||
fun ensureChannel(ctx: Context) {
|
fun ensureChannel(ctx: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (channelReady) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
channelReady = true
|
||||||
|
return
|
||||||
|
}
|
||||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||||
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
|
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
|
||||||
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
|
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
|
||||||
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
|
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
|
||||||
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH ||
|
if (existing == null) {
|
||||||
existing.sound != null
|
|
||||||
) {
|
|
||||||
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),
|
||||||
// HIGH: a heads-up banner on record start is the visible
|
|
||||||
// confirmation that recording began. Ongoing FGS rows stay
|
|
||||||
// top-ranked.
|
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
).apply {
|
).apply {
|
||||||
description = ctx.getString(R.string.notif_channel_recording_desc)
|
description = ctx.getString(R.string.notif_channel_recording_desc)
|
||||||
|
|
@ -65,6 +65,8 @@ object RecordingNotificationHelper {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
channelReady = true
|
||||||
|
}
|
||||||
|
|
||||||
private fun svcIntent(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)
|
||||||
|
|
@ -96,6 +98,7 @@ object RecordingNotificationHelper {
|
||||||
elapsedMs: Long,
|
elapsedMs: Long,
|
||||||
fileName: String = "Shonar",
|
fileName: String = "Shonar",
|
||||||
confirmingDiscard: Boolean = false,
|
confirmingDiscard: Boolean = false,
|
||||||
|
alertOnce: Boolean = true,
|
||||||
): Notification {
|
): Notification {
|
||||||
val openApp = PendingIntent.getActivity(
|
val openApp = PendingIntent.getActivity(
|
||||||
ctx, 10,
|
ctx, 10,
|
||||||
|
|
@ -150,7 +153,7 @@ object RecordingNotificationHelper {
|
||||||
// name in the system header.
|
// name in the system header.
|
||||||
.setContentText("$elapsed • $fileName")
|
.setContentText("$elapsed • $fileName")
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(alertOnce)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
// PRIVATE + publicVersion below: the secure lockscreen shows the
|
// PRIVATE + publicVersion below: the secure lockscreen shows the
|
||||||
// simplified single-icon row instead of the full card.
|
// simplified single-icon row instead of the full card.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground microphone owner. Single-flight: START while active is a
|
* Foreground microphone owner. Single-flight: START while active is a
|
||||||
|
|
@ -69,6 +70,7 @@ class RecordingService : Service() {
|
||||||
private var accumulatedMs = 0L
|
private var accumulatedMs = 0L
|
||||||
private var resumedAtElapsed = 0L
|
private var resumedAtElapsed = 0L
|
||||||
private var ticker: Job? = null
|
private var ticker: Job? = null
|
||||||
|
private var starting = false
|
||||||
|
|
||||||
private lateinit var stateStore: RecordingStateStore
|
private lateinit var stateStore: RecordingStateStore
|
||||||
|
|
||||||
|
|
@ -80,6 +82,14 @@ class RecordingService : Service() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val action = intent?.action
|
||||||
|
// Only a cold-start START posts the banner. Re-posting on every
|
||||||
|
// action raced the stopForeground(REMOVE) in discard/save and left
|
||||||
|
// a ghost "Recording" notification after the service was gone; it
|
||||||
|
// also flickered the discard-confirm card back to the plain card.
|
||||||
|
if (action == ACTION_START && _snapshot.value.phase == RecordingSnapshot.Phase.IDLE && !starting) {
|
||||||
|
ensureForeground()
|
||||||
|
}
|
||||||
scope.launch { recoverIfNeeded() }
|
scope.launch { recoverIfNeeded() }
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startRecording()
|
ACTION_START -> startRecording()
|
||||||
|
|
@ -97,7 +107,7 @@ class RecordingService : Service() {
|
||||||
// ---------- public API used by widget / ViewModel ----------
|
// ---------- public API used by widget / ViewModel ----------
|
||||||
|
|
||||||
private fun startRecording() {
|
private fun startRecording() {
|
||||||
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return // no duplicates
|
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE || starting) 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
|
||||||
) {
|
) {
|
||||||
|
|
@ -106,35 +116,19 @@ class RecordingService : Service() {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
starting = true
|
||||||
val app = application as ShonarApplication
|
val app = application as ShonarApplication
|
||||||
val id = app.recordingRepository.newRecordingId()
|
val id = app.recordingRepository.newRecordingId()
|
||||||
val temp = app.recordingRepository.tempFile(id)
|
|
||||||
runCatching { temp.parentFile?.mkdirs() }
|
|
||||||
|
|
||||||
try {
|
|
||||||
createdAtMs = System.currentTimeMillis()
|
createdAtMs = System.currentTimeMillis()
|
||||||
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()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// prepare() touches the filesystem (and the codec HAL) — off the
|
||||||
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply {
|
// main thread. The FGS banner from ensureForeground() already told
|
||||||
|
// the user recording started; a start() failure reverts the UI.
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val temp = app.recordingRepository.tempFile(id)
|
||||||
|
val ready = try {
|
||||||
|
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this@RecordingService) else 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)
|
||||||
|
|
@ -144,17 +138,21 @@ class RecordingService : Service() {
|
||||||
prepare()
|
prepare()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Mic busy (another app) or HW error: keep temp (0 bytes -> sweep
|
|
||||||
// later), release, go IDLE. Never crash the widget tap.
|
|
||||||
android.util.Log.w("ShonarRec", "mic unavailable", e)
|
android.util.Log.w("ShonarRec", "mic unavailable", e)
|
||||||
recorder?.release()
|
recorder?.release()
|
||||||
recorder = null
|
recorder = null
|
||||||
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
|
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
starting = false
|
||||||
|
if (!ready) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
broadcast()
|
broadcast()
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
currentId = id
|
currentId = id
|
||||||
|
|
@ -175,6 +173,8 @@ class RecordingService : Service() {
|
||||||
}
|
}
|
||||||
startTicker()
|
startTicker()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun pauseRecording() {
|
private fun pauseRecording() {
|
||||||
if (_snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
|
if (_snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
|
||||||
|
|
@ -292,18 +292,52 @@ class RecordingService : Service() {
|
||||||
shutdownRecorder(releaseOnly = false, keepTemp = false)
|
shutdownRecorder(releaseOnly = false, keepTemp = false)
|
||||||
ticker?.cancel()
|
ticker?.cancel()
|
||||||
ticker = null
|
ticker = null
|
||||||
|
starting = false
|
||||||
runCatching { temp?.delete() }
|
runCatching { temp?.delete() }
|
||||||
setPhase(RecordingSnapshot.Phase.IDLE)
|
setPhase(RecordingSnapshot.Phase.IDLE)
|
||||||
|
// Tear the banner down NOW, synchronously: a later scope.cancel()
|
||||||
|
// in onDestroy would otherwise kill the cleanup coroutine before
|
||||||
|
// stopForeground() runs and leave a ghost notification behind.
|
||||||
|
runCatching { stopForeground(STOP_FOREGROUND_REMOVE) }
|
||||||
|
runCatching {
|
||||||
|
getSystemService(android.app.NotificationManager::class.java)
|
||||||
|
.cancel(RecordingNotificationHelper.NOTIFICATION_ID)
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
stateStore.clear()
|
stateStore.clear()
|
||||||
ShonarWidgetUpdater.refresh(applicationContext)
|
ShonarWidgetUpdater.refresh(applicationContext)
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- internals ----------
|
// ---------- internals ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a foreground notification immediately. Called at the top of
|
||||||
|
* onStartCommand so the banner appears instantly — no waiting for
|
||||||
|
* MediaRecorder setup, file I/O, or DB access.
|
||||||
|
*/
|
||||||
|
private fun ensureForeground() {
|
||||||
|
try {
|
||||||
|
val notif = RecordingNotificationHelper.build(
|
||||||
|
this, RecordingSnapshot.Phase.RECORDING, 0L, displayFileName(),
|
||||||
|
alertOnce = false,
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
android.util.Log.w("ShonarRec", "FGS start denied", e)
|
||||||
|
broadcast()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun elapsedMs(): Long = when (_snapshot.value.phase) {
|
private fun elapsedMs(): Long = when (_snapshot.value.phase) {
|
||||||
RecordingSnapshot.Phase.RECORDING ->
|
RecordingSnapshot.Phase.RECORDING ->
|
||||||
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
|
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
|
@ -129,23 +129,26 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(24.dp)
|
.padding(horizontal = 24.dp),
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 24.dp),
|
||||||
) {
|
) {
|
||||||
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
item { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) }
|
||||||
|
item {
|
||||||
Text(
|
Text(
|
||||||
"Oral Notes and Audio Recorder — everything stays on this phone",
|
"Oral Notes and Audio Recorder — everything stays on this phone",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
StorageChip()
|
}
|
||||||
|
item { StorageChip() }
|
||||||
if (storageOk == false) {
|
if (storageOk == false) {
|
||||||
|
item {
|
||||||
val ctx = context
|
val ctx = context
|
||||||
StorageAccessCard(onGrant = {
|
StorageAccessCard(onGrant = {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -158,10 +161,14 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
}
|
||||||
|
item { Spacer(Modifier.height(8.dp)) }
|
||||||
|
item {
|
||||||
QuickCard("Settings", "Appearance, custom settings",
|
QuickCard("Settings", "Appearance, custom settings",
|
||||||
Icons.Filled.Settings, onOpenSettings)
|
Icons.Filled.Settings, onOpenSettings)
|
||||||
|
}
|
||||||
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||||
|
item {
|
||||||
RecordingStatusCard(
|
RecordingStatusCard(
|
||||||
state = recorderState,
|
state = recorderState,
|
||||||
onPause = vm::pause,
|
onPause = vm::pause,
|
||||||
|
|
@ -170,15 +177,23 @@ fun HomeScreen(
|
||||||
onDiscard = vm::discard,
|
onDiscard = vm::discard,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (recordings.isNotEmpty()) {
|
|
||||||
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.align(Alignment.Start))
|
|
||||||
notice?.let {
|
|
||||||
Text(it, color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
modifier = Modifier.align(Alignment.Start))
|
|
||||||
}
|
}
|
||||||
recordings.forEach { recording ->
|
if (recordings.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
notice?.let { msg ->
|
||||||
|
item {
|
||||||
|
Text(msg, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = recordings,
|
||||||
|
key = { it.id },
|
||||||
|
) { recording ->
|
||||||
RecordingRow(
|
RecordingRow(
|
||||||
recording = recording,
|
recording = recording,
|
||||||
playing = playingId == recording.id,
|
playing = playingId == recording.id,
|
||||||
|
|
@ -189,6 +204,7 @@ fun HomeScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
item {
|
||||||
Text(
|
Text(
|
||||||
"Your recordings will appear here and stay on this device.",
|
"Your recordings will appear here and stay on this device.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|
@ -197,6 +213,7 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (consentSeen == false) {
|
if (consentSeen == false) {
|
||||||
ConsentDialog(onAcknowledge = {
|
ConsentDialog(onAcknowledge = {
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,8 @@ class ShonarRecordWidget : AppWidgetProvider() {
|
||||||
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||||
PendingIntent.getBroadcast(
|
PendingIntent.getBroadcast(
|
||||||
ctx, 50,
|
ctx, 50,
|
||||||
Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD),
|
Intent(ctx, ShonarRecordWidget::class.java).setAction(ACTION_WIDGET_RECORD)
|
||||||
|
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Shonar"
|
android:text="Shonar"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Delete current recording?"
|
android:text="Delete current recording?"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="Recording"
|
android:text="Recording"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="Shonar"
|
android:text="Shonar"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="14sp" />
|
android:textSize="14sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="00:00"
|
android:text="00:00"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="28sp"
|
android:textSize="28sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="Recording"
|
android:text="Recording"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="Shonar"
|
android:text="Shonar"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
@ -53,6 +55,7 @@
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
android:singleLine="true"
|
android:singleLine="true"
|
||||||
android:text="00:00"
|
android:text="00:00"
|
||||||
|
android:textColor="@color/shonar_widget_text"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue