Android 10 (Q) 中的后台服务录像机

Posted

技术标签:

【中文标题】Android 10 (Q) 中的后台服务录像机【英文标题】:Background Service Video Recorder in Android 10 (Q) 【发布时间】:2020-08-28 10:18:35 【问题描述】:

我正在开发一个仅适用于 android 服务的应用程序,无需用户操作。

我想创建一个只使用服务的后台录像机。 我发现了几个项目,但它们太旧了(每个都大约 5 岁),就像这样:https://github.com/pickerweng/CameraRecorder

Android 文档不是很温和。 SurfaceView 似乎是一个解决方案,但不幸的是它只能在活动中创建。

谁有任何可能使用 Camera2 的线索?

【问题讨论】:

【参考方案1】:

除非您是前台应用程序或前台服务(带有持久通知),否则您无法在 Android Q 或更高版本上使用相机。

也就是说,如果您是其中一种情况,您可以使用已弃用的相机 API 或更新的 camera2 API,而无需进行绘图预览。

对于旧 API,您可以只使用 SurfaceTexture 作为预览输出,否则永远不要对 SurfaceTexture 做任何事情;对于camera2,只设置一个录制输出Surface,没有预览输出Surface。

【讨论】:

有通知没关系...我只是不想使用任何Activity。你知道我可以在前台服务和持久通知中使用的代码吗? 我不知道实现我建议的示例代码。我可能会尝试通过删除预览输出目标并将其移动为前台服务而不是活动来调整 camera2video:github.com/android/camera-samples/tree/master/Camera2Video【参考方案2】:

这是使用 CameraX 的后台录制应用程序的完整工作实现:

