Skip to content

Commit e613d6e

Browse files
committed
Rewrote sharing system
No longer need to write write the plain bytes to disk, ever!
1 parent f11420b commit e613d6e

8 files changed

Lines changed: 267 additions & 243 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
android:name="android.support.FILE_PROVIDER_PATHS"
4242
android:resource="@xml/file_paths"/>
4343
</provider>
44+
45+
<provider
46+
android:name=".share.DecryptingImageProvider"
47+
android:authorities="${applicationId}.decryptingprovider"
48+
android:exported="false"
49+
android:grantUriPermissions="true"/>
4450
</application>
4551

4652
</manifest>

app/src/main/kotlin/com/darkrockstudios/app/securecamera/MainActivity.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ class MainActivity : ComponentActivity() {
1616
override fun onCreate(savedInstanceState: Bundle?) {
1717
super.onCreate(savedInstanceState)
1818

19-
clearShareDirectory(this)
20-
2119
if (BuildConfig.DEBUG.not()) {
2220
window.setFlags(
2321
WindowManager.LayoutParams.FLAG_SECURE,
@@ -49,7 +47,6 @@ class MainActivity : ComponentActivity() {
4947

5048
override fun onResume() {
5149
super.onResume()
52-
// Refresh permission status when the app comes back to the foreground
5350
locationRepository.refreshPermissionStatus()
5451
}
5552
}

app/src/main/kotlin/com/darkrockstudios/app/securecamera/ShareUtils.kt

Lines changed: 0 additions & 153 deletions
This file was deleted.

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

Lines changed: 2 additions & 5 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.PhotoDef
77
import com.darkrockstudios.app.securecamera.camera.SecureImageManager
88
import com.darkrockstudios.app.securecamera.preferences.AppPreferencesManager
9-
import com.darkrockstudios.app.securecamera.sharePhotosData
9+
import com.darkrockstudios.app.securecamera.share.sharePhotosWithProvider
1010
import kotlinx.coroutines.Dispatchers
1111
import kotlinx.coroutines.flow.update
1212
import kotlinx.coroutines.launch
@@ -107,11 +107,8 @@ class GalleryViewModel(
107107
val photoDefs = uiState.value.selectedPhotos.mapNotNull { imageManager.getPhotoByName(it) }
108108
if (photoDefs.isNotEmpty()) {
109109
viewModelScope.launch(Dispatchers.IO) {
110-
sharePhotosData(
110+
sharePhotosWithProvider(
111111
photos = photoDefs,
112-
sanitizeName = uiState.value.sanitizeFileName,
113-
sanitizeMetadata = uiState.value.sanitizeMetadata,
114-
imageManager = imageManager,
115112
context = context
116113
)
117114
withContext(Dispatchers.Main) {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.darkrockstudios.app.securecamera.share
2+
3+
import android.content.ContentProvider
4+
import android.content.ContentValues
5+
import android.database.Cursor
6+
import android.database.MatrixCursor
7+
import android.graphics.Bitmap.CompressFormat
8+
import android.graphics.BitmapFactory
9+
import android.net.Uri
10+
import android.os.Handler
11+
import android.os.Looper
12+
import android.os.ParcelFileDescriptor
13+
import android.os.ProxyFileDescriptorCallback
14+
import android.os.storage.StorageManager
15+
import android.provider.OpenableColumns
16+
import com.darkrockstudios.app.securecamera.camera.PhotoDef
17+
import com.darkrockstudios.app.securecamera.camera.SecureImageManager
18+
import com.darkrockstudios.app.securecamera.preferences.AppPreferencesManager
19+
import kotlinx.coroutines.flow.first
20+
import kotlinx.coroutines.runBlocking
21+
import org.koin.core.component.KoinComponent
22+
import org.koin.core.component.inject
23+
import java.io.ByteArrayOutputStream
24+
import kotlin.uuid.ExperimentalUuidApi
25+
import kotlin.uuid.Uuid
26+
27+
28+
/**
29+
* A ContentProvider that decrypts and streams images on-demand without writing decrypted data to disk.
30+
* This provider handles URIs in the format:
31+
* content://com.darkrockstudios.app.securecamera.decryptingprovider/photos/[photo_name]
32+
*/
33+
class DecryptingImageProvider : ContentProvider(), KoinComponent {
34+
35+
private val imageManager: SecureImageManager by inject()
36+
private val preferencesManager: AppPreferencesManager by inject()
37+
38+
@OptIn(ExperimentalUuidApi::class)
39+
private val uuid = Uuid.random()
40+
41+
override fun onCreate(): Boolean {
42+
return true
43+
}
44+
45+
override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String> {
46+
return arrayOf(MIME_TYPE)
47+
}
48+
49+
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
50+
if (mode != "r") return null
51+
52+
val segments = uri.pathSegments
53+
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() }
57+
58+
val ropc = ReadOnlyPhotoCallback(photoDef, sanitizeMetadata, imageManager)
59+
val storage = context!!.getSystemService(StorageManager::class.java)
60+
return storage.openProxyFileDescriptor(
61+
ParcelFileDescriptor.MODE_READ_ONLY,
62+
ropc,
63+
Handler(Looper.getMainLooper())
64+
)
65+
}
66+
67+
/**
68+
* Handles the query method to provide additional metadata about the file
69+
* This allows us to set a sanitized filename when the file is shared
70+
*/
71+
@OptIn(ExperimentalStdlibApi::class)
72+
override fun query(
73+
uri: Uri,
74+
projection: Array<out String>?,
75+
selection: String?,
76+
selectionArgs: Array<out String>?,
77+
sortOrder: String?
78+
): Cursor? {
79+
val segments = uri.pathSegments
80+
if (segments.size < 2) return null
81+
82+
val photoName = segments.last()
83+
val photoDef = imageManager.getPhotoByName(photoName) ?: return null
84+
85+
val sanitizeName = runBlocking { preferencesManager.sanitizeFileName.first() }
86+
val sanitizeMetadata = runBlocking { preferencesManager.sanitizeMetadata.first() }
87+
88+
val size = runBlocking {
89+
if (sanitizeMetadata)
90+
stripMetadataInMemory(imageManager.decryptJpg(photoDef)).size
91+
else
92+
imageManager.decryptJpg(photoDef).size
93+
}
94+
95+
val columnNames = projection ?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
96+
val row = arrayOfNulls<Any>(columnNames.size)
97+
for (i in columnNames.indices) {
98+
when (columnNames[i]) {
99+
OpenableColumns.DISPLAY_NAME -> {
100+
row[i] = getFileName(photoDef, sanitizeName)
101+
}
102+
103+
OpenableColumns.SIZE -> {
104+
row[i] = size
105+
}
106+
}
107+
}
108+
109+
val cursor = MatrixCursor(columnNames, 1)
110+
cursor.addRow(row)
111+
return cursor
112+
}
113+
114+
override fun getType(uri: Uri): String = MIME_TYPE
115+
116+
override fun insert(uri: Uri, values: ContentValues?): Uri? = error("insert Unsupported")
117+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
118+
error("delete Unsupported")
119+
120+
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
121+
error("update Unsupported")
122+
123+
@OptIn(ExperimentalUuidApi::class)
124+
private fun getFileName(photoDef: PhotoDef, sanitizeName: Boolean): String {
125+
return if (sanitizeName) {
126+
"image_" + uuid.toHexString() + ".jpg"
127+
128+
} else {
129+
photoDef.photoName
130+
}
131+
}
132+
133+
companion object {
134+
private const val MIME_TYPE = "image/jpeg"
135+
}
136+
}
137+
138+
private class ReadOnlyPhotoCallback(
139+
private val photoDef: PhotoDef,
140+
private val sanitizeMetadata: Boolean,
141+
private val imageManager: SecureImageManager,
142+
) : ProxyFileDescriptorCallback() {
143+
144+
private val decryptedBytes: ByteArray by lazy {
145+
if (sanitizeMetadata) {
146+
val bytes = runBlocking { imageManager.decryptJpg(photoDef) }
147+
stripMetadataInMemory(bytes)
148+
} else {
149+
runBlocking { imageManager.decryptJpg(photoDef) }
150+
}
151+
}
152+
153+
override fun onGetSize(): Long = decryptedBytes.size.toLong()
154+
155+
override fun onRead(offset: Long, size: Int, data: ByteArray): Int {
156+
if (offset >= decryptedBytes.size) return 0
157+
val actually = minOf(size, decryptedBytes.size - offset.toInt(), data.size)
158+
System.arraycopy(decryptedBytes, offset.toInt(), data, 0, actually)
159+
return actually
160+
}
161+
162+
override fun onRelease() {
163+
decryptedBytes.fill(0)
164+
}
165+
}
166+
167+
private fun stripMetadataInMemory(imageData: ByteArray): ByteArray {
168+
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
169+
return if (bitmap != null) {
170+
ByteArrayOutputStream().use { outputStream ->
171+
bitmap.compress(CompressFormat.JPEG, 90, outputStream)
172+
outputStream.toByteArray()
173+
}
174+
} else {
175+
error("Failed to strip metadata in memory")
176+
}
177+
}

0 commit comments

Comments
 (0)