Skip to content

Commit

Permalink
Introduce Vaults
Browse files Browse the repository at this point in the history
Encrypt your Media in a secure folder only accessible by your credentials

The vaults and media and their info are completely encrypted and stored in the app's private folder.
The encryption is a standard AES256_GCM scheme, but customisable options are on their way (with option to backup/restore the vault)

Fixes Ability to hide photos/videos in a hidden folder within the app #232
Fixes Hidden folders[Enhancement] #402

Signed-off-by: IacobIonut01 <[email protected]>
  • Loading branch information
IacobIonut01 committed Jun 23, 2024
1 parent 9917971 commit f065958
Show file tree
Hide file tree
Showing 43 changed files with 4,811 additions and 100 deletions.
23 changes: 22 additions & 1 deletion app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package com.dot.gallery.core
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.FileObserver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
Expand Down Expand Up @@ -40,4 +41,24 @@ fun Context.contentFlowObserver(uris: Array<Uri>) = callbackFlow {
awaitClose {
contentResolver.unregisterContentObserver(observer)
}
}.conflate().onEach { if (!it) delay(1000) }
}.conflate().onEach { if (!it) delay(1000) }


private var observerFileJob: Job? = null
fun Context.fileFlowObserver() = callbackFlow {
val observer = object : FileObserver(filesDir, CREATE or DELETE or MODIFY or MOVED_FROM or MOVED_TO) {
override fun onEvent(event: Int, path: String?) {
observerFileJob?.cancel()
observerFileJob = launch(Dispatchers.IO) {
send(true)
}
}
}
observer.startWatching()
observerFileJob = launch(Dispatchers.IO) {
send(true)
}
awaitClose {
observer.stopWatching()
}
}
13 changes: 13 additions & 0 deletions app/src/main/kotlin/com/dot/gallery/core/MediaState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package com.dot.gallery.core
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import com.dot.gallery.feature_node.domain.model.Album
import com.dot.gallery.feature_node.domain.model.EncryptedMedia
import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem
import com.dot.gallery.feature_node.domain.model.Media
import com.dot.gallery.feature_node.domain.model.MediaItem
import kotlinx.parcelize.Parcelize
Expand All @@ -29,4 +31,15 @@ data class MediaState(
data class AlbumState(
val albums: List<Album> = emptyList(),
val error: String = ""
) : Parcelable

@Immutable
@Parcelize
data class EncryptedMediaState(
val media: List<EncryptedMedia> = emptyList(),
val mappedMedia: List<EncryptedMediaItem> = emptyList(),
val mappedMediaWithMonthly: List<EncryptedMediaItem> = emptyList(),
val dateHeader: String = "",
val error: String = "",
val isLoading: Boolean = true
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PhotoAlbum
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
Expand Down Expand Up @@ -63,13 +63,15 @@ import com.dot.gallery.R
import com.dot.gallery.core.Settings.Misc.rememberOldNavbar
import com.dot.gallery.feature_node.presentation.util.NavigationItem
import com.dot.gallery.feature_node.presentation.util.Screen
import com.dot.gallery.ui.core.icons.Albums

@Composable
fun rememberNavigationItems(): List<NavigationItem> {
val timelineTitle = stringResource(R.string.nav_timeline)
val albumsTitle = stringResource(R.string.nav_albums)
val libraryTitle = stringResource(R.string.library)
return remember {
listOf(
mutableListOf(
NavigationItem(
name = timelineTitle,
route = Screen.TimelineScreen.route,
Expand All @@ -78,7 +80,12 @@ fun rememberNavigationItems(): List<NavigationItem> {
NavigationItem(
name = albumsTitle,
route = Screen.AlbumsScreen.route,
icon = Icons.Outlined.PhotoAlbum,
icon = com.dot.gallery.ui.core.Icons.Albums,
),
NavigationItem(
name = libraryTitle,
route = Screen.LibraryScreen(),
icon = Icons.Outlined.PhotoLibrary
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

package com.dot.gallery.core.presentation.components

import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -15,10 +16,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.NavType
Expand Down Expand Up @@ -46,13 +47,17 @@ import com.dot.gallery.feature_node.presentation.common.ChanneledViewModel
import com.dot.gallery.feature_node.presentation.common.MediaViewModel
import com.dot.gallery.feature_node.presentation.favorites.FavoriteScreen
import com.dot.gallery.feature_node.presentation.ignored.IgnoredScreen
import com.dot.gallery.feature_node.presentation.library.LibraryScreen
import com.dot.gallery.feature_node.presentation.mediaview.MediaViewScreen
import com.dot.gallery.feature_node.presentation.settings.SettingsScreen
import com.dot.gallery.feature_node.presentation.setup.SetupScreen
import com.dot.gallery.feature_node.presentation.timeline.TimelineScreen
import com.dot.gallery.feature_node.presentation.trashed.TrashedGridScreen
import com.dot.gallery.feature_node.presentation.util.Screen
import com.dot.gallery.feature_node.presentation.util.newImageLoader
import com.dot.gallery.feature_node.presentation.vault.VaultScreen
import com.dot.gallery.feature_node.presentation.vault.VaultViewModel
import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.EncryptedMediaViewScreen

@OptIn(ExperimentalCoilApi::class)
@Composable
Expand Down Expand Up @@ -97,6 +102,10 @@ fun NavigationComp(
var lastShouldDisplay by rememberSaveable {
mutableStateOf(bottomNavEntries.find { item -> item.route == currentDest } != null)
}
val shouldSkipAuth = rememberSaveable {
mutableStateOf(false)
}

LaunchedEffect(navBackStackEntry) {
navBackStackEntry?.destination?.route?.let {
val shouldDisplayBottomBar =
Expand All @@ -105,6 +114,12 @@ fun NavigationComp(
bottomBarState.value = shouldDisplayBottomBar
lastShouldDisplay = shouldDisplayBottomBar
}
if (it != Screen.VaultScreen()) {
shouldSkipAuth.value = false
}
if (it.contains(Screen.EncryptedMediaViewScreen())) {
shouldSkipAuth.value = true
}
systemBarFollowThemeState.value = !it.contains(Screen.MediaViewScreen.route)
}
}
Expand All @@ -117,6 +132,7 @@ fun NavigationComp(
val timelineViewModel = hiltViewModel<MediaViewModel>().apply {
attachToLifecycle()
}
val vaults by timelineViewModel.vaults.collectAsStateWithLifecycle()
LaunchedEffect(groupTimelineByMonth) {
timelineViewModel.groupByMonth = groupTimelineByMonth
}
Expand Down Expand Up @@ -287,7 +303,9 @@ fun NavigationComp(
mediaId = mediaId,
mediaState = if (albumId != -1L) timelineViewModel.customMediaState else timelineViewModel.mediaState,
albumsState = albumsViewModel.albumsState,
handler = timelineViewModel.handler
handler = timelineViewModel.handler,
addMedia = timelineViewModel::addMedia,
vaults = vaults
)
}
composable(
Expand Down Expand Up @@ -328,6 +346,7 @@ fun NavigationComp(
it.attachToLifecycle()
}
}
val vaults by viewModel.vaults.collectAsStateWithLifecycle()
MediaViewScreen(
navigateUp = navPipe::navigateUp,
toggleRotate = toggleRotate,
Expand All @@ -336,7 +355,9 @@ fun NavigationComp(
target = target,
mediaState = if (target == TARGET_FAVORITES) viewModel.customMediaState else viewModel.mediaState,
albumsState = albumsViewModel.albumsState,
handler = viewModel.handler
handler = viewModel.handler,
addMedia = viewModel::addMedia,
vaults = vaults
)
}
composable(
Expand All @@ -356,5 +377,59 @@ fun NavigationComp(
albumsState = albumsState
)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
composable(
route = Screen.VaultScreen()
) {
val vm = hiltViewModel<VaultViewModel>()

VaultScreen(
shouldSkipAuth = shouldSkipAuth,
navigateUp = navPipe::navigateUp,
navigate = navPipe::navigate,
vm = vm
)
}
composable(
route = Screen.EncryptedMediaViewScreen.id(),
arguments = listOf(
navArgument("mediaId") {
type = NavType.LongType
}
)
) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry(Screen.VaultScreen())
}
val mediaId =
backStackEntry.arguments?.getLong("mediaId")
val vm = hiltViewModel<VaultViewModel>(parentEntry)
EncryptedMediaViewScreen(
navigateUp = navPipe::navigateUp,
toggleRotate = toggleRotate,
paddingValues = paddingValues,
mediaId = mediaId ?: -1,
mediaState = vm.mediaState,
vault = vm.currentVault,
restoreMedia = vm::restoreMedia,
deleteMedia = vm::deleteMedia
)
}
}

composable(
route = Screen.LibraryScreen()
) {
LibraryScreen(
navigate = navPipe::navigate,
mediaViewModel = timelineViewModel,
toggleNavbar = navPipe::toggleNavbar,
paddingValues = paddingValues,
isScrolling = isScrolling,
searchBarActive = searchBarActive
)
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.dot.gallery.feature_node.data.data_source

import android.content.Context
import android.net.Uri
import android.security.keystore.UserNotAuthenticatedException
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
import androidx.security.crypto.MasterKey
import com.dot.gallery.feature_node.domain.model.Vault
import com.dot.gallery.feature_node.domain.model.fromByteArray
import com.dot.gallery.feature_node.domain.model.toByteArray
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.Serializable
import java.security.GeneralSecurityException
import javax.inject.Inject

class KeychainHolder @Inject constructor(
@ApplicationContext private val context: Context
) {

val filesDir: File = context.filesDir

fun vaultFolder(vault: Vault) = File(filesDir, vault.uuid.toString())
fun vaultInfoFile(vault: Vault) = File(vaultFolder(vault), VAULT_INFO_FILE_NAME)
fun Vault.mediaFile(mediaId: Long) = File(vaultFolder(this), "$mediaId.enc")

private val masterKey: MasterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()

fun writeVaultInfo(vault: Vault, onSuccess: () -> Unit = {}) {
val vaultFolder = File(filesDir, vault.uuid.toString())
if (!vaultFolder.exists()) {
vaultFolder.mkdir()
}

File(vaultFolder, VAULT_INFO_FILE_NAME).apply {
if (exists()) delete()
encrypt(vault)
}
onSuccess()
}

fun checkVaultFolder(vault: Vault) {
val mainFolder = File(filesDir, vault.uuid.toString())
if (!mainFolder.exists()) {
mainFolder.mkdir()
writeVaultInfo(vault)
}
}

@Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class)
fun <T : Serializable> File.decrypt(): T = EncryptedFile.Builder(
context,
this,
masterKey,
AES256_GCM_HKDF_4KB
).build().openFileInput().use {
fromByteArray(it.readBytes())
}


@Throws(GeneralSecurityException::class, IOException::class, FileNotFoundException::class, UserNotAuthenticatedException::class)
fun <T : Serializable> File.encrypt(data: T) {
EncryptedFile.Builder(
context,
this,
masterKey,
AES256_GCM_HKDF_4KB
).build().openFileOutput().use {
it.write(data.toByteArray())
}
}

@Throws(IOException::class)
fun getBytes(uri: Uri): ByteArray? =
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val byteBuffer = ByteArrayOutputStream()
val bufferSize = 1024
val buffer = ByteArray(bufferSize)

var len: Int
while (inputStream.read(buffer).also { len = it } != -1) {
byteBuffer.write(buffer, 0, len)
}
byteBuffer.toByteArray()
}

companion object {
const val VAULT_INFO_FILE_NAME = "info.vault"
const val VAULT_INFO_FILE = "/$VAULT_INFO_FILE_NAME"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import android.content.ContentValues
import android.database.Cursor
import android.database.MergeCursor
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Environment
Expand Down Expand Up @@ -134,6 +135,17 @@ fun ContentResolver.overrideImage(
}
}

fun ContentResolver.restoreImage(
byteArray: ByteArray,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG,
mimeType: String = "image/png",
relativePath: String = Environment.DIRECTORY_PICTURES + "/Restored",
displayName: String
): Uri? {
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
return saveImage(bitmap, format, mimeType, relativePath, displayName)
}

fun ContentResolver.saveImage(
bitmap: Bitmap,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG,
Expand Down
Loading

0 comments on commit f065958

Please sign in to comment.