1) 这里是后台服务

    class MediaRecordingService : LifecycleService() 

    companion object 
        const val CHANNEL_ID: String = "media_recorder_service"
        private val TAG = MediaRecordingService::class.simpleName
        private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
        const val CHANNEL_NAME: String = "Media recording service"
        const val ONGOING_NOTIFICATION_ID: Int = 2345
        const val ACTION_START_WITH_PREVIEW: String = "start_recording"
        const val BIND_USECASE: String = "bind_usecase"
    

    enum class RecordingState 
        RECORDING, PAUSED, STOPPED
    

    class RecordingServiceBinder(private val service: MediaRecordingService) : Binder() 
        fun getService(): MediaRecordingService 
            return service
        
    
    private var preview: Preview? = null
    private lateinit var timer: Timer
    private var cameraProvider: ProcessCameraProvider? = null
    private lateinit var recordingServiceBinder: RecordingServiceBinder
    private var activeRecording: ActiveRecording? = null
    private var videoCapture: androidx.camera.video.VideoCapture<Recorder>? = null
    private val listeners = HashSet<DataListener>(1)
    private val pendingActions: HashMap<String, Runnable> = hashMapOf()
    private var recordingState: RecordingState = RecordingState.STOPPED
    private var duration: Int = 0
    private var timerTask: TimerTask? = null
    private var isSoundEnabled: Boolean = true

    override fun onCreate() 
        super.onCreate()
        recordingServiceBinder = RecordingServiceBinder(this)
        timer = Timer()
    

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int 
        super.onStartCommand(intent, flags, startId)
        when(intent?.action) 
            ACTION_START_WITH_PREVIEW -> 
                if (cameraProvider == null) 
                    initializeCamera()
                
            
        
        return START_NOT_STICKY
    

    private fun initializeCamera() 
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener(
            // Used to bind the lifecycle of cameras to the lifecycle owner
            cameraProvider = cameraProviderFuture.get()
            val qualitySelector = getQualitySelector()
            val recorder = Recorder.Builder()
                .setQualitySelector(qualitySelector)
                .build()
            videoCapture = withOutput(recorder)
                        // Select back camera as a default
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try 
                // Unbind use cases before rebinding
                cameraProvider?.unbindAll()
                // Bind use cases to camera
                cameraProvider?.bindToLifecycle(this, cameraSelector, videoCapture)
             catch(exc: Exception) 
                Log.e(MediaRecordingService::class.simpleName, "Use case binding failed", exc)
            
            val action = pendingActions[BIND_USECASE]
            action?.run()
            pendingActions.remove(BIND_USECASE)
        , ContextCompat.getMainExecutor(this))
    

    private fun getQualitySelector(): QualitySelector 
        return QualitySelector
            .firstTry(QualitySelector.QUALITY_UHD)
            .thenTry(QualitySelector.QUALITY_FHD)
            .thenTry(QualitySelector.QUALITY_HD)
            .finallyTry(
                QualitySelector.QUALITY_SD,
                QualitySelector.FALLBACK_STRATEGY_LOWER
            )
    

    fun startRecording() 
        val mediaStoreOutputOptions = createMediaStoreOutputOptions()
        if (ActivityCompat.checkSelfPermission(
                this,
                Manifest.permission.RECORD_AUDIO
            ) != PackageManager.PERMISSION_GRANTED
        ) 
            return
        

        var pendingRecording = videoCapture?.output?.prepareRecording(this, mediaStoreOutputOptions)
        if (isSoundEnabled) 
            pendingRecording = pendingRecording?.withAudioEnabled()
        
        activeRecording = pendingRecording?.withEventListener(ContextCompat.getMainExecutor(this),
                
                    when (it) 
                        is VideoRecordEvent.Start -> 
                            startTrackingTime()
                            recordingState = RecordingState.RECORDING
                        

                        is VideoRecordEvent.Finalize -> 
                            recordingState = RecordingState.STOPPED
                            duration = 0
                            timerTask?.cancel()
                        
                    
                    for (listener in listeners) 
                        listener.onRecordingEvent(it)
                    
                )
            ?.start()
        recordingState = RecordingState.RECORDING
    

    private fun startTrackingTime() 
        timerTask = object: TimerTask() 
            override fun run() 
                if (recordingState == RecordingState.RECORDING) 
                    duration += 1
                    for (listener in listeners) 
                        listener.onNewData(duration)
                    
                
            
        
        timer.scheduleAtFixedRate(timerTask, 1000, 1000)
    

    fun stopRecording() 
        activeRecording?.stop()
        activeRecording = null
    

    private fun createMediaStoreOutputOptions(): MediaStoreOutputOptions 
        val name = "CameraX-recording-" +
                SimpleDateFormat(FILENAME_FORMAT, Locale.getDefault())
                    .format(System.currentTimeMillis()) + ".mp4"
        val contentValues = ContentValues().apply 
            put(MediaStore.Video.Media.DISPLAY_NAME, name)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 
                put(MediaStore.MediaColumns.RELATIVE_PATH, "DCIM/Recorded Videos")
            
        
        return MediaStoreOutputOptions.Builder(
            contentResolver,
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI
        )
            .setContentValues(contentValues)
            .build()
    

    fun bindPreviewUseCase(surfaceProvider: Preview.SurfaceProvider?) 
        activeRecording?.pause()
        if (cameraProvider != null) 
            bindInternal(surfaceProvider)
         else 
            pendingActions[BIND_USECASE] = Runnable 
                bindInternal(surfaceProvider)
            
        
    

    private fun bindInternal(surfaceProvider: Preview.SurfaceProvider?) 
        if (preview != null) 
            cameraProvider?.unbind(preview)
        
        initPreviewUseCase()
        preview?.setSurfaceProvider(surfaceProvider)
        val cameraInfo: CameraInfo? = cameraProvider?.bindToLifecycle(
            this@MediaRecordingService,
            CameraSelector.DEFAULT_BACK_CAMERA,
            preview
        )?.cameraInfo
        observeCameraState(cameraInfo, this)
    

    private fun initPreviewUseCase() 
        preview?.setSurfaceProvider(null)
        preview = Preview.Builder()
            .build()
    

    fun unbindPreview() 
        // Just remove the surface provider. I discovered that for some reason if you unbind the Preview usecase the camera willl stop recording the video.
        preview?.setSurfaceProvider(null)
    

    fun startRunningInForeground() 
        val parentStack = TaskStackBuilder.create(this)
            .addNextIntentWithParentStack(Intent(this, MainActivity::class.java))

        val pendingIntent1 = parentStack.getPendingIntent(0, 0)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) 
            val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)
            val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            nm.createNotificationChannel(channel)
        

        val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle(getText(R.string.video_recording))
            .setContentText(getText(R.string.video_recording_in_background))
            .setSmallIcon(R.drawable.ic_record)
            .setContentIntent(pendingIntent1)
            .build()
        startForeground(ONGOING_NOTIFICATION_ID, notification)
    

    fun isSoundEnabled(): Boolean 
        return isSoundEnabled
    

    fun setSoundEnabled(enabled: Boolean) 
        isSoundEnabled = enabled
    

    // Stop recording and remove SurfaceView
    override fun onDestroy() 
        super.onDestroy()
        activeRecording?.stop()
        timerTask?.cancel()
    

    override fun onBind(intent: Intent): IBinder 
        super.onBind(intent)
        return recordingServiceBinder
    

    fun addListener(listener: DataListener) 
        listeners.add(listener)
    

    fun removeListener(listener: DataListener) 
        listeners.remove(listener)
    

    fun getRecordingState(): RecordingState 
        return recordingState
    

    private fun observeCameraState(cameraInfo: androidx.camera.core.CameraInfo?, context: Context) 
        cameraInfo?.cameraState?.observe(this)  cameraState ->
            run 
                when (cameraState.type) 
                    CameraState.Type.PENDING_OPEN -> 
                        // Ask the user to close other camera apps
                    
                    CameraState.Type.OPENING -> 
                        // Show the Camera UI
                        for (listener in listeners) 
                            listener.onCameraOpened()
                        
                    
                    CameraState.Type.OPEN -> 
                        // Setup Camera resources and begin processing
                    
                    CameraState.Type.CLOSING -> 
                        // Close camera UI
                    
                    CameraState.Type.CLOSED -> 
                        // Free camera resources
                    
                
            

            cameraState.error?.let  error ->
                when (error.code) 
                    // Open errors
                    CameraState.ERROR_STREAM_CONFIG -> 
                        // Make sure to setup the use cases properly
                        Toast.makeText(context,
                            "Stream config error. Restart application",
                            Toast.LENGTH_SHORT).show()
                    
                    // Opening errors
                    CameraState.ERROR_CAMERA_IN_USE -> 
                        // Close the camera or ask user to close another camera app that's using the
                        // camera
                        Toast.makeText(context,
                            "Camera in use. Close any apps that are using the camera",
                            Toast.LENGTH_SHORT).show()
                    
                    CameraState.ERROR_MAX_CAMERAS_IN_USE -> 
                        // Close another open camera in the app, or ask the user to close another
                        // camera app that's using the camera
                    
                    CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> 

                    
                    // Closing errors
                    CameraState.ERROR_CAMERA_DISABLED -> 
                         // Ask the user to enable the device's cameras
                        Toast.makeText(context,
                            "Camera disabled",
                            Toast.LENGTH_SHORT).show()
                    
                    CameraState.ERROR_CAMERA_FATAL_ERROR -> 
                        // Ask the user to reboot the device to restore camera function
                        Toast.makeText(context,
                            "Fatal error",
                            Toast.LENGTH_SHORT).show()
                    
                    // Closed errors
                    CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> 
                        // Ask the user to disable the "Do Not Disturb" mode, then reopen the camera
                        Toast.makeText(context,
                            "Do not disturb mode enabled",
                            Toast.LENGTH_SHORT).show()
                    
                
            
        
    

    interface DataListener 
        fun onNewData(duration: Int)
        fun onCameraOpened()
        fun onRecordingEvent(it: VideoRecordEvent?)
    

    

