Commit d67ce3d2c2a599ea7759a4b36be095994d5e1ef2

Authored by yibo
1 parent bb2506da

feat(lifelog-recorder): v0.2 — Opus 编码 + 自启动 + 电池白名单

让 v1 真能脱离开发环境 24/7 跑的最后三公里:

- SegmentWriter 改 Opus(MediaCodec audio/opus encoder + MediaMuxer OGG)
  ~17× 压缩,~2 KB/s 实测(vs WAV 32 KB/s)
  存储预算:8h 语音/天 → ~57 MB/天,~1.7 GB/月(vs WAV 51 GB/月)
  ffprobe 验证 codec_name=opus, channels=1, OggS magic OK

- BootReceiver 监听 BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
  QUICKBOOT_POWERON,try-catch 兜底 Android 14+ FG service 限制

- MainActivity 加电池豁免行:dumpsys deviceidle whitelist 实时检测,
  未豁免时给「申请豁免」按钮,触发系统 RequestIgnoreBatteryOptimizations
  对话框;ON_RESUME 生命周期观察让从设置页回来 UI 自动刷新

- AndroidManifest:
  + REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 权限
  + BootReceiver 注册(含 3 个 boot 相关 action)

HANDOFF.md §五 更新待办(v0.2 三项移到「已完成」),加存储预算 +
Android 14 BOOT_COMPLETED caveat 段落(Pixel 8 Pro 实测能自动续上,
前提 app 至少开过一次 + RECORD_AUDIO 已授)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
lifelog-recorder/HANDOFF.md
... ... @@ -40,13 +40,14 @@ APK 大小: 62 MB(含 34 MB sherpa-onnx .so + 0.6 MB silero VAD + 3 MB samp
40 40 ```
41 41 app/src/main/kotlin/com/lemon/lifelog/
42 42 ├── LifelogApp.kt — Application,建 NotificationChannel
43   -├── MainActivity.kt — Compose UI,时间轴 LazyColumn + 测试按钮
  43 +├── MainActivity.kt — Compose UI,时间轴 + 电池豁免 UI + 测试按钮
44 44 ├── service/
45 45 │ ├── RecorderService.kt — 前台服务,pipeline 主体 ←★ 改 ASR 流程改这里
46   -│ └── RecorderState.kt — StateFlow 把服务状态推到 UI
  46 +│ ├── RecorderState.kt — StateFlow 把服务状态推到 UI
  47 +│ └── BootReceiver.kt — BOOT_COMPLETED 自启 (Android 14 有 caveat 见 §七)
47 48 ├── audio/
48 49 │ ├── AudioCapture.kt — AudioRecord wrapper,emits FloatArray frames
49   -│ └── SegmentWriter.kt — 写 16k mono WAV 到 filesDir(待换 Opus)
  50 +│ └── SegmentWriter.kt — Opus encode via MediaCodec + Ogg muxer,~17× 压缩
50 51 ├── asr/
51 52 │ ├── ParaformerEngine.kt — sherpa-onnx paraformer 包装 ←★ 主 ASR
52 53 │ ├── ModelDownloader.kt — 首次启动下模型 + 断点续传
... ... @@ -150,13 +151,30 @@ VAD 参数(在 `RecorderService.runPipeline` 里调):
150 151  
151 152 ## §五 §六 待办(按优先级)
152 153  
153   -1. **Opus 编码**(🔥 必须):WAV 在 24/7 跑下 ~2.8 GB/天。换 `MediaCodec OMX.google.opus.encoder` 12 kbps voice mode,缩到 ~130 MB/天。改 `SegmentWriter.kt`。
154   -2. **自启动 + 电池白名单**(🔥 必须):BOOT_COMPLETED Receiver 拉起 RecorderService;首启弹 REQUEST_IGNORE_BATTERY_OPTIMIZATIONS。否则一重启就死、Doze 一介入就死。
155   -3. **声纹识别 / speaker diarization**(用户当面提过 3 次):sherpa-onnx 已有 `Speaker.kt` + `SpeakerEmbeddingExtractorConfig.kt`。流程:(a) 「这是我」录音 → 建 anchor embedding 存 SQLite;(b) 每段算 embedding 算 cosine 找最近邻;(c) Transcript 加 `speakerHint` 列。需要下 `sherpa-onnx-3dspeaker-zh.onnx`(~38 MB)。
156   -4. **FTS5 中文搜索**:Room + sqlite-jsqlite-with-icu。Schema 加 `TranscriptFts` 虚表用 ICU tokenizer。UI 加搜索框 + 高亮命中段。
157   -5. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。
158   -6. **存储 TTL**:删超过 N 天的 WAV,文字永留。Periodic WorkManager job。
159   -7. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(这次定的 MVP 是「一键暂停就够」,但用户后面可能要细化)。
  154 +✅ **已完成(v0.2,2026-06):Opus 编码 / BootReceiver / 电池白名单**
  155 +
  156 +剩下:
  157 +
  158 +1. **声纹识别 / speaker diarization**(🔥 用户当面提过 3 次):sherpa-onnx 已有 `Speaker.kt` + `SpeakerEmbeddingExtractorConfig.kt`。流程:(a)「这是我」录音 → 建 anchor embedding 存 SQLite;(b) 每段算 embedding 算 cosine 找最近邻;(c) Transcript 加 `speakerHint` 列。需要下 `sherpa-onnx-3dspeaker-zh.onnx`(~38 MB)。
  159 +2. **FTS5 中文搜索**:Room + sqlite-jsqlite-with-icu。Schema 加 `TranscriptFts` 虚表用 ICU tokenizer。UI 加搜索框 + 高亮命中段。
  160 +3. **LLM 纪要 worker**:WorkManager cron。每小时 / 静音 30min 触发,按时间窗 group segments → POST 到 hermes gateway `http://127.0.0.1:8642/v1/chat/completions`(需要先把 hermes api_server platform 配置起来,见 `~/.hermes/config.yaml`)。
  161 +4. **存储 TTL**:删超过 N 天的 .opus,文字永留。Periodic WorkManager job。
  162 +5. **设置页**:存储用量、模型切换、provider 切换、暂停时段配置(v1 定的 MVP 是「一键暂停就够」,但 v2 可能要细化)。
  163 +6. **段播放**:时间轴每条点开能直接播原音(Opus 系统原生支持,MediaPlayer 就能放)。
  164 +7. **回链 lemonPCRM**:Transcript 提到的人名 / 项目 → GraphStore `MENTIONED_IN` 边。
  165 +
  166 +### 存储预算(v0.2 后)
  167 +
  168 +- Opus 16 kbps mono = ~2 KB/s
  169 +- VAD 后实际有效语音 ≈ 8 h/天
  170 +- → ~57 MB/天,~1.7 GB/月
  171 +- 转写文字(UTF-8 中文)≈ 30 字/秒 × 8h × 3600 ≈ 不到 1 MB/月
  172 +
  173 +### Android 14+ BOOT_COMPLETED FG service caveat
  174 +
  175 +`RecorderService` 的 `foregroundServiceType="microphone"` 是受限 FG type。从 BOOT_COMPLETED 启动**不保证总是成功**——Android 14+ 的 `ForegroundServiceStartNotAllowedException` 可能扔出来(特别是首次重启、还没用过 app 的情况)。`BootReceiver.onReceive` 已 try/catch 兜底,失败时 log + no-op,用户开 app 手动点开始就能恢复。
  176 +
  177 +实测 Pixel 8 Pro Android 16 BP2A 上**重启后能自动续上**(前提:app 已经至少打开过一次 + RECORD_AUDIO 已授)。
160 178  
161 179 ---
162 180  
... ...
lifelog-recorder/app/src/main/AndroidManifest.xml
... ... @@ -8,6 +8,7 @@
8 8 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
9 9 <uses-permission android:name="android.permission.WAKE_LOCK" />
10 10 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
  11 + <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
11 12 <uses-permission android:name="android.permission.INTERNET" />
12 13 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
13 14  
... ... @@ -34,5 +35,16 @@
34 35 android:name=".service.RecorderService"
35 36 android:exported="false"
36 37 android:foregroundServiceType="microphone" />
  38 +
  39 + <receiver
  40 + android:name=".service.BootReceiver"
  41 + android:exported="true"
  42 + android:enabled="true">
  43 + <intent-filter>
  44 + <action android:name="android.intent.action.BOOT_COMPLETED" />
  45 + <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
  46 + <action android:name="android.intent.action.QUICKBOOT_POWERON" />
  47 + </intent-filter>
  48 + </receiver>
37 49 </application>
38 50 </manifest>
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
1 1 package com.lemon.lifelog
2 2  
3 3 import android.Manifest
  4 +import android.annotation.SuppressLint
  5 +import android.content.Context
4 6 import android.content.Intent
  7 +import android.net.Uri
5 8 import android.os.Build
6 9 import android.os.Bundle
  10 +import android.os.PowerManager
  11 +import android.provider.Settings
7 12 import androidx.activity.ComponentActivity
8 13 import androidx.activity.compose.rememberLauncherForActivityResult
9 14 import androidx.activity.compose.setContent
... ... @@ -28,11 +33,15 @@ import androidx.compose.material3.Surface
28 33 import androidx.compose.material3.Text
29 34 import androidx.compose.runtime.Composable
30 35 import androidx.compose.runtime.collectAsState
  36 +import androidx.compose.runtime.DisposableEffect
31 37 import androidx.compose.runtime.getValue
32 38 import androidx.compose.runtime.mutableStateOf
33 39 import androidx.compose.runtime.remember
34 40 import androidx.compose.runtime.rememberCoroutineScope
35 41 import androidx.compose.runtime.setValue
  42 +import androidx.lifecycle.Lifecycle
  43 +import androidx.lifecycle.LifecycleEventObserver
  44 +import androidx.lifecycle.compose.LocalLifecycleOwner
36 45 import androidx.compose.ui.Modifier
37 46 import androidx.compose.ui.unit.dp
38 47 import androidx.core.content.ContextCompat
... ... @@ -85,6 +94,20 @@ private fun MainScreen() {
85 94 val repo = remember(activity) { Repository(activity) }
86 95 val timeline by repo.segments.observeTimeline(200).collectAsState(initial = emptyList())
87 96  
  97 + // Battery optimisation exemption — recompute whenever activity resumes
  98 + // (user may have just returned from the system settings dialog).
  99 + var batteryExempt by remember { mutableStateOf(isBatteryOptExempt(activity)) }
  100 + val lifecycleOwner = LocalLifecycleOwner.current
  101 + DisposableEffect(lifecycleOwner) {
  102 + val observer = LifecycleEventObserver { _, event ->
  103 + if (event == Lifecycle.Event.ON_RESUME) {
  104 + batteryExempt = isBatteryOptExempt(activity)
  105 + }
  106 + }
  107 + lifecycleOwner.lifecycle.addObserver(observer)
  108 + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
  109 + }
  110 +
88 111 val permissionLauncher = rememberLauncherForActivityResult(
89 112 ActivityResultContracts.RequestMultiplePermissions()
90 113 ) { results ->
... ... @@ -117,6 +140,21 @@ private fun MainScreen() {
117 140 }
118 141 }) { Text(if (running) "停止录音" else "开始录音") }
119 142  
  143 + // Battery optimisation exemption row.
  144 + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
  145 + horizontalArrangement = Arrangement.spacedBy(8.dp)) {
  146 + Text(
  147 + if (batteryExempt) "✓ 已加入电池白名单"
  148 + else "⚠ 未加入电池白名单(Doze 会关掉录音)",
  149 + style = MaterialTheme.typography.bodySmall,
  150 + )
  151 + if (!batteryExempt) {
  152 + Button(onClick = { requestBatteryOptExemption(activity) }) {
  153 + Text("申请豁免")
  154 + }
  155 + }
  156 + }
  157 +
