app/src/main/kotlin/com/honjimaku/subrep/CaptureService.kt (9393 bytes)
1 package com.honjimaku.subrep 2 3 import android.app.Notification 4 import android.app.NotificationChannel 5 import android.app.NotificationManager 6 import android.app.PendingIntent 7 import android.app.Service 8 import android.content.Context 9 import android.content.Intent 10 import android.content.pm.ServiceInfo 11 import android.graphics.drawable.Icon 12 import android.media.AudioRecord 13 import android.media.projection.MediaProjection 14 import android.media.projection.MediaProjectionManager 15 import android.os.Build 16 import android.os.Handler 17 import android.os.HandlerThread 18 import android.os.IBinder 19 import android.os.Looper 20 import okhttp3.OkHttpClient 21 import java.io.FileDescriptor 22 import java.io.PrintWriter 23 import java.util.concurrent.TimeUnit 24 import kotlin.concurrent.thread 25 26 /** 27 * Captures the sound, turns it into captions with Whisper on the phone, and gives the captions 28 * to the screen, to the relay and to SubRead Overlay. A foreground service, so that it lives 29 * while the user is in the player. 30 */ 31 class CaptureService : Service() { 32 33 private lateinit var store: Store 34 private val http = OkHttpClient.Builder().pingInterval(20, TimeUnit.SECONDS).build() 35 private val main = Handler(Looper.getMainLooper()) 36 private val captions = HandlerThread("captions").apply { start() } 37 private var engine: Engine? = null 38 private var relay: Socket? = null 39 private var overlay: OverlayFeed? = null 40 private var record: AudioRecord? = null 41 private var projection: MediaProjection? = null 42 private var pump: Thread? = null 43 44 override fun onBind(intent: Intent?): IBinder? = null 45 46 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 47 when (intent?.action) { 48 ACTION_START -> if (!Feed.running) start(intent) 49 ACTION_STOP -> stopAll() 50 } 51 return START_NOT_STICKY 52 } 53 54 private fun start(intent: Intent) { 55 store = Store(this) 56 val screen = intent.getBooleanExtra(EXTRA_SCREEN, false) 57 // Android 14 and later accept the projection only from a foreground service of this type. 58 foreground(screen) 59 Feed.start(store.share) 60 61 val cloud = store.cloud 62 val model = Model.byId(store.model) 63 if (!cloud && !model.downloaded(this)) { 64 Feed.fail("Download the ${model.label} model first.") 65 stopAll() 66 return 67 } 68 69 val record = try { 70 if (screen) Capture.ofApps(projectionOf(intent)) else Capture.ofMicrophone() 71 } catch (e: Exception) { 72 Feed.fail("${e.javaClass.simpleName}: ${e.message}") 73 stopAll() 74 return 75 } 76 record.startRecording() 77 if (record.recordingState != AudioRecord.RECORDSTATE_RECORDING) { 78 Feed.fail("The recorder did not start.") 79 stopAll() 80 return 81 } 82 this.record = record 83 84 val lang = store.lang 85 val title = store.title 86 if (store.share) { 87 val secret = store.secret 88 // With "auto", the viewers see the language once Whisper found it. 89 val hello = { Messages.relayHello(secret, if (lang == "auto") "" else lang, title) } 90 relay = Socket(http, "${Room.RELAY}/pub/${store.room}", hello, ::onRelayText) { Feed.relay(it) } 91 .also { it.start() } 92 } 93 if (store.overlay && Overlay.installed(this)) overlay = OverlayFeed(this, Handler(captions.looper)) 94 val open: () -> Recognizer = if (cloud) { 95 { CloudRecognizer(Cloud(http, store), Feed::cloudLeft) } 96 } else { 97 { Whisper.open(model.file(this)) } 98 } 99 engine = Engine( 100 open, lang, 101 onCaption = ::onCaption, 102 onLanguage = { found -> relay?.send(Messages.status(true, found, title)) }, 103 onState = { Feed.engine(it) }, 104 ).also { it.start() } 105 pump = thread(name = "pcm") { pump(record) } 106 } 107 108 private fun projectionOf(intent: Intent): MediaProjection { 109 val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0) 110 @Suppress("DEPRECATION") 111 val data = if (Build.VERSION.SDK_INT >= 33) { 112 intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) 113 } else { 114 intent.getParcelableExtra(EXTRA_RESULT_DATA) 115 } ?: throw IllegalStateException("no consent") 116 val manager = getSystemService(MediaProjectionManager::class.java) 117 val projection = manager.getMediaProjection(code, data) ?: throw IllegalStateException("no projection") 118 // Android 14 and later need a callback before the capture starts. The user can stop the 119 // capture from the status bar; then this service stops too. 120 projection.registerCallback(object : MediaProjection.Callback() { 121 override fun onStop() = stopAll() 122 }, main) 123 this.projection = projection 124 return projection 125 } 126 127 private fun pump(record: AudioRecord) { 128 val frame = ByteArray(Capture.FRAME_BYTES) 129 while (!Thread.currentThread().isInterrupted) { 130 val count = record.read(frame, 0, frame.size, AudioRecord.READ_BLOCKING) 131 if (count < 0) break 132 if (count == 0) continue 133 engine?.feed(frame, count) 134 Feed.level(Pcm.peak(frame, count)) 135 } 136 } 137 138 private fun onCaption(caption: Caption) { 139 engine?.let { Feed.progress(it.speed, it.late) } 140 Feed.caption(caption) 141 relay?.send(Messages.caption(caption)) 142 overlay?.caption(caption) 143 } 144 145 private fun onRelayText(text: String) { 146 Messages.error(text)?.let { Feed.fail("relay: $it") } 147 } 148 149 private fun stopAll() { 150 pump?.interrupt() 151 pump = null 152 record?.let { 153 runCatching { it.stop() } 154 it.release() 155 } 156 record = null 157 projection?.stop() 158 projection = null 159 // The last piece still goes out, so the relay and the overlay stop after it. 160 engine?.stop() 161 engine = null 162 relay?.stop() 163 relay = null 164 overlay?.end() 165 overlay = null 166 Feed.stop() 167 stopForeground(STOP_FOREGROUND_REMOVE) 168 stopSelf() 169 } 170 171 override fun onDestroy() { 172 captions.quitSafely() 173 super.onDestroy() 174 } 175 176 /** For `adb shell dumpsys activity service com.honjimaku.subrep/.CaptureService`: the state, for a bug report. */ 177 override fun dump(fd: FileDescriptor, writer: PrintWriter, args: Array<out String>?) { 178 writer.println("running=${Feed.running} engine=${Feed.engine} speed=${Feed.speed} late=${Feed.late}") 179 writer.println("relay=${Feed.relayOn}/${Feed.relayUp} overlay=${Feed.overlayAnswer} peak=${Feed.peak} error=${Feed.error}") 180 writer.println(Feed.text()) 181 } 182 183 private fun foreground(screen: Boolean) { 184 val manager = getSystemService(NotificationManager::class.java) 185 manager.createNotificationChannel( 186 NotificationChannel(CHANNEL, getString(R.string.notification_channel), NotificationManager.IMPORTANCE_LOW), 187 ) 188 val open = PendingIntent.getActivity( 189 this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE, 190 ) 191 val stop = PendingIntent.getService( 192 this, 1, Intent(this, CaptureService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE, 193 ) 194 val icon = Icon.createWithResource(this, R.drawable.icon) 195 val notification = Notification.Builder(this, CHANNEL) 196 .setSmallIcon(icon) 197 .setContentTitle(getString(R.string.notification_title)) 198 .setContentText(getString(R.string.notification_text)) 199 .setContentIntent(open) 200 .setOngoing(true) 201 .addAction(Notification.Action.Builder(icon, getString(R.string.stop), stop).build()) 202 .build() 203 when { 204 Build.VERSION.SDK_INT >= 30 -> startForeground( 205 NOTIFICATION_ID, notification, 206 if (screen) ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION else ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, 207 ) 208 Build.VERSION.SDK_INT >= 29 -> startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST) 209 else -> startForeground(NOTIFICATION_ID, notification) 210 } 211 } 212 213 companion object { 214 const val ACTION_START = "com.honjimaku.subrep.START" 215 const val ACTION_STOP = "com.honjimaku.subrep.STOP" 216 const val EXTRA_SCREEN = "screen" 217 const val EXTRA_RESULT_CODE = "result_code" 218 const val EXTRA_RESULT_DATA = "result_data" 219 private const val CHANNEL = "capture" 220 private const val NOTIFICATION_ID = 1 221 222 fun start(context: Context, screen: Boolean, resultCode: Int = 0, resultData: Intent? = null) { 223 val intent = Intent(context, CaptureService::class.java) 224 .setAction(ACTION_START) 225 .putExtra(EXTRA_SCREEN, screen) 226 .putExtra(EXTRA_RESULT_CODE, resultCode) 227 .putExtra(EXTRA_RESULT_DATA, resultData) 228 context.startForegroundService(intent) 229 } 230 231 fun stop(context: Context) { 232 context.startService(Intent(context, CaptureService::class.java).setAction(ACTION_STOP)) 233 } 234 } 235 }