添加这些依赖项

implementation "androidx.camera:camera-video:1.1.0-alpha11"
    implementation "androidx.camera:camera-camera2:1.1.0-alpha11"
    implementation "androidx.camera:camera-lifecycle:1.1.0-alpha11"
    implementation "androidx.camera:camera-view:1.0.0-alpha31"
    implementation "androidx.camera:camera-extensions:1.0.0-alpha31"
    implementation "androidx.lifecycle:lifecycle-service:2.4.0"

2) 这是活动

class MainActivity : AppCompatActivity(), MediaRecordingService.DataListener 
    private var recordingService: MediaRecordingService? = null
    private lateinit var viewBinding: ActivityMainBinding
    private var isReverseLandscape: Boolean = false

    override fun onCreate(savedInstanceState: Bundle?) 
        super.onCreate(savedInstanceState)
        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
        viewBinding.btnRecord.setOnClickListener 
            onPauseRecordClicked()
        
        viewBinding.btnMute.setOnClickListener  onMuteRecordingClicked() 
        viewBinding.btnRotate.setOnClickListener 
            requestedOrientation = if (isReverseLandscape) 
                ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
             else 
                ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
            
            isReverseLandscape = !isReverseLandscape
        
        viewBinding.btnBack.setOnClickListener 
            onBackPressedDispatcher.onBackPressed()
        
    

    private fun onMuteRecordingClicked() 
        if(recordingService == null) return
        var soundEnabled = recordingService?.isSoundEnabled()
        soundEnabled = !soundEnabled!!
        recordingService?.setSoundEnabled(soundEnabled)
        setSoundState(soundEnabled)
    

    private fun setSoundState(soundEnabled: Boolean) 
        if (soundEnabled)
            viewBinding.viewMute.setBackgroundResource(R.drawable.ic_volume_up_24)
         else 
            viewBinding.viewMute.setBackgroundResource(R.drawable.ic_volume_off_24)
        
    

    private fun bindService() 
        val intent = Intent(this, MediaRecordingService::class.java)
        intent.action = MediaRecordingService.ACTION_START_WITH_PREVIEW
        startService(intent)
        bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
    

    override fun onStart() 
        super.onStart()
        bindService()
    

    private val serviceConnection: ServiceConnection = object : ServiceConnection 
        override fun onServiceConnected(name: ComponentName?, service: IBinder?) 
            recordingService = (service as MediaRecordingService.RecordingServiceBinder).getService()
            onServiceBound(recordingService)
        

        override fun onServiceDisconnected(name: ComponentName?) 

        
    

    private fun onServiceBound(recordingService: MediaRecordingService?) 
        when(recordingService?.getRecordingState())
            MediaRecordingService.RecordingState.RECORDING -> 
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_baseline_stop_24)
                viewBinding.btnMute.visibility = View.INVISIBLE
            
            MediaRecordingService.RecordingState.STOPPED -> 
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_videocam_24)
                viewBinding.txtDuration.text = "00:00:00"
                viewBinding.btnMute.visibility = View.VISIBLE
                setSoundState(recordingService.isSoundEnabled())
            
            else -> 
                // no-op
            
        

        recordingService?.addListener(this)
        recordingService?.bindPreviewUseCase(viewBinding.previewContainer.surfaceProvider)
    

    private fun onPauseRecordClicked() 
        when(recordingService?.getRecordingState())
            MediaRecordingService.RecordingState.RECORDING -> 
                recordingService?.stopRecording()
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_videocam_24)
                viewBinding.txtDuration.text = "00:00:00"
            
            MediaRecordingService.RecordingState.STOPPED -> 
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_baseline_stop_24)
                recordingService?.startRecording()
            
            else -> 
                // no-op
            
        
    

    @SuppressLint("SetTextI18n")
    override fun onNewData(duration: Int) 
        runOnUiThread 
            var seconds = duration
            var minutes = seconds / MINUTE
            seconds %= MINUTE
            val hours = minutes / HOUR
            minutes %= HOUR

            val hoursString = if (hours >= 10) hours.toString() else "0$hours"
            val minutesString = if (minutes >= 10) minutes.toString() else "0$minutes"
            val secondsString = if (seconds >= 10) seconds.toString() else "0$seconds"
            viewBinding.txtDuration.text = "$hoursString:$minutesString:$secondsString"
        
    

    override fun onCameraOpened() 

    

    override fun onRecordingEvent(it: VideoRecordEvent?) 
        when (it) 
            is VideoRecordEvent.Start -> 
                viewBinding.btnMute.visibility = View.INVISIBLE
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_baseline_stop_24)
            

            is VideoRecordEvent.Finalize -> 
                recordingService?.isSoundEnabled()?.let  it1 -> setSoundState(it1) 
                viewBinding.btnMute.visibility = View.VISIBLE
                viewBinding.viewRecordPause.setBackgroundResource(R.drawable.ic_videocam_24)
                onNewData(0)
                val intent = Intent(Intent.ACTION_VIEW, it.outputResults.outputUri)
                intent.setDataAndType(it.outputResults.outputUri, "video/mp4")
                startActivity(Intent.createChooser(intent, "Open recorded video"))
            
        
    

    override fun onStop() 
        super.onStop()
        if (recordingService?.getRecordingState() == MediaRecordingService.RecordingState.STOPPED) 
            recordingService?.let 
                ServiceCompat.stopForeground(it, ServiceCompat.STOP_FOREGROUND_REMOVE)
                recordingService?.stopSelf()
            
         else 
            recordingService?.startRunningInForeground()
        
        recordingService?.unbindPreview()
        recordingService?.removeListener(this)
    

    companion object 
        private const val MINUTE: Int = 60
        private const val HOUR: Int = MINUTE * 60
    

    在清单中这些权限

    <uses-feature android:name="android.hardware.camera.any" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="28" />

