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 com.shonar.recording.PlaybackController
|
||||
import com.shonar.recording.PlaybackOwner
|
||||
import com.shonar.recording.RecordingNotificationHelper
|
||||
import com.shonar.recording.RecordingRepository
|
||||
import com.shonar.recording.ShonarDatabase
|
||||
import com.shonar.settings.DataStoreSettingsStore
|
||||
|
|
@ -69,6 +70,10 @@ class ShonarApplication : Application(), PlaybackOwner {
|
|||
.detectCustomSlowCalls().penaltyLog().build()
|
||||
)
|
||||
}
|
||||
appScope.launch {
|
||||
RecordingNotificationHelper.ensureChannel(this@ShonarApplication)
|
||||
database
|
||||
}
|
||||
appScope.launch {
|
||||
runCatching { recordingRepository.refreshRoot() }
|
||||
runCatching { recordingRepository.migrateRestoreTimes() }
|
||||
|
|
|
|||
|
|
@ -39,31 +39,33 @@ object RecordingNotificationHelper {
|
|||
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
|
||||
@Volatile
|
||||
private var channelReady = false
|
||||
|
||||
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)
|
||||
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
|
||||
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
|
||||
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
|
||||
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH ||
|
||||
existing.sound != null
|
||||
) {
|
||||
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
|
||||
if (existing == null) {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
ctx.getString(R.string.notif_channel_recording),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = ctx.getString(R.string.notif_channel_recording_desc)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
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,
|
||||
).apply {
|
||||
description = ctx.getString(R.string.notif_channel_recording_desc)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
},
|
||||
)
|
||||
channelReady = true
|
||||
}
|
||||
|
||||
private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent {
|
||||
|
|
@ -96,6 +98,7 @@ object RecordingNotificationHelper {
|
|||
elapsedMs: Long,
|
||||
fileName: String = "Shonar",
|
||||
confirmingDiscard: Boolean = false,
|
||||
alertOnce: Boolean = true,
|
||||
): Notification {
|
||||
val openApp = PendingIntent.getActivity(
|
||||
ctx, 10,
|
||||
|
|
@ -150,7 +153,7 @@ object RecordingNotificationHelper {
|
|||
// name in the system header.
|
||||
.setContentText("$elapsed • $fileName")
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOnlyAlertOnce(alertOnce)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
// PRIVATE + publicVersion below: the secure lockscreen shows the
|
||||
// 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.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Foreground microphone owner. Single-flight: START while active is a
|
||||
|
|
@ -69,6 +70,7 @@ class RecordingService : Service() {
|
|||
private var accumulatedMs = 0L
|
||||
private var resumedAtElapsed = 0L
|
||||
private var ticker: Job? = null
|
||||
private var starting = false
|
||||
|
||||
private lateinit var stateStore: RecordingStateStore
|
||||
|
||||
|
|
@ -80,6 +82,14 @@ class RecordingService : Service() {
|
|||
}
|
||||
|
||||
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() }
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startRecording()
|
||||
|
|
@ -97,7 +107,7 @@ class RecordingService : Service() {
|
|||
// ---------- public API used by widget / ViewModel ----------
|
||||
|
||||
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) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
|
|
@ -106,74 +116,64 @@ class RecordingService : Service() {
|
|||
stopSelf()
|
||||
return
|
||||
}
|
||||
starting = true
|
||||
val app = application as ShonarApplication
|
||||
val id = app.recordingRepository.newRecordingId()
|
||||
val temp = app.recordingRepository.tempFile(id)
|
||||
runCatching { temp.parentFile?.mkdirs() }
|
||||
|
||||
try {
|
||||
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)
|
||||
createdAtMs = System.currentTimeMillis()
|
||||
|
||||
// prepare() touches the filesystem (and the codec HAL) — off the
|
||||
// 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)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioEncodingBitRate(128_000)
|
||||
setAudioSamplingRate(44_100)
|
||||
setOutputFile(temp.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ShonarRec", "mic unavailable", e)
|
||||
recorder?.release()
|
||||
recorder = null
|
||||
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
|
||||
false
|
||||
}
|
||||
} 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
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
starting = false
|
||||
if (!ready) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
broadcast()
|
||||
stopSelf()
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioEncodingBitRate(128_000)
|
||||
setAudioSamplingRate(44_100)
|
||||
setOutputFile(temp.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
currentId = id
|
||||
currentTemp = temp
|
||||
accumulatedMs = 0L
|
||||
resumedAtElapsed = SystemClock.elapsedRealtime()
|
||||
acquireWake()
|
||||
requestFocus()
|
||||
watchCalls()
|
||||
// startForeground() above already posted this exact card: pushing a
|
||||
// second notify() here hands SystemUI fresh RemoteViews while it is
|
||||
// still inflating the first set, so an instant shade pull shows the
|
||||
// plain system row until inflation restarts and completes. The 1s
|
||||
// ticker covers subsequent updates.
|
||||
setPhase(RecordingSnapshot.Phase.RECORDING, push = false)
|
||||
scope.launch {
|
||||
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
|
||||
}
|
||||
startTicker()
|
||||
}
|
||||
} 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)
|
||||
recorder?.release()
|
||||
recorder = null
|
||||
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
broadcast()
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
currentId = id
|
||||
currentTemp = temp
|
||||
accumulatedMs = 0L
|
||||
resumedAtElapsed = SystemClock.elapsedRealtime()
|
||||
acquireWake()
|
||||
requestFocus()
|
||||
watchCalls()
|
||||
// startForeground() above already posted this exact card: pushing a
|
||||
// second notify() here hands SystemUI fresh RemoteViews while it is
|
||||
// still inflating the first set, so an instant shade pull shows the
|
||||
// plain system row until inflation restarts and completes. The 1s
|
||||
// ticker covers subsequent updates.
|
||||
setPhase(RecordingSnapshot.Phase.RECORDING, push = false)
|
||||
scope.launch {
|
||||
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
|
||||
}
|
||||
startTicker()
|
||||
}
|
||||
|
||||
private fun pauseRecording() {
|
||||
|
|
@ -292,18 +292,52 @@ class RecordingService : Service() {
|
|||
shutdownRecorder(releaseOnly = false, keepTemp = false)
|
||||
ticker?.cancel()
|
||||
ticker = null
|
||||
starting = false
|
||||
runCatching { temp?.delete() }
|
||||
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 {
|
||||
stateStore.clear()
|
||||
ShonarWidgetUpdater.refresh(applicationContext)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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) {
|
||||
RecordingSnapshot.Phase.RECORDING ->
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
|
|
@ -129,56 +129,71 @@ fun HomeScreen(
|
|||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 24.dp),
|
||||
) {
|
||||
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Oral Notes and Audio Recorder — everything stays on this phone",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
StorageChip()
|
||||
if (storageOk == false) {
|
||||
val ctx = context
|
||||
StorageAccessCard(onGrant = {
|
||||
runCatching {
|
||||
ctx.startActivity(
|
||||
android.content.Intent(
|
||||
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
android.net.Uri.parse("package:${ctx.packageName}"),
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
QuickCard("Settings", "Appearance, custom settings",
|
||||
Icons.Filled.Settings, onOpenSettings)
|
||||
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||
RecordingStatusCard(
|
||||
state = recorderState,
|
||||
onPause = vm::pause,
|
||||
onResume = vm::resume,
|
||||
onStop = vm::stop,
|
||||
onDiscard = vm::discard,
|
||||
item { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) }
|
||||
item {
|
||||
Text(
|
||||
"Oral Notes and Audio Recorder — everything stays on this phone",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
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))
|
||||
item { StorageChip() }
|
||||
if (storageOk == false) {
|
||||
item {
|
||||
val ctx = context
|
||||
StorageAccessCard(onGrant = {
|
||||
runCatching {
|
||||
ctx.startActivity(
|
||||
android.content.Intent(
|
||||
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
android.net.Uri.parse("package:${ctx.packageName}"),
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
recordings.forEach { recording ->
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
item {
|
||||
QuickCard("Settings", "Appearance, custom settings",
|
||||
Icons.Filled.Settings, onOpenSettings)
|
||||
}
|
||||
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||
item {
|
||||
RecordingStatusCard(
|
||||
state = recorderState,
|
||||
onPause = vm::pause,
|
||||
onResume = vm::resume,
|
||||
onStop = vm::stop,
|
||||
onDiscard = vm::discard,
|
||||
)
|
||||
}
|
||||
}
|
||||
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(
|
||||
recording = recording,
|
||||
playing = playingId == recording.id,
|
||||
|
|
@ -189,11 +204,13 @@ fun HomeScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"Your recordings will appear here and stay on this device.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
"Your recordings will appear here and stay on this device.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ class ShonarRecordWidget : AppWidgetProvider() {
|
|||
val tap: PendingIntent = if (phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||
PendingIntent.getBroadcast(
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Shonar"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
|
|
@ -33,6 +34,7 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Delete current recording?"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:text="Recording"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:text="Shonar"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
android:layout_marginStart="8dp"
|
||||
android:singleLine="true"
|
||||
android:text="00:00"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:text="Recording"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
android:ellipsize="end"
|
||||
android:singleLine="true"
|
||||
android:text="Shonar"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="12sp" />
|
||||
</LinearLayout>
|
||||
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
android:layout_marginStart="8dp"
|
||||
android:singleLine="true"
|
||||
android:text="00:00"
|
||||
android:textColor="@color/shonar_widget_text"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue