Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
android:required="false" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required for Bluetooth microphones -->
Expand All @@ -24,6 +25,7 @@

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<uses-permission android:name="android.permission.USE_BIOMETRIC" />

Expand Down Expand Up @@ -73,7 +75,7 @@
android:foregroundServiceType="microphone" />
<service
android:name=".services.VideoRecorderService"
android:foregroundServiceType="camera|microphone" />
android:foregroundServiceType="camera|microphone|location" />

<!-- Change locale for Android <= 12 -->
<service
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/app/myzel394/alibi/db/AppSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ data class VideoRecorderSettings(
val targetedVideoBitRate: Int? = null,
val quality: String? = null,
val targetFrameRate: Int? = null,
val overlaySettings: VideoOverlaySettings = VideoOverlaySettings.getDefaultInstance(),
) {
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
return copy(targetedVideoBitRate = bitRate)
Expand All @@ -465,6 +466,10 @@ data class VideoRecorderSettings(
return copy(targetFrameRate = frameRate)
}

fun setOverlaySettings(overlaySettings: VideoOverlaySettings): VideoRecorderSettings {
return copy(overlaySettings = overlaySettings)
}

fun getQuality(): Quality? =
quality?.let {
QUALITY_NAME_QUALITY_MAP[it]!!
Expand Down Expand Up @@ -532,6 +537,40 @@ data class VideoRecorderSettings(
}
}

@Serializable
data class VideoOverlaySettings(
val timeEnabled: Boolean = false,
val locationEnabled: Boolean = false,
val timeFormat: VideoOverlayTimeFormat = VideoOverlayTimeFormat.ISO_OFFSET_DATE_TIME,
) {
val enabled: Boolean
get() = timeEnabled || locationEnabled

fun setTimeEnabled(timeEnabled: Boolean): VideoOverlaySettings {
return copy(timeEnabled = timeEnabled)
}

fun setLocationEnabled(locationEnabled: Boolean): VideoOverlaySettings {
return copy(locationEnabled = locationEnabled)
}

fun setTimeFormat(timeFormat: VideoOverlayTimeFormat): VideoOverlaySettings {
return copy(timeFormat = timeFormat)
}

companion object {
fun getDefaultInstance() = VideoOverlaySettings()
}
}

@Serializable
enum class VideoOverlayTimeFormat {
ISO_OFFSET_DATE_TIME,
ISO_INSTANT,
ISO_LOCAL_DATE_TIME,
DASHCAM_DATE_TIME,
}

@Serializable
data class NotificationSettings(
val title: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :

lateinit var settings: AppSettings

fun hasInitializedSettings() = ::settings.isInitialized

private lateinit var cycleTimer: ScheduledExecutorService

abstract var batchesFolder: B
Expand Down
116 changes: 107 additions & 9 deletions app/src/main/java/app/myzel394/alibi/services/VideoRecorderService.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package app.myzel394.alibi.services

import android.annotation.SuppressLint
import android.Manifest
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Range
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.TorchState
import androidx.camera.core.UseCaseGroup
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileDescriptorOutputOptions
import androidx.camera.video.FileOutputOptions
Expand All @@ -20,13 +22,18 @@ import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.util.Consumer
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.helpers.VideoBatchesFolder
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.utils.PermissionHelper
import app.myzel394.alibi.videooverlay.OverlayLocationProvider
import app.myzel394.alibi.videooverlay.OverlayTextFormatter
import app.myzel394.alibi.videooverlay.VideoOverlayEffect
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand All @@ -47,6 +54,8 @@ class VideoRecorderService :
private var cameraProvider: ProcessCameraProvider? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var activeRecording: Recording? = null
private var videoOverlayEffect: VideoOverlayEffect? = null
private var overlayLocationProvider: OverlayLocationProvider? = null

// Used to listen and check if the camera is available
private var _cameraAvailableListener = CompletableDeferred<Unit>()
Expand Down Expand Up @@ -103,6 +112,13 @@ class VideoRecorderService :
super.pause()

stopActiveRecording()
stopOverlayLocationProvider()
}

override fun resume() {
super.resume()

startOverlayLocationProvider()
}

override fun startForegroundService() {
Expand All @@ -111,16 +127,31 @@ class VideoRecorderService :
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
getNotificationHelper().buildStartingNotification(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (enableAudio)
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
else
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
buildForegroundServiceType()
} else {
0
},
)
}

private fun buildForegroundServiceType(): Int {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA

if (enableAudio) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}

if (
hasInitializedSettings() &&
settings.videoRecorderSettings.overlaySettings.locationEnabled &&
PermissionHelper.hasGranted(this, Manifest.permission.ACCESS_FINE_LOCATION)
) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}

return type
}

@SuppressLint("MissingPermission")
override fun startNewCycle() {
super.startNewCycle()
Expand Down Expand Up @@ -189,14 +220,23 @@ class VideoRecorderService :

val recorder = buildRecorder()
videoCapture = buildVideoCapture(recorder)
val useCaseGroup = buildUseCaseGroupWithOverlay(videoCapture!!)

runOnMain {
try {
camera = cameraProvider!!.bindToLifecycle(
this,
selectedCamera,
videoCapture
)
camera = if (useCaseGroup != null) {
cameraProvider!!.bindToLifecycle(
this,
selectedCamera,
useCaseGroup,
)
} else {
cameraProvider!!.bindToLifecycle(
this,
selectedCamera,
videoCapture,
)
}

cameraControl = CameraControl(camera!!).also {
it.init()
Expand All @@ -205,6 +245,7 @@ class VideoRecorderService :

_cameraAvailableListener.complete(Unit)
} catch (error: IllegalArgumentException) {
releaseOverlay()
onError()
}
}
Expand All @@ -218,6 +259,7 @@ class VideoRecorderService :
runCatching {
cameraProvider?.unbindAll()
}
releaseOverlay()
_cameraCloserListener.complete(Unit)

// Doesn't need to run on main thread, but
Expand All @@ -237,6 +279,62 @@ class VideoRecorderService :
}
}

private fun buildUseCaseGroupWithOverlay(
videoCapture: VideoCapture<Recorder>,
): UseCaseGroup? {
val overlaySettings = settings.videoRecorderSettings.overlaySettings
if (!overlaySettings.enabled) {
return null
}

val formatter = OverlayTextFormatter()
val locationProvider = if (overlaySettings.locationEnabled) {
OverlayLocationProvider(this).also {
overlayLocationProvider = it
it.start()
}
} else {
null
}

videoOverlayEffect = VideoOverlayEffect(
overlayTextProvider = {
formatter.buildOverlayText(
overlaySettings,
locationProvider?.getLatestLocation(),
)
},
errorListener = Consumer { error ->
error.printStackTrace()
runOnMain {
releaseOverlay()
onError()
}
},
)

return UseCaseGroup.Builder()
.addUseCase(videoCapture)
.addEffect(videoOverlayEffect!!)
.build()
}

private fun startOverlayLocationProvider() {
overlayLocationProvider?.start()
}

private fun stopOverlayLocationProvider() {
overlayLocationProvider?.stop()
}

private fun releaseOverlay() {
stopOverlayLocationProvider()
overlayLocationProvider = null

videoOverlayEffect?.release()
videoOverlayEffect = null
}

private fun getNameForMediaFile() =
"${batchesFolder.mediaPrefix}$counter.${settings.videoRecorderSettings.fileExtension}"

Expand Down
Loading