这是活动布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root_layout"
    android:layout_
    android:layout_
    tools:context="com.theorbapp.MainActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/preview_container"
        android:layout_
        android:layout_
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.0" />

    <FrameLayout
        android:id="@+id/frameLayout"
        android:layout_
        android:layout_
        android:layout_marginStart="16dp"
        android:layout_marginTop="16dp"
        android:background="@color/transparent_black"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/txt_duration"
            android:layout_
            android:layout_
            android:paddingStart="4dp"
            android:paddingEnd="4dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Medium"
            android:textColor="@color/white"
            tools:text="00:01:25" />
    </FrameLayout>

    <FrameLayout
        android:id="@+id/btn_rotate"
        android:layout_
        android:layout_
        android:layout_marginEnd="16dp"
        android:animateLayoutChanges="true"
        android:background="@drawable/circle_drawable"
        app:layout_constraintBottom_toBottomOf="@+id/btn_back"
        app:layout_constraintEnd_toStartOf="@+id/btn_back"
        app:layout_constraintTop_toTopOf="@+id/btn_back">

        <View
            android:id="@+id/view"
            android:layout_
            android:layout_
            android:layout_gravity="center"
            android:background="@drawable/ic_screen_rotation_24" />
    </FrameLayout>

    <FrameLayout
        android:id="@+id/btn_back"
        android:layout_
        android:layout_
        android:layout_marginTop="16dp"
        android:layout_marginEnd="16dp"
        android:animateLayoutChanges="true"
        android:background="@drawable/circle_drawable"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <View
            android:id="@+id/view2"
            android:layout_
            android:layout_
            android:layout_gravity="center"
            android:background="@drawable/ic_baseline_navigate_before_24" />
    </FrameLayout>

    <FrameLayout
        android:id="@+id/btn_record"
        android:layout_
        android:layout_
        android:background="@drawable/circle_drawable"
        app:layout_constraintBottom_toTopOf="@+id/btn_mute"
        app:layout_constraintEnd_toEndOf="@+id/btn_back"
        app:layout_constraintTop_toBottomOf="@+id/btn_back">

        <View
            android:id="@+id/view_record_pause"
            android:layout_
            android:layout_
            android:layout_gravity="center"
            android:background="@drawable/ic_videocam_24" />
    </FrameLayout>

    <FrameLayout
        android:id="@+id/btn_mute"
        android:layout_
        android:layout_
        android:layout_marginEnd="16dp"
        android:layout_marginBottom="16dp"
        android:animateLayoutChanges="true"
        android:background="@drawable/circle_drawable"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent">

        <View
            android:id="@+id/view_mute"
            android:layout_
            android:layout_
            android:layout_gravity="center"
            android:background="@drawable/ic_volume_up_24" />
    </FrameLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

此实现在后台录制视频。但是我发现的错误是,有时当您打开从后台录制返回的应用程序时,预览会变黑。我尝试修复此错误无济于事

【讨论】:

以上是关于Android 10 (Q) 中的后台服务录像机的主要内容,如果未能解决你的问题,请参考以下文章

Android Q - 前台服务需要后台位置权限?

如何在android Q os中从后台服务启动活动,而不从开发人员选项的设置中单击“允许后台活动启动”?

一段时间后,android后台服务停止

平安城市摄像机部署过程中的几个现实问题

从MediaRecord录像中读取H264参数

在 Android Q-Municate 中未收到后台来电和消息通知