120 158 if (running && lastAudioMs > 0) {
121 159 val rtSpeed = if (lastWallMs > 0) lastAudioMs.toDouble() / lastWallMs else 0.0
122 160 Text(
... ... @@ -171,6 +209,20 @@ private fun MainScreen() {
171 209 }
172 210 }
173 211  
  212 +private fun isBatteryOptExempt(ctx: Context): Boolean {
  213 + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
  214 + return pm.isIgnoringBatteryOptimizations(ctx.packageName)
  215 +}
  216 +
  217 +@SuppressLint("BatteryLife")
  218 +private fun requestBatteryOptExemption(ctx: Context) {
  219 + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
  220 + data = Uri.parse("package:${ctx.packageName}")
  221 + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
  222 + }
  223 + runCatching { ctx.startActivity(intent) }
  224 +}
  225 +
174 226 private val TIMELINE_FMT = SimpleDateFormat("HH:mm:ss", Locale.US)
175 227  
176 228 @Composable
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/audio/SegmentWriter.kt
1 1 package com.lemon.lifelog.audio
2 2  
  3 +import android.media.MediaCodec
  4 +import android.media.MediaCodecInfo
  5 +import android.media.MediaFormat
  6 +import android.media.MediaMuxer
  7 +import android.util.Log
3 8 import java.io.File
4   -import java.io.RandomAccessFile
5 9 import java.nio.ByteBuffer
6 10 import java.nio.ByteOrder
7 11 import java.text.SimpleDateFormat
... ... @@ -9,13 +13,23 @@ import java.util.Date
9 13 import java.util.Locale
10 14  
11 15 /**
12   - * Writes a finished VAD segment to disk as 16-bit PCM WAV.
13   - * File layout: `<root>/YYYY-MM-DD/HH-MM-SS-NNNN.wav`
  16 + * Writes a finished VAD segment to disk as Opus-in-Ogg (.opus, ~16 kbps voice mode).
  17 + * File layout: `<root>/YYYY-MM-DD/HH-MM-SS-MS.opus`
14 18 *
15   - * (Opus encoding via MediaCodec is a future optimization; raw WAV at 16k mono is
16   - * ~32 kB/s = ~1 MB/min, still bounded for 24/7 use after VAD trimming.)
  19 + * Bitrate budget: 16 kHz mono PCM16 = 32 kB/s WAV → ~2 kB/s Opus = ~16× compression.
  20 + * Per 24/7 use (≈8 h voice/day after VAD): ~57 MB/day, ~1.7 GB/month.
  21 + *
  22 + * Implementation: MediaCodec (audio/opus encoder) + MediaMuxer (OGG container).
  23 + * Each segment uses its own encoder + muxer pair — startup overhead is small (a few ms)
  24 + * and avoids cross-segment state bugs.
17 25 */
18 26 class SegmentWriter(private val root: File) {
  27 + companion object {
  28 + private const val TAG = "SegmentWriter"
  29 + private const val MIME = MediaFormat.MIMETYPE_AUDIO_OPUS // "audio/opus"
  30 + private const val BITRATE = 16_000 // ~16 kbps, plenty for speech
  31 + }
  32 +
19 33 private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
20 34 private val timeFmt = SimpleDateFormat("HH-mm-ss-SSS", Locale.US)
21 35  
... ... @@ -23,42 +37,116 @@ class SegmentWriter(private val root: File) {
23 37 root.mkdirs()
24 38 }
25 39  
26   - /** Writes samples to a new file, returns absolute path. */
  40 + /** Writes samples to a new .opus file, returns absolute path. */
27 41 fun write(samplesFloat: FloatArray, ts: Long, sampleRate: Int = 16000): String {
28 42 val date = Date(ts)
29 43 val dir = File(root, dayFmt.format(date)).apply { mkdirs() }
30   - val file = File(dir, "${timeFmt.format(date)}.wav")
31   - writeWav(file, samplesFloat, sampleRate)
  44 + val file = File(dir, "${timeFmt.format(date)}.opus")
  45 + encodeToOgg(samplesFloat, file, sampleRate)
32 46 return file.absolutePath
33 47 }
34 48  
35   - private fun writeWav(file: File, samples: FloatArray, sampleRate: Int) {
36   - val pcmBytes = samples.size * 2
37   - RandomAccessFile(file, "rw").use { raf ->
38   - raf.setLength(0)
39   - // RIFF header
40   - raf.writeBytes("RIFF")
41   - raf.writeInt(Integer.reverseBytes(36 + pcmBytes))
42   - raf.writeBytes("WAVE")
43   - // fmt chunk
44   - raf.writeBytes("fmt ")
45   - raf.writeInt(Integer.reverseBytes(16)) // chunk size
46   - raf.writeShort(java.lang.Short.reverseBytes(1).toInt()) // PCM
47   - raf.writeShort(java.lang.Short.reverseBytes(1).toInt()) // channels
48   - raf.writeInt(Integer.reverseBytes(sampleRate))
49   - raf.writeInt(Integer.reverseBytes(sampleRate * 2)) // byte rate
50   - raf.writeShort(java.lang.Short.reverseBytes(2).toInt()) // block align
51   - raf.writeShort(java.lang.Short.reverseBytes(16).toInt())// bits per sample
52   - // data chunk
53   - raf.writeBytes("data")
54   - raf.writeInt(Integer.reverseBytes(pcmBytes))
55   - // samples: scale float [-1,1] → int16 LE
56   - val buf = ByteBuffer.allocate(pcmBytes).order(ByteOrder.LITTLE_ENDIAN)
  49 + private fun encodeToOgg(samples: FloatArray, outFile: File, sampleRate: Int) {
  50 + if (samples.isEmpty()) return
  51 +
  52 + // 1. PCM16 LE buffer (mono).
  53 + val pcm = ByteArray(samples.size * 2)
  54 + ByteBuffer.wrap(pcm).order(ByteOrder.LITTLE_ENDIAN).apply {
57 55 for (s in samples) {
58   - val clamped = (s * 32767f).coerceIn(-32768f, 32767f)
59   - buf.putShort(clamped.toInt().toShort())
  56 + putShort((s.coerceIn(-1f, 1f) * 32767f).toInt().toShort())
  57 + }
  58 + }
  59 +
  60 + // 2. Set up encoder.
  61 + val codec = MediaCodec.createEncoderByType(MIME)
  62 + val format = MediaFormat.createAudioFormat(MIME, sampleRate, 1).apply {
  63 + setInteger(MediaFormat.KEY_BIT_RATE, BITRATE)
  64 + // Default frame size for Opus encoder is sane; explicit max-input-size is a hint.
  65 + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
  66 + }
  67 + try {
  68 + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
  69 + } catch (t: Throwable) {
  70 + Log.e(TAG, "configure failed", t)
  71 + codec.release()
  72 + throw t
  73 + }
  74 + codec.start()
  75 +
  76 + // 3. Muxer (OGG container).
  77 + val muxer = MediaMuxer(outFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
  78 + var trackIndex = -1
  79 + var muxerStarted = false
  80 +
  81 + val info = MediaCodec.BufferInfo()
  82 + var bytesFed = 0
  83 + var ptUs = 0L
  84 + var inputDone = false
  85 + var outputDone = false
  86 +
  87 + try {
  88 + while (!outputDone) {
  89 + // Feed PCM.
  90 + if (!inputDone) {
  91 + val idx = codec.dequeueInputBuffer(50_000)
  92 + if (idx >= 0) {
  93 + val buf = codec.getInputBuffer(idx)!!
  94 + buf.clear()
  95 + val cap = buf.capacity()
  96 + if (bytesFed < pcm.size) {
  97 + val n = minOf(cap, pcm.size - bytesFed)
  98 + buf.put(pcm, bytesFed, n)
  99 + codec.queueInputBuffer(idx, 0, n, ptUs, 0)
  100 + // 2 bytes/sample mono → frames = n/2 → us = frames * 1e6 / sr
  101 + ptUs += n.toLong() / 2L * 1_000_000L / sampleRate
  102 + bytesFed += n
  103 + } else {
  104 + codec.queueInputBuffer(
  105 + idx, 0, 0, ptUs,
  106 + MediaCodec.BUFFER_FLAG_END_OF_STREAM,
  107 + )
  108 + inputDone = true
  109 + }
  110 + }
  111 + }
  112 +
  113 + // Drain encoded output.
  114 + drain@ while (true) {
  115 + val oIdx = codec.dequeueOutputBuffer(info, 50_000)
  116 + when {
  117 + oIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> break@drain
  118 + oIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
  119 + if (!muxerStarted) {
  120 + trackIndex = muxer.addTrack(codec.outputFormat)
  121 + muxer.start()
  122 + muxerStarted = true
  123 + }
  124 + }
  125 + oIdx >= 0 -> {
  126 + val out = codec.getOutputBuffer(oIdx)!!
  127 + if (info.size > 0 && muxerStarted &&
  128 + (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
  129 + ) {
  130 + out.position(info.offset)
  131 + out.limit(info.offset + info.size)
  132 + muxer.writeSampleData(trackIndex, out, info)
  133 + }
  134 + codec.releaseOutputBuffer(oIdx, false)
  135 + if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
  136 + outputDone = true
  137 + break@drain
  138 + }
  139 + }
  140 + }
  141 + }
  142 + }
  143 + } finally {
  144 + runCatching { codec.stop() }
  145 + codec.release()
  146 + if (muxerStarted) {
  147 + runCatching { muxer.stop() }
60 148 }
61   - raf.write(buf.array())
  149 + muxer.release()
62 150 }
63 151 }
64 152 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BootReceiver.kt 0 → 100644
  1 +package com.lemon.lifelog.service
  2 +
  3 +import android.content.BroadcastReceiver
  4 +import android.content.Context
  5 +import android.content.Intent
  6 +import android.util.Log
  7 +import androidx.core.content.ContextCompat
  8 +
  9 +/**
  10 + * Resumes recording on device boot.
  11 + *
  12 + * Android 14+ note: starting a `foregroundServiceType=microphone` service from a
  13 + * BOOT_COMPLETED broadcast is allowed because RECORD_AUDIO is a "while-in-use" permission
  14 + * that survives across reboots once granted. If the start fails (e.g. user has revoked
  15 + * the runtime permission), Android will throw [ForegroundServiceStartNotAllowedException]
  16 + * and we just no-op; the user opens the app and taps "开始录音" to resume.
  17 + */
  18 +class BootReceiver : BroadcastReceiver() {
  19 + override fun onReceive(context: Context, intent: Intent) {
  20 + val action = intent.action ?: return
  21 + if (action != Intent.ACTION_BOOT_COMPLETED &&
  22 + action != Intent.ACTION_LOCKED_BOOT_COMPLETED &&
  23 + action != "android.intent.action.QUICKBOOT_POWERON"
  24 + ) return
  25 +
  26 + Log.i(TAG, "boot action=$action → start RecorderService")
  27 + try {
  28 + ContextCompat.startForegroundService(
  29 + context,
  30 + Intent(context, RecorderService::class.java),
  31 + )
  32 + } catch (t: Throwable) {
  33 + Log.w(TAG, "could not start service from boot", t)
  34 + }
  35 + }
  36 +
  37 + companion object {
  38 + private const val TAG = "BootReceiver"
  39 + }
  40 +}
... ...