Skip to content

Commit 837ffe8

Browse files
committed
Implement SECV video encryption
1 parent 0be09d9 commit 837ffe8

16 files changed

Lines changed: 1450 additions & 46 deletions

app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/CameraState.kt

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import androidx.lifecycle.LifecycleOwner
1414
import androidx.lifecycle.coroutineScope
1515
import com.darkrockstudios.app.securecamera.obfuscation.FacialDetection
1616
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
17+
import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme
18+
import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat
19+
import com.darkrockstudios.app.securecamera.security.streaming.VideoEncryptionHelper
1720
import kotlinx.coroutines.*
21+
import kotlinx.coroutines.flow.collectLatest
1822
import org.koin.core.component.KoinComponent
1923
import org.koin.core.component.inject
2024
import timber.log.Timber
@@ -43,8 +47,10 @@ class CameraState internal constructor(
4347
private val clock: Clock by inject()
4448
private val facialDetection: FacialDetection by inject()
4549
private val preferences: AppSettingsDataSource by inject()
50+
private val encryptionScheme: EncryptionScheme by inject()
4651

4752
private var faceAnalyzer: FacialDetectionAnalyzer? = null
53+
private var videoEncryptionHelper: VideoEncryptionHelper? = null
4854
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
4955
private val analysisExecutor: ExecutorService = Executors.newSingleThreadExecutor()
5056
private val analysisScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -92,11 +98,66 @@ class CameraState internal constructor(
9298
var recordingDurationMs by mutableLongStateOf(0L)
9399
private set
94100

101+
var isEncryptingVideo by mutableStateOf(false)
102+
private set
103+
104+
var encryptionProgress by mutableFloatStateOf(0f)
105+
private set
106+
95107
private var activeRecording: Recording? = null
96108
private var videoCapture: VideoCapture<Recorder>? = null
109+
private var pendingTempFile: File? = null
110+
private var pendingOutputFile: File? = null
97111

98112
init {
99113
observePreferences()
114+
initializeEncryptionHelper()
115+
}
116+
117+
private fun initializeEncryptionHelper() {
118+
val streamingScheme = encryptionScheme.getStreamingCapability()
119+
if (streamingScheme != null) {
120+
videoEncryptionHelper = VideoEncryptionHelper(streamingScheme)
121+
observeEncryptionProgress()
122+
} else {
123+
Timber.w("Streaming encryption not available")
124+
}
125+
}
126+
127+
private fun observeEncryptionProgress() {
128+
lifecycleOwner.lifecycle.coroutineScope.launch {
129+
videoEncryptionHelper?.encryptionProgress?.collectLatest { progress ->
130+
when (progress) {
131+
is VideoEncryptionHelper.EncryptionProgress.Idle -> {
132+
isEncryptingVideo = false
133+
encryptionProgress = 0f
134+
}
135+
136+
is VideoEncryptionHelper.EncryptionProgress.Starting -> {
137+
isEncryptingVideo = true
138+
encryptionProgress = 0f
139+
}
140+
141+
is VideoEncryptionHelper.EncryptionProgress.InProgress -> {
142+
isEncryptingVideo = true
143+
encryptionProgress = progress.progress
144+
}
145+
146+
is VideoEncryptionHelper.EncryptionProgress.Completed -> {
147+
isEncryptingVideo = false
148+
encryptionProgress = 0f
149+
videoEncryptionHelper?.resetProgress()
150+
}
151+
152+
is VideoEncryptionHelper.EncryptionProgress.Error -> {
153+
isEncryptingVideo = false
154+
encryptionProgress = 0f
155+
Timber.e("Encryption error: ${progress.message}")
156+
videoEncryptionHelper?.resetProgress()
157+
}
158+
}
159+
}
160+
}
100161
}
101162

102163
private fun observePreferences() {
@@ -247,8 +308,13 @@ class CameraState internal constructor(
247308
}
248309

249310
/**
250-
* Start video recording. Returns the output file path.
251-
* The video will be saved to the app's internal files directory.
311+
* Start video recording. Returns the final encrypted output file path (.secv).
312+
* The video will be saved to the app's internal files directory after encryption.
313+
*
314+
* Recording flow:
315+
* 1. CameraX writes to a temporary .mp4 file
316+
* 2. On recording complete, the temp file is encrypted to .secv format
317+
* 3. The temp file is securely deleted after encryption
252318
*/
253319
@SuppressLint("MissingPermission")
254320
fun startRecording(context: Context): File? {
@@ -257,12 +323,12 @@ class CameraState internal constructor(
257323
return null
258324
}
259325

260-
if (isRecording) {
261-
Timber.w("Recording already in progress")
326+
if (isRecording || isEncryptingVideo) {
327+
Timber.w("Recording or encryption already in progress")
262328
return null
263329
}
264330

265-
// Create output file in app's internal storage
331+
// Create output directories
266332
val videosDir = File(context.filesDir, "videos")
267333
if (!videosDir.exists()) {
268334
videosDir.mkdirs()
@@ -272,9 +338,16 @@ class CameraState internal constructor(
272338
"yyyyMMdd_HHmmss",
273339
java.util.Locale.US
274340
).format(System.currentTimeMillis())
275-
val outputFile = File(videosDir, "video_$timestamp.mp4")
276341

277-
val fileOutputOptions = FileOutputOptions.Builder(outputFile).build()
342+
// Temp file for CameraX recording (unencrypted)
343+
val tempFile = File(videosDir, "temp_$timestamp.mp4")
344+
// Final encrypted output file
345+
val outputFile = File(videosDir, "video_$timestamp.${SecvFileFormat.FILE_EXTENSION}")
346+
347+
pendingTempFile = tempFile
348+
pendingOutputFile = outputFile
349+
350+
val fileOutputOptions = FileOutputOptions.Builder(tempFile).build()
278351

279352
activeRecording = videoCapture.output
280353
.prepareRecording(context, fileOutputOptions)
@@ -293,21 +366,55 @@ class CameraState internal constructor(
293366

294367
is VideoRecordEvent.Finalize -> {
295368
isRecording = false
369+
activeRecording = null
370+
296371
if (event.hasError()) {
297372
Timber.e("Recording error: ${event.error}, cause: ${event.cause?.message}")
298-
// Delete the failed file
299-
outputFile.delete()
373+
// Delete the failed temp file
374+
tempFile.delete()
375+
pendingTempFile = null
376+
pendingOutputFile = null
300377
} else {
301-
Timber.i("Video saved to: ${event.outputResults.outputUri}")
378+
Timber.i("Recording complete, starting encryption...")
379+
// Start encryption in background
380+
lifecycleOwner.lifecycle.coroutineScope.launch {
381+
encryptRecordedVideo(tempFile, outputFile)
382+
}
302383
}
303-
activeRecording = null
304384
}
305385
}
306386
}
307387

308388
return outputFile
309389
}
310390

391+
/**
392+
* Encrypts the recorded video from temp file to final encrypted file.
393+
*/
394+
private suspend fun encryptRecordedVideo(tempFile: File, outputFile: File) {
395+
val helper = videoEncryptionHelper
396+
if (helper == null) {
397+
Timber.e("Video encryption helper not available")
398+
// Fall back to keeping the unencrypted file (rename to .mp4)
399+
val fallbackFile = File(outputFile.parent, outputFile.nameWithoutExtension + ".mp4")
400+
tempFile.renameTo(fallbackFile)
401+
return
402+
}
403+
404+
val success = helper.encryptVideoFile(tempFile, outputFile)
405+
if (success) {
406+
Timber.i("Video encrypted successfully: ${outputFile.absolutePath}")
407+
} else {
408+
Timber.e("Video encryption failed")
409+
// Keep the temp file as fallback (rename to .mp4)
410+
val fallbackFile = File(outputFile.parent, outputFile.nameWithoutExtension + ".mp4")
411+
tempFile.renameTo(fallbackFile)
412+
}
413+
414+
pendingTempFile = null
415+
pendingOutputFile = null
416+
}
417+
311418
/**
312419
* Stop the current video recording.
313420
*/
@@ -437,6 +544,16 @@ class CameraState internal constructor(
437544
if (isRecording) {
438545
stopRecording()
439546
}
547+
548+
// Clean up any pending temp files
549+
pendingTempFile?.let { file ->
550+
if (file.exists()) {
551+
file.delete()
552+
}
553+
}
554+
pendingTempFile = null
555+
pendingOutputFile = null
556+
440557
faceAnalyzer = null
441558
imageAnalysis?.clearAnalyzer()
442559
analysisScope.cancel()

app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,22 @@ import android.graphics.Bitmap.CompressFormat
66
import android.graphics.BitmapFactory
77
import android.media.MediaMetadataRetriever
88
import android.util.Size
9+
import androidx.core.graphics.scale
910
import com.ashampoo.kim.Kim
1011
import com.ashampoo.kim.common.convertToPhotoMetadata
1112
import com.ashampoo.kim.model.GpsCoordinates
1213
import com.ashampoo.kim.model.MetadataUpdate
1314
import com.ashampoo.kim.model.TiffOrientation
1415
import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme
16+
import com.darkrockstudios.app.securecamera.security.streaming.SecvFileFormat
17+
import com.darkrockstudios.app.securecamera.security.streaming.StreamingDecryptor
18+
import com.darkrockstudios.app.securecamera.security.streaming.StreamingEncryptionScheme
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.withContext
1521
import timber.log.Timber
1622
import java.io.ByteArrayOutputStream
1723
import java.io.File
24+
import java.io.FileOutputStream
1825
import java.text.SimpleDateFormat
1926
import java.util.*
2027
import kotlin.time.toJavaInstant
@@ -334,14 +341,27 @@ class SecureImageRepository(
334341

335342
fun getVideosDirectory(): File = File(appContext.filesDir, VIDEOS_DIR)
336343

344+
/**
345+
* Returns the streaming encryption scheme for video decryption.
346+
*/
347+
fun getStreamingEncryptionScheme(): StreamingEncryptionScheme? {
348+
return encryptionScheme.getStreamingCapability()
349+
}
350+
337351
fun getVideos(): List<VideoDef> {
338352
val dir = getVideosDirectory()
339353
if (!dir.exists()) {
340354
return emptyList()
341355
}
342356

357+
// Include both encrypted (.secv) and legacy unencrypted (.mp4) videos
343358
return dir.listFiles()
344-
?.filter { it.isFile && it.name.endsWith(".mp4") }
359+
?.filter { file ->
360+
file.isFile && (
361+
file.name.endsWith(".${SecvFileFormat.FILE_EXTENSION}") ||
362+
file.name.endsWith(".mp4")
363+
) && !file.name.startsWith("temp_") // Exclude temp files
364+
}
345365
?.map { file ->
346366
val name = file.name
347367
val format = name.substringAfterLast('.', "mp4")
@@ -373,7 +393,7 @@ class SecureImageRepository(
373393
return null
374394
}
375395

376-
val format = videoName.substringAfterLast('.', "mp4")
396+
val format = videoName.substringAfterLast('.', SecvFileFormat.FILE_EXTENSION)
377397
return VideoDef(
378398
videoName = videoName,
379399
videoFormat = format,
@@ -383,7 +403,7 @@ class SecureImageRepository(
383403

384404
/**
385405
* Reads a thumbnail for a video by extracting a frame.
386-
* For now, videos are not encrypted, so we read directly from the file.
406+
* For encrypted videos, temporarily decrypts enough data to extract a frame.
387407
*/
388408
suspend fun readVideoThumbnail(video: VideoDef): Bitmap? {
389409
thumbnailCache.getThumbnail(video)?.let { return it }
@@ -399,16 +419,17 @@ class SecureImageRepository(
399419
null
400420
} else {
401421
// Extract a frame from the video
402-
extractVideoFrame(video.videoFile)?.let { frameBitmap ->
422+
val frameBitmap = if (video.isEncrypted) {
423+
extractEncryptedVideoFrame(video)
424+
} else {
425+
extractVideoFrame(video.videoFile)
426+
}
427+
428+
frameBitmap?.let { bitmap ->
403429
// Scale down for thumbnail
404-
val scaledBitmap = Bitmap.createScaledBitmap(
405-
frameBitmap,
406-
frameBitmap.width / 4,
407-
frameBitmap.height / 4,
408-
true
409-
)
410-
if (scaledBitmap != frameBitmap) {
411-
frameBitmap.recycle()
430+
val scaledBitmap = bitmap.scale(bitmap.width / 4, bitmap.height / 4)
431+
if (scaledBitmap != bitmap) {
432+
bitmap.recycle()
412433
}
413434

414435
// Compress and encrypt the thumbnail
@@ -432,6 +453,53 @@ class SecureImageRepository(
432453
return thumbnailBitmap
433454
}
434455

456+
/**
457+
* Extracts a video frame from an encrypted video file.
458+
* Temporarily decrypts the video content to extract a frame.
459+
*/
460+
private suspend fun extractEncryptedVideoFrame(video: VideoDef): Bitmap? {
461+
val streamingScheme = encryptionScheme.getStreamingCapability() ?: run {
462+
Timber.e("Streaming encryption not available for thumbnail extraction")
463+
return null
464+
}
465+
466+
var decryptor: StreamingDecryptor? = null
467+
var tempFile: File? = null
468+
469+
return try {
470+
decryptor = streamingScheme.createStreamingDecryptor(video.videoFile)
471+
472+
// Create a temporary file with decrypted video content
473+
// We need enough data for MediaMetadataRetriever to extract a frame
474+
// Typically the first few MB are sufficient
475+
val bytesToRead = minOf(decryptor.totalSize, THUMBNAIL_EXTRACTION_BYTES)
476+
val buffer = ByteArray(bytesToRead.toInt())
477+
val bytesRead = decryptor.read(0, buffer, 0, buffer.size)
478+
479+
if (bytesRead <= 0) {
480+
Timber.w("Could not read video data for thumbnail")
481+
return null
482+
}
483+
484+
// Write to a temp file for MediaMetadataRetriever
485+
tempFile = File(appContext.cacheDir, "thumb_temp_${video.videoName}.mp4")
486+
withContext(Dispatchers.IO) {
487+
FileOutputStream(tempFile).use { fos ->
488+
fos.write(buffer, 0, bytesRead)
489+
}
490+
}
491+
492+
// Extract frame from temp file
493+
extractVideoFrame(tempFile)
494+
} catch (e: Exception) {
495+
Timber.e(e, "Failed to extract encrypted video frame")
496+
null
497+
} finally {
498+
decryptor?.close()
499+
tempFile?.delete()
500+
}
501+
}
502+
435503
private fun extractVideoFrame(videoFile: File): Bitmap? {
436504
return try {
437505
MediaMetadataRetriever().use { retriever ->
@@ -637,6 +705,9 @@ class SecureImageRepository(
637705
const val THUMBNAILS_DIR = ".thumbnails"
638706
const val MAX_DECOY_PHOTOS = 10
639707

708+
// Amount of video data to decrypt for thumbnail extraction (5MB should be enough for moov atom)
709+
private const val THUMBNAIL_EXTRACTION_BYTES = 5L * 1024 * 1024
710+
640711
internal fun generateCopyName(dir: File, originalName: String): String {
641712
val base = originalName.substringBeforeLast(".")
642713
val ext = originalName.substringAfterLast('.', "jpg")

0 commit comments

Comments
 (0)