Skip to content

Commit 8b07973

Browse files
committed
Fix video and photo rotation in landscape
- Improve metadata manager
1 parent 7d5c378 commit 8b07973

12 files changed

Lines changed: 426 additions & 21 deletions

File tree

app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/PinVerificationViewModel.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import com.darkrockstudios.app.securecamera.BaseViewModel
77
import com.darkrockstudios.app.securecamera.R
88
import com.darkrockstudios.app.securecamera.encryption.VideoEncryptionService
99
import com.darkrockstudios.app.securecamera.gallery.vibrateDevice
10-
import com.darkrockstudios.app.securecamera.metadata.MetadataManager
1110
import com.darkrockstudios.app.securecamera.metadata.MetadataMigrationManager
1211
import com.darkrockstudios.app.securecamera.navigation.Introduction
1312
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
@@ -30,7 +29,6 @@ class PinVerificationViewModel(
3029
private val verifyPinUseCase: VerifyPinUseCase,
3130
private val pinSizeUseCase: PinSizeUseCase,
3231
private val appSettingsDataSource: AppSettingsDataSource,
33-
private val metadataManager: MetadataManager,
3432
private val metadataMigrationManager: MetadataMigrationManager,
3533
) : BaseViewModel<PinVerificationUiState>() {
3634

@@ -127,8 +125,6 @@ class PinVerificationViewModel(
127125
val isValid = verifyPinUseCase.verifyPin(pin)
128126

129127
if (isValid) {
130-
metadataManager.loadIndex()
131-
132128
if (metadataMigrationManager.needsMigration()) {
133129
runDataMigration()
134130
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ fun CameraControls(
111111
cameraController.stopRecording()
112112
vibrateDevice(context)
113113
} else {
114-
val outputFile = cameraController.startRecording(context)
114+
val outputFile = cameraController.startRecording(context, cameraRotation.toInt())
115115
if (outputFile != null) {
116116
Timber.i("Recording to: ${outputFile.absolutePath}")
117117
vibrateDevice(context)

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.darkrockstudios.app.securecamera.camera
33
import android.annotation.SuppressLint
44
import android.content.Context
55
import android.graphics.RectF
6+
import android.view.Surface
67
import androidx.camera.core.*
78
import androidx.camera.lifecycle.ProcessCameraProvider
89
import androidx.camera.video.*
@@ -260,9 +261,12 @@ class CameraState internal constructor(
260261
* 2. On recording complete, the temp file is encrypted to .secv format
261262
* 3. The temp file is securely deleted after encryption
262263
* 4. Metadata entry is added to the encrypted sidecar
264+
*
265+
* @param context The application context
266+
* @param deviceRotation The current device rotation in degrees (0, 90, 180, 270) from accelerometer
263267
*/
264268
@SuppressLint("MissingPermission")
265-
fun startRecording(context: Context): File? {
269+
fun startRecording(context: Context, deviceRotation: Int = 0): File? {
266270
val videoCapture = this.videoCapture ?: run {
267271
Timber.e("VideoCapture not initialized")
268272
return null
@@ -273,6 +277,14 @@ class CameraState internal constructor(
273277
return null
274278
}
275279

280+
val surfaceRotation = when (deviceRotation) {
281+
90 -> Surface.ROTATION_90
282+
180 -> Surface.ROTATION_180
283+
270 -> Surface.ROTATION_270
284+
else -> Surface.ROTATION_0
285+
}
286+
videoCapture.targetRotation = surfaceRotation
287+
276288
// Create output directories
277289
val videosDir = File(context.filesDir, "videos")
278290
if (!videosDir.exists()) {

app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MediaMetadataEntry.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ data class MediaMetadataEntry(
148148
return buffer.array()
149149
}
150150

151+
/**
152+
* Creates an "empty" entry for pre-allocated slots.
153+
* The entry will have STATUS_EMPTY and all other fields zeroed.
154+
*/
155+
fun emptyEntryBytes(): ByteArray {
156+
val buffer = ByteBuffer.allocate(SecmFileFormat.ENTRY_PLAINTEXT_SIZE)
157+
buffer.order(ByteOrder.LITTLE_ENDIAN)
158+
buffer.put(SecmFileFormat.STATUS_EMPTY)
159+
// Rest is already zero-filled
160+
return buffer.array()
161+
}
162+
151163
/**
152164
* Extracts just the status byte from an entry's plaintext.
153165
*/

app/src/main/kotlin/com/darkrockstudios/app/securecamera/metadata/MetadataManager.kt

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -332,24 +332,63 @@ class MetadataManager(
332332
}
333333

334334
/**
335-
* Allocates a new slot at the end of the file.
335+
* Allocates a new slot by growing the file in chunks.
336+
*
337+
* @return The first newly allocated slot (for immediate use)
336338
*/
337339
private suspend fun allocateNewSlot(): Int {
338-
val newSlot = capacity
339-
capacity++
340+
val currentCapacity = capacity
341+
val firstNewSlot = currentCapacity
342+
343+
// Calculate growth: double current capacity, bounded by min/max
344+
val growthAmount = when {
345+
currentCapacity == 0 -> MIN_GROWTH_SLOTS
346+
currentCapacity < MAX_GROWTH_SLOTS -> currentCapacity.coerceAtLeast(MIN_GROWTH_SLOTS)
347+
else -> MAX_GROWTH_SLOTS
348+
}
349+
350+
val newCapacity = currentCapacity + growthAmount
340351

341-
// Update header capacity
342352
withContext(Dispatchers.IO) {
343-
indexFile?.let { raf ->
344-
raf.seek(SecmFileFormat.HEADER_OFFSET_CAPACITY.toLong())
345-
val buffer = ByteBuffer.allocate(4)
346-
buffer.order(ByteOrder.LITTLE_ENDIAN)
347-
buffer.putInt(capacity)
348-
raf.write(buffer.array())
353+
val raf = indexFile ?: error("Index file not open")
354+
val keyBytes = encryptionScheme.getDerivedKey()
355+
356+
// Write empty encrypted entries for all new slots
357+
for (slot in currentCapacity until newCapacity) {
358+
val plaintext = MediaMetadataEntry.emptyEntryBytes()
359+
val encrypted = encryptEntry(plaintext, keyBytes)
360+
raf.seek(SecmFileFormat.entryOffset(slot))
361+
raf.write(encrypted)
349362
}
363+
364+
// Update header capacity
365+
raf.seek(SecmFileFormat.HEADER_OFFSET_CAPACITY.toLong())
366+
val buffer = ByteBuffer.allocate(4)
367+
buffer.order(ByteOrder.LITTLE_ENDIAN)
368+
buffer.putInt(newCapacity)
369+
raf.write(buffer.array())
370+
371+
obfuscateIndexTimestamp()
350372
}
351373

352-
return newSlot
374+
// Add all new slots except the first one to free list
375+
for (slot in (firstNewSlot + 1) until newCapacity) {
376+
freeSlots.add(slot)
377+
}
378+
379+
capacity = newCapacity
380+
381+
Timber.d("Grew metadata capacity from $currentCapacity to $newCapacity (+$growthAmount slots)")
382+
383+
return firstNewSlot
384+
}
385+
386+
companion object {
387+
/** Minimum number of slots to allocate at once */
388+
private const val MIN_GROWTH_SLOTS = 64
389+
390+
/** Maximum number of slots to add in a single growth operation */
391+
private const val MAX_GROWTH_SLOTS = 512
353392
}
354393

355394
/**

app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.darkrockstudios.app.securecamera.usecases
22

33
import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository
4+
import com.darkrockstudios.app.securecamera.metadata.MetadataManager
45
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
56
import com.darkrockstudios.app.securecamera.security.SchemeConfig
67
import com.darkrockstudios.app.securecamera.security.pin.PinRepository
@@ -11,14 +12,16 @@ class CreatePinUseCase(
1112
private val encryptionScheme: EncryptionScheme,
1213
private val pinRepository: PinRepository,
1314
private val preferencesDataSource: AppSettingsDataSource,
14-
private val authorizePinUseCase: AuthorizePinUseCase
15+
private val authorizePinUseCase: AuthorizePinUseCase,
16+
private val metadataManager: MetadataManager,
1517
) {
1618
suspend fun createPin(pin: String, schemeConfig: SchemeConfig): Boolean {
1719
pinRepository.setAppPin(pin, schemeConfig)
1820
val hashedPin = authorizePinUseCase.authorizePin(pin)
1921
return if (hashedPin != null) {
2022
authorizationRepository.createKey(pin, hashedPin)
2123
encryptionScheme.deriveAndCacheKey(pin, hashedPin)
24+
metadataManager.loadIndex()
2225
preferencesDataSource.setIntroCompleted(true)
2326
true
2427
} else {

app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.darkrockstudios.app.securecamera.usecases
22

33
import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository
44
import com.darkrockstudios.app.securecamera.camera.SecureImageRepository
5+
import com.darkrockstudios.app.securecamera.metadata.MetadataManager
56
import com.darkrockstudios.app.securecamera.security.pin.PinRepository
67
import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme
78

@@ -11,6 +12,7 @@ class VerifyPinUseCase(
1112
private val pinRepository: PinRepository,
1213
private val encryptionScheme: EncryptionScheme,
1314
private val authorizePinUseCase: AuthorizePinUseCase,
15+
private val metadataManager: MetadataManager,
1416
) {
1517
suspend fun verifyPin(pin: String): Boolean {
1618
if (pinRepository.hasPoisonPillPin() && pinRepository.verifyPoisonPillPin(pin)) {
@@ -22,6 +24,7 @@ class VerifyPinUseCase(
2224
val hashedPin = authorizePinUseCase.authorizePin(pin)
2325
return if (hashedPin != null) {
2426
encryptionScheme.deriveAndCacheKey(pin, hashedPin)
27+
metadataManager.loadIndex()
2528
true
2629
} else {
2730
authRepository.incrementFailedAttempts()

app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import android.graphics.BitmapFactory
66
import com.ashampoo.kim.model.GpsCoordinates
77
import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository
88
import com.darkrockstudios.app.securecamera.camera.*
9+
import com.darkrockstudios.app.securecamera.metadata.MetadataManager
910
import com.darkrockstudios.app.securecamera.preferences.HashedPin
11+
import com.darkrockstudios.app.securecamera.security.FileTimestampObfuscator
1012
import com.darkrockstudios.app.securecamera.security.pin.PinRepository
1113
import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme
1214
import io.mockk.*
@@ -34,6 +36,8 @@ class SecureImageRepositoryTest {
3436
private lateinit var secureImageRepository: SecureImageRepository
3537
private lateinit var thumbnailCache: ThumbnailCache
3638
private lateinit var encryptionScheme: EncryptionScheme
39+
private lateinit var metadataManager: MetadataManager
40+
private lateinit var fileTimestampObfuscator: FileTimestampObfuscator
3741

3842
@Before
3943
fun setup() {
@@ -42,6 +46,8 @@ class SecureImageRepositoryTest {
4246
authorizationRepository = mockk(relaxed = true)
4347
thumbnailCache = mockk(relaxed = true)
4448
encryptionScheme = mockk()
49+
metadataManager = mockk(relaxed = true)
50+
fileTimestampObfuscator = mockk(relaxed = true)
4551

4652
// Mock the filesDir and cacheDir
4753
val filesDir = tempFolder.newFolder("files")
@@ -98,6 +104,8 @@ class SecureImageRepositoryTest {
98104
appContext = context,
99105
thumbnailCache = thumbnailCache,
100106
encryptionScheme = encryptionScheme,
107+
metadataManager = metadataManager,
108+
fileTimestampObfuscator = fileTimestampObfuscator,
101109
)
102110
}
103111

@@ -176,7 +184,7 @@ class SecureImageRepositoryTest {
176184
}
177185

178186
@Test
179-
fun `deleteImage should remove the photo file and thumbnail`() {
187+
fun `deleteImage should remove the photo file and thumbnail`() = runTest {
180188
// Given
181189
val galleryDir = secureImageRepository.getGalleryDirectory()
182190
galleryDir.mkdirs()
@@ -199,7 +207,7 @@ class SecureImageRepositoryTest {
199207
}
200208

201209
@Test
202-
fun `deleteImage should return false when photo does not exist`() {
210+
fun `deleteImage should return false when photo does not exist`() = runTest {
203211
// Given
204212
val galleryDir = secureImageRepository.getGalleryDirectory()
205213
galleryDir.mkdirs()
@@ -442,7 +450,7 @@ class SecureImageRepositoryTest {
442450
}
443451

444452
@Test
445-
fun `deleteAllImages should delete all photos`() {
453+
fun `deleteAllImages should delete all photos`() = runTest {
446454
// Given
447455
val galleryDir = secureImageRepository.getGalleryDirectory()
448456
galleryDir.mkdirs()

0 commit comments

Comments
 (0)