Skip to content

Commit ef49da3

Browse files
committed
Implement video sharing
1 parent b29c9b9 commit ef49da3

File tree

8 files changed

+258
-56
lines changed

8 files changed

+258
-56
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
android:resource="@xml/file_paths"/>
6868
</provider>
6969
<provider
70-
android:name=".share.DecryptingImageProvider"
70+
android:name=".share.DecryptingMediaProvider"
7171
android:authorities="${applicationId}.decryptingprovider"
7272
android:exported="false"
7373
android:grantUriPermissions="true"/>

app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryContent.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ fun GalleryContent(
7878
GalleryTopNav(
7979
navController = navController,
8080
onDeleteClick = { viewModel.showDeleteConfirmation() },
81-
onShareClick = { viewModel.shareSelectedPhotos(context) },
81+
onShareClick = { viewModel.shareSelectedMedia(context) },
8282
onSelectAll = { viewModel.selectAllMedia() },
8383
isSelectionMode = uiState.isSelectionMode,
8484
selectedCount = uiState.selectedMedia.size,

app/src/main/kotlin/com/darkrockstudios/app/securecamera/gallery/GalleryViewModel.kt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import com.darkrockstudios.app.securecamera.BaseViewModel
66
import com.darkrockstudios.app.securecamera.camera.MediaItem
77
import com.darkrockstudios.app.securecamera.camera.SecureImageRepository
88
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
9-
import com.darkrockstudios.app.securecamera.share.sharePhotosWithProvider
9+
import com.darkrockstudios.app.securecamera.share.shareMediaWithProvider
1010
import kotlinx.coroutines.Dispatchers
1111
import kotlinx.coroutines.flow.update
1212
import kotlinx.coroutines.launch
@@ -102,13 +102,14 @@ class GalleryViewModel(
102102
}
103103
}
104104

105-
fun shareSelectedPhotos(context: Context) {
106-
// For now, only share photos (video sharing not implemented yet)
107-
val photoDefs = uiState.value.selectedMedia.mapNotNull { imageManager.getPhotoByName(it) }
108-
if (photoDefs.isNotEmpty()) {
105+
fun shareSelectedMedia(context: Context) {
106+
val mediaItems = uiState.value.selectedMedia.mapNotNull {
107+
imageManager.getMediaItemByName(it)
108+
}
109+
if (mediaItems.isNotEmpty()) {
109110
viewModelScope.launch(Dispatchers.IO) {
110-
sharePhotosWithProvider(
111-
photos = photoDefs,
111+
shareMediaWithProvider(
112+
mediaItems = mediaItems,
112113
context = context
113114
)
114115
withContext(Dispatchers.Main) {

app/src/main/kotlin/com/darkrockstudios/app/securecamera/share/DecryptingImageProvider.kt renamed to app/src/main/kotlin/com/darkrockstudios/app/securecamera/share/DecryptingMediaProvider.kt

Lines changed: 114 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import android.os.storage.StorageManager
1515
import android.provider.OpenableColumns
1616
import com.darkrockstudios.app.securecamera.camera.PhotoDef
1717
import com.darkrockstudios.app.securecamera.camera.SecureImageRepository
18+
import com.darkrockstudios.app.securecamera.camera.VideoDef
1819
import com.darkrockstudios.app.securecamera.preferences.AppSettingsDataSource
20+
import com.darkrockstudios.app.securecamera.security.streaming.StreamingDecryptor
1921
import kotlinx.coroutines.flow.first
2022
import kotlinx.coroutines.runBlocking
2123
import org.koin.core.component.KoinComponent
@@ -26,11 +28,12 @@ import kotlin.uuid.Uuid
2628

2729

2830
/**
29-
* A ContentProvider that decrypts and streams images on-demand without writing decrypted data to disk.
31+
* A ContentProvider that decrypts and streams photos and videos on-demand without writing decrypted data to disk.
3032
* This provider handles URIs in the format:
31-
* content://com.darkrockstudios.app.securecamera.decryptingprovider/photos/[photo_name]
33+
* - content://com.darkrockstudios.app.securecamera.decryptingprovider/photos/[photo_name]
34+
* - content://com.darkrockstudios.app.securecamera.decryptingprovider/videos/[video_name]
3235
*/
33-
class DecryptingImageProvider : ContentProvider(), KoinComponent {
36+
class DecryptingMediaProvider : ContentProvider(), KoinComponent {
3437

3538
private val imageManager: SecureImageRepository by inject()
3639
private val preferencesManager: AppSettingsDataSource by inject()
@@ -42,26 +45,55 @@ class DecryptingImageProvider : ContentProvider(), KoinComponent {
4245
return true
4346
}
4447

48+
companion object {
49+
const val PATH_PHOTOS = "photos"
50+
const val PATH_VIDEOS = "videos"
51+
}
52+
4553
override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String> {
46-
return arrayOf(MIME_TYPE)
54+
val segments = uri.pathSegments
55+
if (segments.size < 2) return emptyArray()
56+
57+
return when (segments[0]) {
58+
PATH_PHOTOS -> arrayOf("image/jpeg")
59+
PATH_VIDEOS -> arrayOf("video/mp4")
60+
else -> emptyArray()
61+
}
4762
}
4863

4964
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
5065
if (mode != "r") return null
5166

5267
val segments = uri.pathSegments
5368
if (segments.size < 2) return null
54-
val photoName = segments.last()
55-
val photoDef = imageManager.getPhotoByName(photoName) ?: return null
56-
val sanitizeMetadata = runBlocking { preferencesManager.sanitizeMetadata.first() }
5769

58-
val ropc = ReadOnlyPhotoCallback(photoDef, sanitizeMetadata, imageManager)
70+
val mediaType = segments[0] // "photos" or "videos"
71+
val mediaName = segments.last()
72+
5973
val storage = context!!.getSystemService(StorageManager::class.java)
60-
return storage.openProxyFileDescriptor(
61-
ParcelFileDescriptor.MODE_READ_ONLY,
62-
ropc,
63-
Handler(Looper.getMainLooper())
64-
)
74+
75+
return when (mediaType) {
76+
PATH_PHOTOS -> {
77+
val photoDef = imageManager.getPhotoByName(mediaName) ?: return null
78+
val sanitizeMetadata = runBlocking { preferencesManager.sanitizeMetadata.first() }
79+
val ropc = ReadOnlyPhotoCallback(photoDef, sanitizeMetadata, imageManager)
80+
storage.openProxyFileDescriptor(
81+
ParcelFileDescriptor.MODE_READ_ONLY,
82+
ropc,
83+
Handler(Looper.getMainLooper())
84+
)
85+
}
86+
PATH_VIDEOS -> {
87+
val videoDef = imageManager.getVideoByName(mediaName) ?: return null
88+
val rovc = ReadOnlyVideoCallback(videoDef, imageManager)
89+
storage.openProxyFileDescriptor(
90+
ParcelFileDescriptor.MODE_READ_ONLY,
91+
rovc,
92+
Handler(Looper.getMainLooper())
93+
)
94+
}
95+
else -> null
96+
}
6597
}
6698

6799
/**
@@ -79,25 +111,43 @@ class DecryptingImageProvider : ContentProvider(), KoinComponent {
79111
val segments = uri.pathSegments
80112
if (segments.size < 2) return null
81113

82-
val photoName = segments.last()
83-
val photoDef = imageManager.getPhotoByName(photoName) ?: return null
114+
val mediaType = segments[0]
115+
val mediaName = segments.last()
84116

85117
val sanitizeName = runBlocking { preferencesManager.sanitizeFileName.first() }
86-
val sanitizeMetadata = runBlocking { preferencesManager.sanitizeMetadata.first() }
87118

88-
val size = runBlocking {
89-
if (sanitizeMetadata)
90-
stripMetadataInMemory(imageManager.decryptJpg(photoDef)).size
91-
else
92-
imageManager.decryptJpg(photoDef).size
119+
val (displayName, size) = when (mediaType) {
120+
PATH_PHOTOS -> {
121+
val photoDef = imageManager.getPhotoByName(mediaName) ?: return null
122+
val sanitizeMetadata = runBlocking { preferencesManager.sanitizeMetadata.first() }
123+
val photoSize = runBlocking {
124+
if (sanitizeMetadata)
125+
stripMetadataInMemory(imageManager.decryptJpg(photoDef)).size
126+
else
127+
imageManager.decryptJpg(photoDef).size
128+
}
129+
Pair(getPhotoFileName(photoDef, sanitizeName), photoSize)
130+
}
131+
PATH_VIDEOS -> {
132+
val videoDef = imageManager.getVideoByName(mediaName) ?: return null
133+
val videoSize = runBlocking {
134+
val scheme = imageManager.getStreamingEncryptionScheme()!!
135+
val decryptor = scheme.createStreamingDecryptor(videoDef.videoFile)
136+
val size = decryptor.totalSize
137+
decryptor.close()
138+
size
139+
}
140+
Pair(getVideoFileName(videoDef, sanitizeName), videoSize)
141+
}
142+
else -> return null
93143
}
94144

95145
val columnNames = projection ?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
96146
val row = arrayOfNulls<Any>(columnNames.size)
97147
for (i in columnNames.indices) {
98148
when (columnNames[i]) {
99149
OpenableColumns.DISPLAY_NAME -> {
100-
row[i] = getFileName(photoDef, sanitizeName)
150+
row[i] = displayName
101151
}
102152

103153
OpenableColumns.SIZE -> {
@@ -111,7 +161,16 @@ class DecryptingImageProvider : ContentProvider(), KoinComponent {
111161
return cursor
112162
}
113163

114-
override fun getType(uri: Uri): String = MIME_TYPE
164+
override fun getType(uri: Uri): String {
165+
val segments = uri.pathSegments
166+
if (segments.size < 2) return "application/octet-stream"
167+
168+
return when (segments[0]) {
169+
PATH_PHOTOS -> "image/jpeg"
170+
PATH_VIDEOS -> "video/mp4"
171+
else -> "application/octet-stream"
172+
}
173+
}
115174

116175
override fun insert(uri: Uri, values: ContentValues?): Uri? = error("insert Unsupported")
117176
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
@@ -121,17 +180,21 @@ class DecryptingImageProvider : ContentProvider(), KoinComponent {
121180
error("update Unsupported")
122181

123182
@OptIn(ExperimentalUuidApi::class)
124-
private fun getFileName(photoDef: PhotoDef, sanitizeName: Boolean): String {
183+
private fun getPhotoFileName(photoDef: PhotoDef, sanitizeName: Boolean): String {
125184
return if (sanitizeName) {
126185
"image_" + uuid.toHexString() + ".jpg"
127-
128186
} else {
129187
photoDef.photoName
130188
}
131189
}
132190

133-
companion object {
134-
private const val MIME_TYPE = "image/jpeg"
191+
@OptIn(ExperimentalUuidApi::class)
192+
private fun getVideoFileName(videoDef: VideoDef, sanitizeName: Boolean): String {
193+
return if (sanitizeName) {
194+
"video_" + uuid.toHexString() + ".mp4"
195+
} else {
196+
videoDef.videoName
197+
}
135198
}
136199
}
137200

@@ -164,6 +227,30 @@ private class ReadOnlyPhotoCallback(
164227
}
165228
}
166229

230+
private class ReadOnlyVideoCallback(
231+
private val videoDef: VideoDef,
232+
private val imageManager: SecureImageRepository,
233+
) : ProxyFileDescriptorCallback() {
234+
235+
private val decryptor: StreamingDecryptor = runBlocking {
236+
val scheme = imageManager.getStreamingEncryptionScheme()!!
237+
scheme.createStreamingDecryptor(videoDef.videoFile)
238+
}
239+
240+
override fun onGetSize(): Long = decryptor.totalSize
241+
242+
override fun onRead(offset: Long, size: Int, data: ByteArray): Int {
243+
if (offset >= decryptor.totalSize) return 0
244+
return runBlocking {
245+
decryptor.read(offset, data, 0, size)
246+
}
247+
}
248+
249+
override fun onRelease() {
250+
decryptor.close()
251+
}
252+
}
253+
167254
private fun stripMetadataInMemory(imageData: ByteArray): ByteArray {
168255
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
169256
return if (bitmap != null) {

0 commit comments

Comments
 (0)