From 9bdb49efb0a5b334d2501cb3bb3a38f9c5beb345 Mon Sep 17 00:00:00 2001 From: IacobIonut01 Date: Sun, 23 Jun 2024 12:33:14 +0300 Subject: [PATCH] Introduce Vaults 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 --- app/build.gradle.kts | 2 +- .../com/dot/gallery/core/MediaObserver.kt | 23 +- .../kotlin/com/dot/gallery/core/MediaState.kt | 13 + .../core/presentation/components/AppBar.kt | 13 +- .../presentation/components/NavigationComp.kt | 81 ++- .../data/data_source/KeychainHolder.kt | 97 ++++ .../data/data_types/MediaStoreCursor.kt | 12 + .../data/repository/MediaRepositoryImpl.kt | 181 ++++++- .../domain/model/EncryptedMedia.kt | 153 ++++++ .../feature_node/domain/model/MediaItem.kt | 21 + .../feature_node/domain/model/Vault.kt | 12 + .../domain/repository/MediaRepository.kt | 38 +- .../domain/use_case/VaultUseCases.kt | 48 ++ .../presentation/albums/AlbumsScreen.kt | 85 +--- .../presentation/common/MediaViewModel.kt | 19 +- .../standalone/StandaloneActivity.kt | 10 +- .../standalone/StandaloneViewModel.kt | 20 +- .../presentation/util/ImageUtils.kt | 10 + .../feature_node/presentation/util/Screen.kt | 9 + .../presentation/util/StateExt.kt | 80 ++- .../presentation/vault/VaultDisplay.kt | 317 ++++++++++++ .../presentation/vault/VaultScreen.kt | 99 ++++ .../presentation/vault/VaultSetup.kt | 170 +++++++ .../presentation/vault/VaultViewModel.kt | 197 ++++++++ .../vault/components/DeleteVaultSheet.kt | 131 +++++ .../components/EncryptedMediaGridView.kt | 343 +++++++++++++ .../vault/components/EncryptedMediaImage.kt | 170 +++++++ .../components/EncryptedTimelineScroller.kt | 217 +++++++++ .../vault/components/EncryptedTrashDialog.kt | 298 +++++++++++ .../vault/components/NewVaultSheet.kt | 132 +++++ .../vault/components/SelectVaultSheet.kt | 103 ++++ .../EncryptedMediaViewScreen.kt | 271 ++++++++++ .../encryptedmediaview/components/AppBar.kt | 107 ++++ .../components/BottomBar.kt | 461 ++++++++++++++++++ .../components/media/MediaPreviewComponent.kt | 44 ++ .../components/media/ZoomablePagerImage.kt | 111 +++++ .../components/video/VideoDurationHeader.kt | 62 +++ .../components/video/VideoPlayer.kt | 223 +++++++++ .../components/video/VideoPlayerController.kt | 265 ++++++++++ .../vault/utils/BiometricManagerExt.kt | 89 ++++ .../com/dot/gallery/injection/AppModule.kt | 19 +- .../com/dot/gallery/ui/core/icons/Albums.kt | 79 +++ .../dot/gallery/ui/core/icons/Encrypted.kt | 66 +++ app/src/main/res/values/strings.xml | 12 + 44 files changed, 4812 insertions(+), 101 deletions(-) create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMedia.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/AppBar.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoDurationHeader.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/ui/core/icons/Albums.kt create mode 100644 app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 448f0ac8c..f02f0a621 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,7 +20,7 @@ android { applicationId = "com.dot.gallery" minSdk = 30 targetSdk = 34 - versionCode = 30000 + versionCode = 30001 versionName = "3.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt index e932e147f..304ebf213 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaObserver.kt @@ -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 @@ -40,4 +41,24 @@ fun Context.contentFlowObserver(uris: Array) = callbackFlow { awaitClose { contentResolver.unregisterContentObserver(observer) } -}.conflate().onEach { if (!it) delay(1000) } \ No newline at end of file +}.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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt b/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt index d3afc9b4b..496fe0cf9 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/MediaState.kt @@ -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 @@ -29,4 +31,15 @@ data class MediaState( data class AlbumState( val albums: List = emptyList(), val error: String = "" +) : Parcelable + +@Immutable +@Parcelize +data class EncryptedMediaState( + val media: List = emptyList(), + val mappedMedia: List = emptyList(), + val mappedMediaWithMonthly: List = emptyList(), + val dateHeader: String = "", + val error: String = "", + val isLoading: Boolean = true ) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt index 3388cc537..ed932eae8 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/AppBar.kt @@ -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 @@ -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 { 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, @@ -78,7 +80,12 @@ fun rememberNavigationItems(): List { 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 ) ) } diff --git a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt index 83b43c509..f52e911e3 100644 --- a/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt +++ b/app/src/main/kotlin/com/dot/gallery/core/presentation/components/NavigationComp.kt @@ -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 @@ -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 @@ -46,6 +47,7 @@ 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 @@ -53,6 +55,9 @@ 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 @@ -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 = @@ -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) } } @@ -117,6 +132,7 @@ fun NavigationComp( val timelineViewModel = hiltViewModel().apply { attachToLifecycle() } + val vaults by timelineViewModel.vaults.collectAsStateWithLifecycle() LaunchedEffect(groupTimelineByMonth) { timelineViewModel.groupByMonth = groupTimelineByMonth } @@ -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( @@ -328,6 +346,7 @@ fun NavigationComp( it.attachToLifecycle() } } + val vaults by viewModel.vaults.collectAsStateWithLifecycle() MediaViewScreen( navigateUp = navPipe::navigateUp, toggleRotate = toggleRotate, @@ -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( @@ -356,5 +377,59 @@ fun NavigationComp( albumsState = albumsState ) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + composable( + route = Screen.VaultScreen() + ) { + val vm = hiltViewModel() + + 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(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 + ) + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt new file mode 100644 index 000000000..670f75b15 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_source/KeychainHolder.kt @@ -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 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 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" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt index ec3cbc5cd..e78892ee5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/data_types/MediaStoreCursor.kt @@ -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 @@ -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, diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt index 323828d44..e83134f97 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/data/repository/MediaRepositoryImpl.kt @@ -10,15 +10,20 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle +import android.os.Environment import android.provider.MediaStore import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.IntentSenderRequest import androidx.core.app.ActivityOptionsCompat import com.dot.gallery.core.Resource import com.dot.gallery.core.contentFlowObserver +import com.dot.gallery.core.fileFlowObserver import com.dot.gallery.feature_node.data.data_source.InternalDatabase +import com.dot.gallery.feature_node.data.data_source.KeychainHolder +import com.dot.gallery.feature_node.data.data_source.KeychainHolder.Companion.VAULT_INFO_FILE_NAME import com.dot.gallery.feature_node.data.data_source.Query import com.dot.gallery.feature_node.data.data_types.copyMedia import com.dot.gallery.feature_node.data.data_types.findMedia @@ -34,9 +39,12 @@ import com.dot.gallery.feature_node.data.data_types.updateMedia import com.dot.gallery.feature_node.data.data_types.updateMediaExif import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ExifAttributes import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.PinnedAlbum +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.model.toEncryptedMedia import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.domain.util.OrderType @@ -44,13 +52,20 @@ import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.BOTH import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.PHOTOS import com.dot.gallery.feature_node.presentation.picker.AllowedMedia.VIDEOS +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.Serializable class MediaRepositoryImpl( private val context: Context, - private val database: InternalDatabase + private val database: InternalDatabase, + private val keychainHolder: KeychainHolder ) : MediaRepository { /** @@ -81,7 +96,10 @@ class MediaRepositoryImpl( it.getMediaTrashed().removeBlacklisted() } - override fun getAlbums(mediaOrder: MediaOrder, ignoreBlacklisted: Boolean): Flow>> = + override fun getAlbums( + mediaOrder: MediaOrder, + ignoreBlacklisted: Boolean + ): Flow>> = context.retrieveAlbums { it.getAlbums(mediaOrder = mediaOrder).toMutableList().apply { replaceAll { album -> @@ -344,6 +362,156 @@ class MediaRepositoryImpl( displayName: String ) = context.contentResolver.overrideImage(uri, bitmap, format, mimeType, relativePath, displayName) + override fun getVaults(): Flow>> = + context.retrieveInternalFiles { + with(keychainHolder) { + filesDir.listFiles() + ?.filter { it.isDirectory && File(it, VAULT_INFO_FILE_NAME).exists() } + ?.mapNotNull { + try { + File(it, VAULT_INFO_FILE_NAME).decrypt() as Vault + } catch (e: Exception) { + null + } + } + ?: emptyList() + } + } + + + override suspend fun createVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) { + var collected = false + getVaults().collectLatest { + if (!collected) { + collected = true + val alreadyExists = it.data?.contains(vault) ?: false + if (alreadyExists) { + onFailed("Vault \"${vault.name}\" exists") + } else { + keychainHolder.writeVaultInfo(vault, onSuccess) + } + } + } + } + + override suspend fun deleteVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) { + try { + with(keychainHolder) { vaultFolder(vault).deleteRecursively() } + } catch (e: IOException) { + e.printStackTrace() + onFailed("Failed to delete vault ${vault.name} (${vault.uuid})") + return + } + onSuccess() + } + + override fun getEncryptedMedia(vault: Vault): Flow>> = + context.retrieveInternalFiles { + with(keychainHolder) { + vaultFolder(vault).listFiles()?.filter { + it.name.endsWith("enc") + }?.mapNotNull { + try { + val id = it.nameWithoutExtension.toLong() + vault.mediaFile(id).decrypt() + } catch (e: Exception) { + null + } + } ?: emptyList() + } + } + + override suspend fun addMedia(vault: Vault, media: Media): Boolean = + withContext(Dispatchers.IO) { + with(keychainHolder) { + keychainHolder.checkVaultFolder(vault) + val output = vault.mediaFile(media.id) + if (output.exists()) output.delete() + val encryptedMedia = + getBytes(media.uri)?.let { bytes -> media.toEncryptedMedia(bytes) } + return@withContext try { + encryptedMedia?.let { + output.encrypt(it) + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + } + + override suspend fun restoreMedia(vault: Vault, media: EncryptedMedia): Boolean = + withContext(Dispatchers.IO) { + with(keychainHolder) { + checkVaultFolder(vault) + return@withContext try { + val output = vault.mediaFile(media.id) + val bitmap = BitmapFactory.decodeByteArray(media.bytes, 0, media.bytes.size) + val restored = saveImage( + bitmap = bitmap, + displayName = media.label, + mimeType = "image/png", + format = Bitmap.CompressFormat.PNG, + relativePath = Environment.DIRECTORY_PICTURES + "/Restored" + ) != null + val deleted = if (restored) output.delete() else false + restored && deleted + } catch (e: Exception) { + e.printStackTrace() + false + } + } + } + + override suspend fun deleteEncryptedMedia(vault: Vault, media: EncryptedMedia): Boolean = + withContext(Dispatchers.IO) { + with(keychainHolder) { + checkVaultFolder(vault) + return@withContext try { + vault.mediaFile(media.id).delete() + } catch (e: Exception) { + e.printStackTrace() + false + } + } + } + + override suspend fun deleteAllEncryptedMedia( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (failedFiles: List) -> Unit + ): Boolean = withContext(Dispatchers.IO) { + with(keychainHolder) { + checkVaultFolder(vault) + val failedFiles = mutableListOf() + val files = vaultFolder(vault).listFiles() + files?.forEach { file -> + try { + file.delete() + } catch (e: Exception) { + e.printStackTrace() + failedFiles.add(file) + } + } + if (failedFiles.isEmpty()) { + onSuccess() + true + } else { + onFailed(failedFiles) + false + } + } + } + private fun List.removeBlacklisted(): List = toMutableList().apply { removeAll { media -> database.getBlacklistDao().albumIsBlacklisted(media.albumID) @@ -391,5 +559,14 @@ class MediaRepositoryImpl( Resource.Error(message = e.localizedMessage ?: "An error occurred") } }.conflate() + + private fun Context.retrieveInternalFiles(dataBody: suspend (ContentResolver) -> List) = + fileFlowObserver().map { + try { + Resource.Success(data = dataBody.invoke(contentResolver)) + } catch (e: Exception) { + Resource.Error(message = e.localizedMessage ?: "An error occurred") + } + }.conflate() } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMedia.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMedia.kt new file mode 100644 index 000000000..1a70f742c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/EncryptedMedia.kt @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInput +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +@Immutable +@Parcelize +data class EncryptedMedia( + val id: Long = 0, + val label: String, + val bytes: ByteArray, + val path: String, + val timestamp: Long, + val mimeType: String, + val duration: String? = null, +) : Parcelable, Serializable { + + @IgnoredOnParcel + @Stable + val isVideo: Boolean = mimeType.startsWith("video/") && duration != null + + @IgnoredOnParcel + @Stable + val isImage: Boolean = mimeType.startsWith("image/") + + @Stable + override fun toString(): String { + return "$id, $path, $timestamp, $mimeType" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as EncryptedMedia + + if (id != other.id) return false + if (label != other.label) return false + if (!bytes.contentEquals(other.bytes)) return false + if (path != other.path) return false + if (timestamp != other.timestamp) return false + if (mimeType != other.mimeType) return false + if (duration != other.duration) return false + if (isVideo != other.isVideo) return false + if (isImage != other.isImage) return false + if (isRaw != other.isRaw) return false + if (fileExtension != other.fileExtension) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + label.hashCode() + result = 31 * result + bytes.contentHashCode() + result = 31 * result + path.hashCode() + result = 31 * result + timestamp.hashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + (duration?.hashCode() ?: 0) + result = 31 * result + isVideo.hashCode() + result = 31 * result + isImage.hashCode() + result = 31 * result + isRaw.hashCode() + result = 31 * result + fileExtension.hashCode() + return result + } + + + /** + * Determine if the current media is a raw format + * + * Checks if [mimeType] starts with "image/x-" or "image/vnd." + * + * Most used formats: + * - ARW: image/x-sony-arw + * - CR2: image/x-canon-cr2 + * - CRW: image/x-canon-crw + * - DCR: image/x-kodak-dcr + * - DNG: image/x-adobe-dng + * - ERF: image/x-epson-erf + * - K25: image/x-kodak-k25 + * - KDC: image/x-kodak-kdc + * - MRW: image/x-minolta-mrw + * - NEF: image/x-nikon-nef + * - ORF: image/x-olympus-orf + * - PEF: image/x-pentax-pef + * - RAF: image/x-fuji-raf + * - RAW: image/x-panasonic-raw + * - SR2: image/x-sony-sr2 + * - SRF: image/x-sony-srf + * - X3F: image/x-sigma-x3f + * + * Other proprietary image types in the standard: + * image/vnd.manufacturer.filename_extension for instance for NEF by Nikon and .mrv for Minolta: + * - NEF: image/vnd.nikon.nef + * - Minolta: image/vnd.minolta.mrw + */ + @IgnoredOnParcel + @Stable + val isRaw: Boolean = + mimeType.isNotBlank() && (mimeType.startsWith("image/x-") || mimeType.startsWith("image/vnd.")) + + @IgnoredOnParcel + @Stable + val fileExtension: String = label.substringAfterLast(".").removePrefix(".") + +} + +fun Media.toEncryptedMedia(bytes: ByteArray): EncryptedMedia { + return EncryptedMedia( + id = id, + label = label, + bytes = bytes, + path = path, + timestamp = timestamp, + mimeType = mimeType, + duration = duration + ) +} + +@Suppress("UNCHECKED_CAST") +fun fromByteArray(byteArray: ByteArray): T { + val byteArrayInputStream = ByteArrayInputStream(byteArray) + val objectInput: ObjectInput = ObjectInputStream(byteArrayInputStream) + val result = objectInput.readObject() as T + objectInput.close() + byteArrayInputStream.close() + return result +} + +fun Serializable.toByteArray(): ByteArray { + val byteArrayOutputStream = ByteArrayOutputStream() + val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) + objectOutputStream.writeObject(this) + objectOutputStream.flush() + val result = byteArrayOutputStream.toByteArray() + byteArrayOutputStream.close() + objectOutputStream.close() + return result +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt index ca89d4818..06d684bd5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/MediaItem.kt @@ -30,6 +30,27 @@ sealed class MediaItem : Parcelable { } +@Parcelize +@Immutable +sealed class EncryptedMediaItem : Parcelable { + @Stable + abstract val key: String + + @Immutable + data class Header( + override val key: String, + val text: String, + val data: List + ) : EncryptedMediaItem() + + @Immutable + data class MediaViewItem( + override val key: String, + val media: EncryptedMedia + ) : EncryptedMediaItem() + +} + val Any.isHeaderKey: Boolean get() = this is String && this.startsWith("header_") diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt new file mode 100644 index 000000000..76720f2db --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/model/Vault.kt @@ -0,0 +1,12 @@ +package com.dot.gallery.feature_node.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.io.Serializable +import java.util.UUID + +@Parcelize +data class Vault( + val uuid: UUID = UUID.randomUUID(), + val name: String +): Parcelable, Serializable diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt index d19e131a9..2a68da67d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/repository/MediaRepository.kt @@ -12,12 +12,15 @@ import androidx.activity.result.IntentSenderRequest import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.Album import com.dot.gallery.feature_node.domain.model.BlacklistedAlbum +import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ExifAttributes import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.domain.model.PinnedAlbum +import com.dot.gallery.feature_node.domain.model.Vault import com.dot.gallery.feature_node.domain.util.MediaOrder import com.dot.gallery.feature_node.presentation.picker.AllowedMedia import kotlinx.coroutines.flow.Flow +import java.io.File interface MediaRepository { @@ -48,7 +51,10 @@ interface MediaRepository { fun getMediaByAlbumId(albumId: Long): Flow>> - fun getMediaByAlbumIdWithType(albumId: Long, allowedMedia: AllowedMedia): Flow>> + fun getMediaByAlbumIdWithType( + albumId: Long, + allowedMedia: AllowedMedia + ): Flow>> fun getAlbumsWithType(allowedMedia: AllowedMedia): Flow>> @@ -69,7 +75,7 @@ interface MediaRepository { ) suspend fun copyMedia( - from: Media, + from: Media, path: String ): Boolean @@ -110,4 +116,32 @@ interface MediaRepository { displayName: String ): Boolean + fun getVaults(): Flow>> + + suspend fun createVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) + + suspend fun deleteVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) + + fun getEncryptedMedia(vault: Vault): Flow>> + + suspend fun addMedia(vault: Vault, media: Media): Boolean + + suspend fun restoreMedia(vault: Vault, media: EncryptedMedia): Boolean + + suspend fun deleteEncryptedMedia(vault: Vault, media: EncryptedMedia): Boolean + + suspend fun deleteAllEncryptedMedia( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (failedFiles: List) -> Unit + ): Boolean + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt new file mode 100644 index 000000000..ced383d98 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/domain/use_case/VaultUseCases.kt @@ -0,0 +1,48 @@ +package com.dot.gallery.feature_node.domain.use_case + +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.repository.MediaRepository +import kotlinx.coroutines.flow.map +import java.io.File + +class VaultUseCases( + private val repository: MediaRepository +) { + + fun getVaults() = repository.getVaults().map { it.data ?: emptyList() } + + suspend fun createVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) = repository.createVault(vault, onSuccess, onFailed) + + suspend fun deleteVault( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: String) -> Unit + ) = repository.deleteVault(vault, onSuccess, onFailed) + + fun getEncryptedMedia(vault: Vault) = + repository.getEncryptedMedia(vault) + + suspend fun addMedia(vault: Vault, media: Media) = + repository.addMedia(vault, media) + + suspend fun restoreMedia(vault: Vault, media: EncryptedMedia) = + repository.restoreMedia(vault, media) + + suspend fun deleteEncryptedMedia(vault: Vault, media: EncryptedMedia) = + repository.deleteEncryptedMedia(vault, media) + + suspend fun deleteAllEncryptedMedia( + vault: Vault, + onSuccess: () -> Unit, + onFailed: (reason: List) -> Unit + ) = repository.deleteAllEncryptedMedia(vault, onSuccess, onFailed) + +} + + diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt index dfd99b0b2..2807b6b25 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/albums/AlbumsScreen.kt @@ -8,23 +8,13 @@ package com.dot.gallery.feature_node.presentation.albums import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -34,10 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -98,23 +85,10 @@ fun AlbumsScreen( isScrolling = isScrolling, activeState = searchBarActive ) { - var expandedDropdown by remember { mutableStateOf(false) } - IconButton(onClick = { expandedDropdown = !expandedDropdown }) { + IconButton(onClick = { navigate(Screen.SettingsScreen.route) }) { Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = stringResource(R.string.drop_down_cd) - ) - } - DropdownMenu( - expanded = expandedDropdown, - onDismissRequest = { expandedDropdown = false } - ) { - DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.settings_title)) }, - onClick = { - expandedDropdown = false - navigate(Screen.SettingsScreen.route) - } + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.settings_title) ) } } @@ -137,55 +111,6 @@ fun AlbumsScreen( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - item( - span = { GridItemSpan(maxLineSpan) }, - key = "headerButtons" - ) { - Row( - modifier = Modifier - .pinchItem(key = "headerButtons" ) - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.TrashedScreen.route) - }, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Icon( - imageVector = Icons.Outlined.DeleteOutline, - contentDescription = stringResource(id = R.string.trash) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = R.string.trash) - ) - } - Button( - modifier = Modifier.weight(1f), - onClick = { - navigate(Screen.FavoriteScreen.route) - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ) - ) { - Icon( - imageVector = Icons.Outlined.FavoriteBorder, - contentDescription = stringResource(id = R.string.favorites) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(id = R.string.favorites) - ) - } - } - } if (pinnedState.albums.isNotEmpty()) { item( span = { GridItemSpan(maxLineSpan) }, @@ -196,7 +121,7 @@ fun AlbumsScreen( modifier = Modifier .pinchItem(key = "pinnedAlbums") .padding(horizontal = 8.dp) - .padding(bottom = 24.dp), + .padding(vertical = 24.dp), text = stringResource(R.string.pinned_albums_title), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt index 1ffee9a53..81996a68d 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/common/MediaViewModel.kt @@ -16,7 +16,9 @@ import androidx.lifecycle.viewModelScope import com.dot.gallery.core.MediaState import com.dot.gallery.core.Resource import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.Vault import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.domain.use_case.VaultUseCases import com.dot.gallery.feature_node.presentation.util.RepeatOnResume import com.dot.gallery.feature_node.presentation.util.collectMedia import com.dot.gallery.feature_node.presentation.util.mediaFlow @@ -33,7 +35,8 @@ import javax.inject.Inject @HiltViewModel open class MediaViewModel @Inject constructor( - private val mediaUseCases: MediaUseCases + private val mediaUseCases: MediaUseCases, + private val vaultUseCases: VaultUseCases ) : ViewModel() { var lastQuery = mutableStateOf("") @@ -48,6 +51,9 @@ open class MediaViewModel @Inject constructor( val selectedPhotoState = mutableStateListOf() val handler = mediaUseCases.mediaHandleUseCase + private val _vaults = MutableStateFlow>(emptyList()) + val vaults = _vaults.asStateFlow() + var albumId: Long = -1L var target: String? = null @@ -67,6 +73,12 @@ open class MediaViewModel @Inject constructor( } } + fun addMedia(vault: Vault, media: Media) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.addMedia(vault, media) + } + } + fun clearQuery() { queryMedia("") } @@ -195,6 +207,11 @@ open class MediaViewModel @Inject constructor( ) } } + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.getVaults().collectLatest { + _vaults.emit(it) + } + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt index 44f091cf4..201dd7276 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneActivity.kt @@ -10,9 +10,12 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory import com.dot.gallery.core.AlbumState @@ -30,6 +33,7 @@ class StandaloneActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() val action = intent.action.toString() val isSecure = action.lowercase().contains("secure") val clipData = intent.clipData @@ -49,7 +53,7 @@ class StandaloneActivity : ComponentActivity() { reviewMode = action.lowercase().contains("review") dataList = uriList.toList() } - + val vaults by viewModel.vaults.collectAsStateWithLifecycle() MediaViewScreen( navigateUp = { finish() }, toggleRotate = ::toggleOrientation, @@ -58,7 +62,9 @@ class StandaloneActivity : ComponentActivity() { mediaId = viewModel.mediaId, mediaState = viewModel.mediaState, albumsState = MutableStateFlow(AlbumState()), - handler = viewModel.handler + handler = viewModel.handler, + addMedia = viewModel::addMedia, + vaults = vaults ) } BackHandler { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt index ef4664ffc..5ad528ae5 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/standalone/StandaloneViewModel.kt @@ -11,7 +11,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.dot.gallery.core.MediaState import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.Vault import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.domain.use_case.VaultUseCases import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -26,7 +28,8 @@ import javax.inject.Inject class StandaloneViewModel @Inject constructor( @ApplicationContext private val applicationContext: Context, - private val mediaUseCases: MediaUseCases + private val mediaUseCases: MediaUseCases, + private val vaultUseCases: VaultUseCases ) : ViewModel() { private val _mediaState = MutableStateFlow(MediaState()) @@ -44,6 +47,16 @@ class StandaloneViewModel @Inject constructor( var mediaId: Long = -1 + + private val _vaults = MutableStateFlow>(emptyList()) + val vaults = _vaults.asStateFlow() + + fun addMedia(vault: Vault, media: Media) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.addMedia(vault, media) + } + } + private fun getMedia(clipDataUriList: List = emptyList()) { viewModelScope.launch(Dispatchers.IO) { if (clipDataUriList.isNotEmpty()) { @@ -60,6 +73,11 @@ class StandaloneViewModel @Inject constructor( } } } + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.getVaults().collectLatest { + _vaults.emit(it) + } + } } private fun mediaFromUris(): MediaState { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt index fec74ddc6..90eb60783 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/ImageUtils.kt @@ -22,10 +22,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.app.ShareCompat import androidx.exifinterface.media.ExifInterface +import com.dot.gallery.feature_node.domain.model.EncryptedMedia import com.dot.gallery.feature_node.domain.model.ImageFilter import com.dot.gallery.feature_node.domain.model.Media import com.dot.gallery.feature_node.presentation.mediaview.components.InfoRow import com.dot.gallery.feature_node.presentation.mediaview.components.retrieveMetadata +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.UriByteDataHelper import jp.co.cyberagent.android.gpuimage.GPUImage import jp.co.cyberagent.android.gpuimage.filter.GPUImageColorMatrixFilter import jp.co.cyberagent.android.gpuimage.filter.GPUImageFilter @@ -554,6 +556,14 @@ fun Context.shareMedia(media: Media) { .startChooser() } +fun Context.shareMedia(media: EncryptedMedia) { + ShareCompat + .IntentBuilder(this) + .setType(media.mimeType) + .addStream(UriByteDataHelper.getUri(media.bytes, media.duration != null)) + .startChooser() +} + fun Context.shareMedia(mediaList: List) { val mimeTypes = if (mediaList.find { it.duration != null } != null) { diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt index e8a4e2336..a418babc6 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/Screen.kt @@ -29,5 +29,14 @@ sealed class Screen(val route: String) { data object SetupScreen: Screen("setup_screen") + data object VaultScreen : Screen("vault_screen") + data object EncryptedMediaViewScreen : Screen("encrypted_media_view_screen") { + fun id() = "$route?mediaId={mediaId}" + + fun id(id: Long) = "$route?mediaId=$id" + } + + data object LibraryScreen : Screen("library_screen") + operator fun invoke() = route } diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt index 42702541d..10e7de10c 100644 --- a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/util/StateExt.kt @@ -8,13 +8,16 @@ package com.dot.gallery.feature_node.presentation.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.dot.gallery.core.Constants +import com.dot.gallery.core.EncryptedMediaState import com.dot.gallery.core.MediaState import com.dot.gallery.core.Resource +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 com.dot.gallery.feature_node.domain.use_case.MediaUseCases @@ -130,7 +133,7 @@ suspend fun MutableStateFlow.collectMedia( } withContext(Dispatchers.Main) { println("-->Media mapping took: ${System.currentTimeMillis() - timeStart}ms") - tryEmit( + emit( MediaState( isLoading = false, error = error, @@ -148,4 +151,75 @@ private fun List.dateHeader(albumId: Long): String = val startDate: DateExt = last().timestamp.getDateExt() val endDate: DateExt = first().timestamp.getDateExt() getDateHeader(startDate, endDate) - } else "" \ No newline at end of file + } else "" + +suspend fun MutableStateFlow.collectEncryptedMedia( + data: List, + groupByMonth: Boolean = false, + withMonthHeader: Boolean = true +) { + val timeStart = System.currentTimeMillis() + val mappedData = mutableListOf() + val mappedDataWithMonthly = mutableListOf() + val monthHeaderList: MutableSet = mutableSetOf() + withContext(Dispatchers.IO) { + val groupedData = data.groupBy { + if (groupByMonth) { + it.timestamp.getMonth() + } else { + it.timestamp.getDate( + stringToday = "Today" + /** Localized in composition */ + , + stringYesterday = "Yesterday" + /** Localized in composition */ + ) + } + } + groupedData.forEach { (date, data) -> + val dateHeader = EncryptedMediaItem.Header("header_$date", date, data) + val groupedMedia = data.map { + EncryptedMediaItem.MediaViewItem("media_${it.id}_${it.label}", it) + } + if (groupByMonth) { + mappedData.add(dateHeader) + mappedData.addAll(groupedMedia) + mappedDataWithMonthly.add(dateHeader) + mappedDataWithMonthly.addAll(groupedMedia) + } else { + val month = getMonth(date) + if (month.isNotEmpty() && !monthHeaderList.contains(month)) { + monthHeaderList.add(month) + if (withMonthHeader && mappedDataWithMonthly.isNotEmpty()) { + mappedDataWithMonthly.add( + EncryptedMediaItem.Header( + "header_big_${month}_${data.size}", + month, + emptyList() + ) + ) + } + } + mappedData.add(dateHeader) + if (withMonthHeader) { + mappedDataWithMonthly.add(dateHeader) + } + mappedData.addAll(groupedMedia) + if (withMonthHeader) { + mappedDataWithMonthly.addAll(groupedMedia) + } + } + } + } + withContext(Dispatchers.Main) { + println("-->Media mapping took: ${System.currentTimeMillis() - timeStart}ms") + emit( + EncryptedMediaState( + isLoading = false, + media = data, + mappedMedia = mappedData, + mappedMediaWithMonthly = if (withMonthHeader) mappedDataWithMonthly else emptyList(), + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt new file mode 100644 index 000000000..59af6397f --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultDisplay.kt @@ -0,0 +1,317 @@ +package com.dot.gallery.feature_node.presentation.vault + +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dokar.pinchzoomgrid.PinchZoomGridLayout +import com.dokar.pinchzoomgrid.rememberPinchZoomGridState +import com.dot.gallery.R +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.Constants.cellsList +import com.dot.gallery.core.Settings.Misc.rememberGridSize +import com.dot.gallery.core.presentation.components.EmptyMedia +import com.dot.gallery.core.presentation.components.Error +import com.dot.gallery.core.presentation.components.LoadingMedia +import com.dot.gallery.feature_node.presentation.picker.PickerActivityContract +import com.dot.gallery.feature_node.presentation.util.Screen +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.feature_node.presentation.vault.components.DeleteVaultSheet +import com.dot.gallery.feature_node.presentation.vault.components.EncryptedMediaGridView +import com.dot.gallery.feature_node.presentation.vault.components.NewVaultSheet +import com.dot.gallery.feature_node.presentation.vault.components.SelectVaultSheet +import kotlinx.coroutines.launch + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultDisplay( + navigateUp: () -> Unit, + navigate: (route: String) -> Unit, + onCreateVaultClick: () -> Unit, + vm: VaultViewModel +) { + val state by vm.mediaState.collectAsStateWithLifecycle() + val vaults by vm.vaults.collectAsStateWithLifecycle() + val currentVault by vm.currentVault.collectAsStateWithLifecycle() + + LaunchedEffect(vaults) { + if (vaults.isNotEmpty()) { + vm.setVault(currentVault) { + //TODO: Add error handling + } + } + } + + var lastCellIndex by rememberGridSize() + + val pinchState = rememberPinchZoomGridState( + cellsList = cellsList, + initialCellsIndex = lastCellIndex + ) + + var canScroll by rememberSaveable { mutableStateOf(true) } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + state = rememberTopAppBarState(), + canScroll = { canScroll }, + ) + + LaunchedEffect(pinchState.isZooming) { + canScroll = !pinchState.isZooming + lastCellIndex = cellsList.indexOf(pinchState.currentCells) + } + + val pickerLauncher = rememberLauncherForActivityResult(PickerActivityContract()) { mediaList -> + mediaList.forEach { + vm.addMedia(it) + } + } + + val scope = rememberCoroutineScope() + val newVaultSheetState = rememberAppBottomSheetState() + val deleteVaultSheetState = rememberAppBottomSheetState() + + Box { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + floatingActionButton = { + FloatingActionButton( + onClick = { + pickerLauncher.launch(Unit) + } + ) { + Icon(imageVector = Icons.Outlined.Add, contentDescription = stringResource(R.string.add_media_to_vault_cd)) + } + }, + topBar = { + TopAppBar( + title = { + Column { + val sheetState = rememberAppBottomSheetState() + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { + scope.launch { + if (vaults.size > 1) { + sheetState.show() + } + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = currentVault?.name ?: stringResource(R.string.unknown_vault) + ) + if (vaults.size > 1) { + Icon( + modifier = Modifier + .size(24.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape + ), + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + SelectVaultSheet( + state = sheetState, + vaults = vaults.filterNot { it == currentVault }, + onVaultSelected = { vault -> + scope.launch { + vm.setVault(vault) {} + } + } + ) + } + }, + navigationIcon = { + IconButton(onClick = navigateUp) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back_cd) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { + PinchZoomGridLayout(state = pinchState) { + AnimatedVisibility( + visible = state.isLoading, + enter = enterAnimation, + exit = exitAnimation + ) { + LoadingMedia( + paddingValues = PaddingValues( + top = it.calculateTopPadding() + 56.dp, + bottom = it.calculateBottomPadding() + ) + ) + } + EncryptedMediaGridView( + mediaState = state, + allowSelection = true, + showSearchBar = false, + enableStickyHeaders = false, + paddingValues = it, + canScroll = canScroll, + showMonthlyHeader = false, + isScrolling = remember { mutableStateOf(false) }, + aboveGridContent = { + Column { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp) + .horizontalScroll(state = rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SuggestionChip( + onClick = { + scope.launch { + newVaultSheetState.show() + } + }, + label = { Text(text = stringResource(R.string.new_vault)) }, + border = null, + shape = CircleShape, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + iconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + icon = { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null + ) + } + ) + SuggestionChip( + onClick = { + scope.launch { + deleteVaultSheetState.show() + } + }, + label = { Text(text = stringResource(R.string.delete_vault)) }, + border = null, + shape = CircleShape, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer, + iconContentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + icon = { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = null + ) + } + ) + } + val showError = remember(state) { state.error.isNotEmpty() } + AnimatedVisibility( + visible = showError, + modifier = Modifier + .padding(it) + .fillMaxWidth() + ) { + Error(errorMessage = state.error) + } + val showEmpty = + remember(state) { state.media.isEmpty() && !state.isLoading && !showError } + AnimatedVisibility( + visible = showEmpty, + modifier = Modifier + .padding(it) + .fillMaxWidth() + ) { + EmptyMedia() + } + } + NewVaultSheet( + state = newVaultSheetState, + onConfirm = onCreateVaultClick + ) + DeleteVaultSheet( + state = deleteVaultSheetState, + onConfirm = { + vm.deleteVault(currentVault!!) + if (vaults.isEmpty()) { + navigateUp() + } + } + ) + }, + onMediaClick = { encryptedMedia -> + navigate(Screen.EncryptedMediaViewScreen.id(encryptedMedia.id)) + } + ) + } + } + /*SelectionSheet( + modifier = Modifier + .align(Alignment.BottomEnd), + target = target, + selectedMedia = selectedMedia, + selectionState = selectionState, + albumsState = albumsState, + handler = handler + )*/ + + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt new file mode 100644 index 000000000..592013c09 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultScreen.kt @@ -0,0 +1,99 @@ +package com.dot.gallery.feature_node.presentation.vault + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt.PromptInfo +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dot.gallery.R +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.feature_node.presentation.vault.utils.rememberBiometricState + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun VaultScreen( + shouldSkipAuth: MutableState, + navigateUp: () -> Unit, + navigate: (route: String) -> Unit, + vm: VaultViewModel +) { + + val vaults by vm.vaults.collectAsStateWithLifecycle() + val currentVault by vm.currentVault.collectAsStateWithLifecycle() + + var isAuthenticated by remember { mutableStateOf(shouldSkipAuth.value) } + val biometricState = rememberBiometricState( + onSuccess = { + isAuthenticated = true + }, + onFailed = { isAuthenticated = false }, + biometricPromptInfo = PromptInfo.Builder() + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .setTitle(stringResource(R.string.biometric_authentication)) + .setSubtitle(stringResource(R.string.unlock_your_vault)) + .build() + ) + var addNewVault by remember { mutableStateOf(false) } + val canAllowAccess = remember(biometricState) { + biometricState.canAllowAccess + } + + LaunchedEffect(isAuthenticated, canAllowAccess, vaults) { + if (!isAuthenticated && !addNewVault && vaults.isNotEmpty()) { + if (canAllowAccess) { + biometricState.authenticate() + } else navigateUp() + } + } + if (isAuthenticated && canAllowAccess) { + vm.attachToLifecycle() + } + + AnimatedVisibility( + visible = addNewVault || vaults.isEmpty(), + enter = enterAnimation, + exit = exitAnimation + ) { + VaultSetup( + navigateUp = { + if (addNewVault) { + addNewVault = false + if (vaults.isEmpty()) navigateUp() + } else { + navigateUp() + } + }, + onCreate = { + addNewVault = false + isAuthenticated = false + biometricState.authenticate() + }, + vm = vm + ) + } + + AnimatedVisibility( + visible = currentVault != null && !addNewVault && isAuthenticated, + enter = enterAnimation, + exit = exitAnimation + ) { + VaultDisplay( + navigateUp = navigateUp, + navigate = navigate, + onCreateVaultClick = { addNewVault = true }, + vm = vm + ) + } +} + diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt new file mode 100644 index 000000000..dd12fd3a1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultSetup.kt @@ -0,0 +1,170 @@ +package com.dot.gallery.feature_node.presentation.vault + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.ui.core.Icons +import com.dot.gallery.ui.core.icons.Encrypted + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun VaultSetup( + navigateUp: () -> Unit, + onCreate: () -> Unit, + vm: VaultViewModel +) { + val context = LocalContext.current + + var nameError by remember { mutableStateOf("") } + var newVault by remember { mutableStateOf(Vault(name = "")) } + + val biometricManager = remember { BiometricManager.from(context) } + val isBiometricAvailable = remember { + biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + } + + + Scaffold( + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + OutlinedButton(onClick = navigateUp) { + Text(text = stringResource(id = R.string.action_cancel)) + } + Button( + onClick = { + vm.setVault(newVault) { + println("Error: $it") + nameError = it + } + if (nameError.isEmpty()) { + onCreate() + } + }, + enabled = isBiometricAvailable && nameError.isEmpty() && newVault.name.isNotEmpty() + ) { + Text(text = stringResource(id = R.string.get_started)) + } + } + } + ) { + Column( + modifier = Modifier + .padding(it) + .padding(horizontal = 24.dp) + .padding(top = 24.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Encrypted, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Setup your vault", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Encrypt your most private photos & videos", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = newVault.name, + onValueChange = { newName -> + newVault = newVault.copy(name = newName) + }, + label = { Text(text = "Vault Name") }, + isError = nameError.isNotEmpty(), + enabled = isBiometricAvailable + ) + AnimatedVisibility(visible = nameError.isNotEmpty()) { + Text( + text = nameError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + AnimatedVisibility(visible = !isBiometricAvailable) { + Text( + modifier = Modifier.fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + text = "Please set-up a phone security measure before setting up the vault.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + + } + + AnimatedVisibility(visible = isBiometricAvailable) { + Text( + modifier = Modifier.fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + text = "The vault will be accessed using your phone security measures (password or biometrics)\n\n" + + "Encryption key can be accessed inside the vault and will be used in order to restore any vaults.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt new file mode 100644 index 000000000..e793db3e1 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/VaultViewModel.kt @@ -0,0 +1,197 @@ +@file:Suppress("LABEL_NAME_CLASH") + +package com.dot.gallery.feature_node.presentation.vault + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dot.gallery.core.EncryptedMediaState +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.Media +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.domain.use_case.VaultUseCases +import com.dot.gallery.feature_node.presentation.util.RepeatOnResume +import com.dot.gallery.feature_node.presentation.util.collectEncryptedMedia +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.io.ByteArrayOutputStream +import java.io.IOException +import javax.inject.Inject + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@HiltViewModel +open class VaultViewModel @Inject constructor( + private val contentResolver: ContentResolver, + private val vaultUseCases: VaultUseCases +) : ViewModel() { + + private var _currentVault = MutableStateFlow(null) + val currentVault = _currentVault.asStateFlow() + + private val _mediaState = MutableStateFlow(EncryptedMediaState()) + val mediaState = _mediaState.asStateFlow() + + private val _vaults = MutableStateFlow>(emptyList()) + val vaults = _vaults.asStateFlow() + + init { + initVaults() + } + + fun setVault(vault: Vault?, onFailed: (reason: String) -> Unit) { + viewModelScope.launch { + vaultUseCases.getVaults().collectLatest { vaults -> + if (vault == null) { + getMedia(null) + return@collectLatest + } + val hasVault = vaults.find { it.uuid == vault.uuid } != null + if (hasVault) { + getMedia(vault) + } else { + vaultUseCases.createVault( + vault = vault, + onSuccess = { + getMedia(vault) + }, + onFailed = onFailed + ) + } + } + } + } + + fun deleteVault(vault: Vault) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.deleteVault( + vault = vault, + onSuccess = { + getMedia(null) + }, + onFailed = { + getMedia(null) + } + ) + } + } + + /** + * Attach the [VaultViewModel] to the lifecycle of the composable. + * This will start watching the directory for changes and update the media state accordingly. + * It will also fetch the media when the composable is resumed. + * This should be called in the composable that will use the [VaultViewModel]. + * + * Call only after Biometric Auth is successful. + */ + @SuppressLint("ComposableNaming") + @Composable + fun attachToLifecycle() { + RepeatOnResume { + getMedia(_currentVault.value) + } + } + + fun addMedia(media: Media) { + viewModelScope.launch(Dispatchers.IO) { + try { + _currentVault.value?.let { vault -> + getBytes(media.uri)?.let { + vaultUseCases.addMedia(vault, media) + } ?: return@let + getMedia(vault) + } + } catch (e: IOException) { + e.printStackTrace() + } + } + } + + fun restoreMedia(vault: Vault, media: EncryptedMedia) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.restoreMedia(vault, media) + getMedia(vault) + } + } + + fun deleteMedia(vault: Vault, media: EncryptedMedia) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.deleteEncryptedMedia(vault, media) + getMedia(vault) + } + } + + fun deleteAllMedia(vault: Vault) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.deleteAllEncryptedMedia( + vault = vault, + onSuccess = { + getMedia(vault) + }, + onFailed = { failedFiles -> + // TODO: Handle failed files + } + ) + } + } + + private fun initVaults() { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.getVaults().collectLatest { vaults -> + _vaults.emit(vaults) + } + } + } + + @Throws(IOException::class) + private fun getBytes(uri: Uri): ByteArray? = + 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() + } + + + private fun getMedia(vault: Vault?) { + viewModelScope.launch(Dispatchers.IO) { + vaultUseCases.getVaults().collectLatest { vaults -> + _vaults.emit(vaults) + if (vaults.isEmpty()) { + _mediaState.emit(EncryptedMediaState(isLoading = false, error = "No vaults found")) + return@collectLatest + } + _currentVault.emit(vault ?: vaults.first()) + vaultUseCases.getEncryptedMedia(_currentVault.value!!).collectLatest { + val data = it.data ?: emptyList() + if (_mediaState.value.media == data) { + if (data.isEmpty()) { + _mediaState.emit(EncryptedMediaState(isLoading = false)) + } + return@collectLatest + } + _mediaState.collectEncryptedMedia(data = data) + } + } + } + } + + override fun onCleared() { + _mediaState.value = EncryptedMediaState() + _currentVault.value = null + super.onCleared() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt new file mode 100644 index 000000000..1f0440e79 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/DeleteVaultSheet.kt @@ -0,0 +1,131 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteVaultSheet( + state: AppBottomSheetState, + onConfirm: () -> Unit +) { + val scope = rememberCoroutineScope() + + if (state.isVisible) { + ModalBottomSheet( + sheetState = state.sheetState, + onDismissRequest = { + scope.launch { + state.hide() + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 0.dp, + dragHandle = { DragHandle() }, + windowInsets = WindowInsets(0, 0, 0, 0) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 16.dp) + .navigationBarsPadding() + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontStyle = MaterialTheme.typography.titleLarge.fontStyle, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing + ) + ) { + append("Confirm Deletion?") + } + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + ) + Text( + modifier = Modifier.fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + text = "The current vault and its content will be deleted permanently.\nThis action cannot be undone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer + val tertiaryOnContainer = MaterialTheme.colorScheme.onTertiaryContainer + Button( + onClick = { + scope.launch { + state.hide() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = tertiaryContainer, + contentColor = tertiaryOnContainer + ) + ) { + Text(text = "Cancel") + } + + Button( + onClick = { + onConfirm() + scope.launch { + state.hide() + } + } + ) { + Text(text = "Yes") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt new file mode 100644 index 000000000..3eb6674a6 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaGridView.kt @@ -0,0 +1,343 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dokar.pinchzoomgrid.PinchZoomGridScope +import com.dot.gallery.R +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.EncryptedMediaState +import com.dot.gallery.core.presentation.components.StickyHeader +import com.dot.gallery.core.presentation.components.util.StickyHeaderGrid +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.isBigHeaderKey +import com.dot.gallery.feature_node.domain.model.isHeaderKey +import com.dot.gallery.feature_node.domain.model.isIgnoredKey +import com.dot.gallery.feature_node.presentation.common.components.rememberHeaderOffset +import com.dot.gallery.feature_node.presentation.util.FeedbackManager +import com.dot.gallery.feature_node.presentation.util.update +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PinchZoomGridScope.EncryptedMediaGridView( + mediaState: EncryptedMediaState, + paddingValues: PaddingValues = PaddingValues(0.dp), + searchBarPaddingTop: Dp = 0.dp, + showSearchBar: Boolean = false, + allowSelection: Boolean = false, + selectionState: MutableState = remember { mutableStateOf(false) }, + selectedMedia: SnapshotStateList = remember { mutableStateListOf() }, + toggleSelection: (Int) -> Unit = {}, + canScroll: Boolean = true, + allowHeaders: Boolean = remember { true }, + enableStickyHeaders: Boolean = false, + showMonthlyHeader: Boolean = false, + aboveGridContent: @Composable (() -> Unit)? = null, + isScrolling: MutableState, + onMediaClick: (media: EncryptedMedia) -> Unit = {} +) { + val stringToday = stringResource(id = R.string.header_today) + val stringYesterday = stringResource(id = R.string.header_yesterday) + + val scope = rememberCoroutineScope() + val mappedData = remember(showMonthlyHeader, mediaState) { + if (showMonthlyHeader) mediaState.mappedMediaWithMonthly + else mediaState.mappedMedia + } + + /** Selection state handling **/ + BackHandler( + enabled = selectionState.value && allowSelection, + onBack = { + selectionState.value = false + selectedMedia.clear() + } + ) + /** ************ **/ + + val feedbackManager = FeedbackManager.rememberFeedbackManager() + + @Composable + fun mediaGrid() { + LaunchedEffect(gridState.isScrollInProgress) { + isScrolling.value = gridState.isScrollInProgress + } + Box { + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize(), + columns = gridCells, + contentPadding = paddingValues, + userScrollEnabled = canScroll, + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + if (aboveGridContent != null) { + item( + span = { GridItemSpan(maxLineSpan) }, + key = "aboveGrid" + ) { + aboveGridContent.invoke() + } + } + + if (allowHeaders) { + items( + items = mappedData, + key = { item -> item.key }, + contentType = { item -> item.key.startsWith("media_") }, + span = { item -> + GridItemSpan(if (item.key.isHeaderKey) maxLineSpan else 1) + } + ) { it -> + val item = remember { + if (it is EncryptedMediaItem.Header) it + else it as EncryptedMediaItem.MediaViewItem + } + if (item is EncryptedMediaItem.Header) { + val isChecked = rememberSaveable { mutableStateOf(false) } + if (allowSelection) { + LaunchedEffect(selectionState.value) { + // Uncheck if selectionState is set to false + isChecked.value = isChecked.value && selectionState.value + } + LaunchedEffect(selectedMedia.size) { + // Partial check of media items should not check the header + isChecked.value = selectedMedia.containsAll(item.data) + } + } + StickyHeader( + modifier = Modifier.pinchItem(key = it.key), + date = remember { + item.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + }, + showAsBig = remember { item.key.isBigHeaderKey }, + isCheckVisible = selectionState, + isChecked = isChecked + ) { + if (allowSelection) { + feedbackManager.vibrate() + scope.launch { + isChecked.value = !isChecked.value + if (isChecked.value) { + val toAdd = item.data.toMutableList().apply { + // Avoid media from being added twice to selection + removeIf { selectedMedia.contains(it) } + } + selectedMedia.addAll(toAdd) + } else selectedMedia.removeAll(item.data) + selectionState.update(selectedMedia.isNotEmpty()) + } + } + } + } else { + EncryptedMediaImage( + modifier = Modifier + .animateItemPlacement() + .pinchItem(key = it.key), + media = (item as EncryptedMediaItem.MediaViewItem).media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.media.indexOf(it)) + } else onMediaClick(it) + } + ) { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(mediaState.media.indexOf(it)) + } + } + } + } + } else { + itemsIndexed( + items = mediaState.media, + key = { _, item -> item.toString() }, + contentType = { _, item -> item.isImage } + ) { index, media -> + EncryptedMediaImage( + modifier = Modifier + .animateItemPlacement() + .pinchItem(key = media.toString()), + media = media, + selectionState = selectionState, + selectedMedia = selectedMedia, + canClick = canScroll, + onItemClick = { + if (selectionState.value && allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } else onMediaClick(it) + }, + onItemLongClick = { + if (allowSelection) { + feedbackManager.vibrate() + toggleSelection(index) + } + } + ) + } + } + } + + if (allowHeaders) { + EncryptedTimelineScroller( + gridState = gridState, + mappedData = mappedData, + paddingValues = paddingValues + ) + } + } + } + + if (enableStickyHeaders) { + /** + * Remember last known header item + */ + val stickyHeaderLastItem = remember { mutableStateOf(null) } + + val headers = remember(mappedData) { + mappedData.filterIsInstance().filter { !it.key.isBigHeaderKey } + } + + val stickyHeaderItem by remember(mappedData) { + derivedStateOf { + val firstItem = gridState.layoutInfo.visibleItemsInfo.firstOrNull() + val firstHeaderIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull { + it.key.isHeaderKey && !it.key.toString().contains("big") + }?.index + + val item = firstHeaderIndex?.let(mappedData::getOrNull) + stickyHeaderLastItem.apply { + if (item != null && item is EncryptedMediaItem.Header) { + val newItem = item.text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + val newIndex = (headers.indexOf(item) - 1).coerceAtLeast(0) + val previousHeader = headers[newIndex].text + .replace("Today", stringToday) + .replace("Yesterday", stringYesterday) + value = if (firstItem != null && !firstItem.key.isHeaderKey) { + previousHeader + } else { + newItem + } + } + }.value + } + } + val searchBarPadding by animateDpAsState( + targetValue = remember(isScrolling.value, showSearchBar, searchBarPaddingTop) { + if (showSearchBar && !isScrolling.value) { + SearchBarDefaults.InputFieldHeight + searchBarPaddingTop + 8.dp + } else if (showSearchBar && isScrolling.value) searchBarPaddingTop else 0.dp + }, + label = "searchBarPadding" + ) + + val density = LocalDensity.current + val searchBarOffset = remember(density, showSearchBar, searchBarPadding) { + with(density) { + return@with if (showSearchBar) + 28.sp.roundToPx() + searchBarPadding.roundToPx() else 0 + } + } + val headerMatcher: (LazyGridItemInfo) -> Boolean = remember { + { item -> item.key.isHeaderKey || item.key.isIgnoredKey } + } + val headerOffset by rememberHeaderOffset(gridState, headerMatcher, searchBarOffset) + StickyHeaderGrid( + modifier = Modifier.fillMaxSize(), + showSearchBar = showSearchBar, + headerOffset = headerOffset, + stickyHeader = { + val show = remember( + mediaState, + stickyHeaderItem + ) { mediaState.media.isNotEmpty() && stickyHeaderItem != null } + AnimatedVisibility( + visible = show, + enter = enterAnimation, + exit = exitAnimation + ) { + val text by remember(stickyHeaderItem) { + derivedStateOf { stickyHeaderItem ?: "" } + } + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .background( + Brush.verticalGradient( + listOf( + // 3.dp is the elevation the LargeTopAppBar use + MaterialTheme.colorScheme.surfaceColorAtElevation( + 3.dp + ), + Color.Transparent + ) + ) + ) + .padding(horizontal = 16.dp) + .padding(top = 24.dp + searchBarPadding, bottom = 24.dp) + .fillMaxWidth() + ) + } + } + ) { mediaGrid() } + } else mediaGrid() + + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt new file mode 100644 index 000000000..50ca3cce0 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedMediaImage.kt @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.size.Scale +import com.dot.gallery.core.Constants.Animation +import com.dot.gallery.core.presentation.components.CheckBox +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.MediaEqualityDelegate +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.VideoDurationHeader + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EncryptedMediaImage( + modifier: Modifier = Modifier, + media: EncryptedMedia, + selectionState: MutableState, + selectedMedia: SnapshotStateList, + canClick: Boolean, + onItemClick: (EncryptedMedia) -> Unit, + onItemLongClick: (EncryptedMedia) -> Unit, +) { + var isSelected by remember { mutableStateOf(false) } + LaunchedEffect(selectionState.value, selectedMedia.size) { + isSelected = if (!selectionState.value) false else { + selectedMedia.find { it.id == media.id } != null + } + } + val selectedSize by animateDpAsState( + if (isSelected) 12.dp else 0.dp, label = "selectedSize" + ) + val scale by animateFloatAsState( + if (isSelected) 0.5f else 1f, label = "scale" + ) + val selectedShapeSize by animateDpAsState( + if (isSelected) 16.dp else 0.dp, label = "selectedShapeSize" + ) + val strokeSize by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, label = "strokeSize" + ) + val primaryContainerColor = MaterialTheme.colorScheme.primaryContainer + val strokeColor by animateColorAsState( + targetValue = if (isSelected) primaryContainerColor else Color.Transparent, + label = "strokeColor" + ) + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(media.bytes) + .memoryCachePolicy(CachePolicy.ENABLED) + .placeholderMemoryCacheKey(media.toString()) + .scale(Scale.FIT) + .build(), + modelEqualityDelegate = MediaEqualityDelegate(), + contentScale = ContentScale.FillBounds, + filterQuality = FilterQuality.None + ) + Box( + modifier = modifier + .combinedClickable( + enabled = canClick, + onClick = { + onItemClick(media) + if (selectionState.value) { + isSelected = !isSelected + } + }, + onLongClick = { + onItemLongClick(media) + if (selectionState.value) { + isSelected = !isSelected + } + }, + ) + .aspectRatio(1f) + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .aspectRatio(1f) + .padding(selectedSize) + .clip(RoundedCornerShape(selectedShapeSize)) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(selectedShapeSize) + ) + .border( + width = strokeSize, + shape = RoundedCornerShape(selectedShapeSize), + color = strokeColor + ) + ) { + Image( + modifier = Modifier + .fillMaxSize(), + painter = painter, + contentDescription = media.label, + contentScale = ContentScale.Crop, + ) + } + + AnimatedVisibility( + visible = remember(media) { + media.duration != null + }, + enter = Animation.enterAnimation, + exit = Animation.exitAnimation, + modifier = Modifier.align(Alignment.TopEnd) + ) { + VideoDurationHeader( + modifier = Modifier + .padding(selectedSize / 2) + .scale(scale), + media = media + ) + } + + AnimatedVisibility( + visible = selectionState.value, + enter = Animation.enterAnimation, + exit = Animation.exitAnimation + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + CheckBox(isChecked = isSelected) + } + } + } +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt new file mode 100644 index 000000000..d68a181f8 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTimelineScroller.kt @@ -0,0 +1,217 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.EncryptedMediaItem +import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs + +@Composable +fun BoxScope.EncryptedTimelineScroller( + gridState: LazyGridState, + mappedData: List, + paddingValues: PaddingValues +) { + val scope = rememberCoroutineScope() + val offsets = remember { mutableStateMapOf() } + var selectedIndex by remember { mutableIntStateOf(0) } + val feedbackManager = rememberFeedbackManager() + fun scrollIfNeeded(offset: Float) { + val index = offsets + .mapValues { abs(it.value - offset) } + .entries + .minByOrNull { it.value } + ?.key ?: return + if (selectedIndex == index) return + selectedIndex = index + scope.launch { + feedbackManager.vibrate() + gridState.scrollToItem(selectedIndex, scrollOffset = -250) + } + } + + var scrollVisible by remember { mutableStateOf(false) } + val scrollAlpha by animateFloatAsState( + animationSpec = tween(durationMillis = 1000), + targetValue = if (scrollVisible) 1f else 0f, + label = "scrollAlpha" + ) + + val configuration = LocalConfiguration.current + val height = remember(configuration) { configuration.screenHeightDp } + val headerList = + remember(mappedData) { mappedData.filterIsInstance() } + val bottomPadding = remember { 32 /*dp*/ } + + /** + * Distribute scroll items height proportionally to the amount of date headers + **/ + val parentTopPadding = remember(paddingValues) { paddingValues.calculateTopPadding().value } + val parentBottomPadding = remember { paddingValues.calculateBottomPadding().value } + val heightSize = + remember(height, parentTopPadding, parentBottomPadding, bottomPadding, headerList) { + (height - parentTopPadding - parentBottomPadding - bottomPadding) / headerList.size + } + + // W.I.P. + /*val yearList = remember { mutableStateListOf() } + LazyColumn( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(paddingValues) + .padding(top = 32.dp, bottom = bottomPadding.dp) + .padding(end = 48.dp) + .fillMaxHeight() + .wrapContentWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.End + ) { + itemsIndexed( + items = headerList, + key = { _, item -> item.key } + ) { index, item -> + val year = getYear(item.text) + if (!yearList.contains(year)) { + yearList.add(year) + val padding = heightSize * index + Log.d( + Constants.TAG, + "Height size: $heightSize, Index: $index, Top padding for $year: $padding" + ) + Text( + modifier = Modifier + .graphicsLayer { + translationY = -padding + } + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(100) + ) + .padding(vertical = 4.dp, horizontal = 5.dp), + text = year, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + }*/ + + Box( + modifier = Modifier + .padding(paddingValues) + .padding(top = 32.dp, bottom = bottomPadding.dp) + .graphicsLayer { + translationY = offsets[selectedIndex] ?: 0f + alpha = scrollAlpha + } + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.tertiary, + shape = RoundedCornerShape( + topStartPercent = 100, + bottomStartPercent = 100 + ) + ) + .align(Alignment.TopEnd) + .padding(vertical = 2.5.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_scroll_arrow), + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiary + ) + } + + Column( + modifier = Modifier + .padding(paddingValues) + .padding(top = 32.dp, bottom = bottomPadding.dp) + .align(Alignment.TopEnd) + .fillMaxHeight() + .verticalScroll(state = rememberScrollState()) + .graphicsLayer { + alpha = scrollAlpha + } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { + scope.launch { + scrollVisible = true + } + }, + onDragCancel = { + scope.launch { + delay(1500) + scrollVisible = false + } + }, + onDragEnd = { + scope.launch { + delay(500) + scrollVisible = false + } + }, + onVerticalDrag = { change, _ -> + if (!scrollVisible) { + scrollVisible = true + } + scrollIfNeeded(change.position.y) + } + ) + }, + verticalArrangement = Arrangement.Top + ) { + mappedData.forEachIndexed { i, header -> + if (header is EncryptedMediaItem.Header) { + Spacer( + modifier = Modifier + .width(16.dp) + .height(heightSize.dp) + .onGloballyPositioned { + offsets[i] = it.boundsInParent().center.y + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt new file mode 100644 index 000000000..f2b95b872 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/EncryptedTrashDialog.kt @@ -0,0 +1,298 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import com.dot.gallery.R +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction +import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.DELETE +import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.RESTORE +import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction.TRASH +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.FeedbackManager.Companion.rememberFeedbackManager +import com.dot.gallery.ui.theme.Shapes +import kotlinx.coroutines.launch + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) +@Composable +fun EncryptedTrashDialog( + appBottomSheetState: AppBottomSheetState, + data: List, + action: TrashDialogAction, + onConfirm: suspend (List) -> Unit +) { + val dataCopy = data.toMutableStateList() + var confirmed by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + BackHandler( + appBottomSheetState.isVisible && !confirmed + ) { + scope.launch { + confirmed = false + appBottomSheetState.hide() + } + } + if (appBottomSheetState.isVisible) { + confirmed = false + ModalBottomSheet( + sheetState = appBottomSheetState.sheetState, + onDismissRequest = { + scope.launch { + appBottomSheetState.hide() + } + }, + dragHandle = { DragHandle() }, + windowInsets = WindowInsets(0, 0, 0, 0) + ) { + val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer + val tertiaryOnContainer = MaterialTheme.colorScheme.onTertiaryContainer + val mainButtonDefaultText = stringResource(R.string.action_confirm) + val mainButtonConfirmText = stringResource(R.string.action_confirmed) + val mainButtonText = remember(confirmed) { + if (confirmed) mainButtonConfirmText else mainButtonDefaultText + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + AnimatedVisibility( + visible = !confirmed, + enter = enterAnimation, + exit = exitAnimation + ) { + val text = when (action) { + TRASH -> stringResource(R.string.dialog_to_trash) + DELETE -> stringResource(R.string.dialog_delete) + RESTORE -> stringResource(R.string.dialog_from_trash) + } + Column { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontStyle = MaterialTheme.typography.titleLarge.fontStyle, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing + ) + ) { + append(text) + } + append("\n") + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = MaterialTheme.typography.bodyMedium.fontStyle, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + letterSpacing = MaterialTheme.typography.bodyMedium.letterSpacing + ) + ) { + append(stringResource(R.string.s_items, dataCopy.size)) + } + }, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + ) + } + } + + AnimatedVisibility( + visible = confirmed, + enter = enterAnimation, + exit = exitAnimation + ) { + val text = + when (action) { + TRASH -> stringResource(R.string.trashing_items, dataCopy.size) + DELETE -> stringResource(R.string.deleting_items, dataCopy.size) + RESTORE -> stringResource(R.string.restoring_items, dataCopy.size) + } + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(24.dp) + .fillMaxWidth() + ) + } + + val alpha by animateFloatAsState( + targetValue = if (!confirmed) 1f else 0.5f, + label = "alphaAnimation" + ) + + val alignment = if (dataCopy.size == 1) { + Alignment.CenterHorizontally + } else Alignment.Start + + LazyRow( + modifier = Modifier + .alpha(alpha) + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment), + ) { + if (dataCopy.size > 1) { + item { + Spacer(modifier = Modifier.width(16.dp)) + } + } + items( + items = dataCopy, + key = { it.toString() }, + contentType = { it.mimeType } + ) { + val context = LocalContext.current + val longPressText = stringResource(R.string.long_press_to_remove) + val canBeTrashed = true + val borderWidth = if (canBeTrashed) 0.5.dp else 2.dp + val borderColor = + if (canBeTrashed) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error + val shape = if (canBeTrashed) Shapes.large else Shapes.extraLarge + val feedbackManager = rememberFeedbackManager() + Box( + modifier = Modifier + .animateItemPlacement() + .size(width = 80.dp, height = 120.dp) + .clip(shape) + .border( + width = borderWidth, + color = borderColor, + shape = shape + ) + .combinedClickable( + enabled = !confirmed, + onLongClick = { + feedbackManager.vibrate() + scope.launch { + dataCopy.remove(it) + if (dataCopy.isEmpty()) { + appBottomSheetState.hide() + dataCopy.addAll(data) + } + } + }, + onClick = { + feedbackManager.vibrateStrong() + Toast + .makeText(context, longPressText, Toast.LENGTH_SHORT) + .show() + } + ) + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(it.bytes) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build(), + contentDescription = it.label, + contentScale = ContentScale.Crop + ) + } + } + } + + Row( + modifier = Modifier.padding(24.dp), + horizontalArrangement = Arrangement + .spacedBy(24.dp, Alignment.CenterHorizontally) + ) { + AnimatedVisibility(visible = !confirmed) { + Button( + onClick = { + scope.launch { + appBottomSheetState.hide() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = tertiaryContainer, + contentColor = tertiaryOnContainer + ) + ) { + Text(text = stringResource(R.string.action_cancel)) + } + } + Button( + enabled = !confirmed, + onClick = { + confirmed = true + scope.launch { + onConfirm.invoke(dataCopy) + appBottomSheetState.hide() + } + } + ) { + Text(text = mainButtonText) + } + } + } + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt new file mode 100644 index 000000000..3e1f30f15 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/NewVaultSheet.kt @@ -0,0 +1,132 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewVaultSheet( + state: AppBottomSheetState, + onConfirm: () -> Unit +) { + val scope = rememberCoroutineScope() + + if (state.isVisible) { + ModalBottomSheet( + sheetState = state.sheetState, + onDismissRequest = { + scope.launch { + state.hide() + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 0.dp, + dragHandle = { DragHandle() }, + windowInsets = WindowInsets(0, 0, 0, 0) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 16.dp) + .navigationBarsPadding() + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontStyle = MaterialTheme.typography.titleLarge.fontStyle, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing + ) + ) { + append("Create new vault?") + } + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + ) + Text( + modifier = Modifier.fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp), + text = "The vault will be accessed using your phone security measures (password or biometrics)\n\n" + + "Encryption key can be accessed inside the vault and will be used in order to restore any vaults.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + val tertiaryContainer = MaterialTheme.colorScheme.tertiaryContainer + val tertiaryOnContainer = MaterialTheme.colorScheme.onTertiaryContainer + Button( + onClick = { + scope.launch { + state.hide() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = tertiaryContainer, + contentColor = tertiaryOnContainer + ) + ) { + Text(text = "Cancel") + } + + Button( + onClick = { + onConfirm() + scope.launch { + state.hide() + } + } + ) { + Text(text = "Yes") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt new file mode 100644 index 000000000..6d14f8229 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/components/SelectVaultSheet.kt @@ -0,0 +1,103 @@ +package com.dot.gallery.feature_node.presentation.vault.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.common.components.OptionItem +import com.dot.gallery.feature_node.presentation.common.components.OptionLayout +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectVaultSheet( + state: AppBottomSheetState, + vaults: List, + onVaultSelected: (Vault) -> Unit +) { + val scope = rememberCoroutineScope() + val vaultOptions = remember(vaults, state) { + vaults.map { + OptionItem( + text = it.name, + onClick = { + onVaultSelected(it) + scope.launch { + state.hide() + } + } + ) + } + } + + if (state.isVisible) { + ModalBottomSheet( + sheetState = state.sheetState, + onDismissRequest = { + scope.launch { + state.hide() + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + tonalElevation = 0.dp, + dragHandle = { DragHandle() }, + windowInsets = WindowInsets(0, 0, 0, 0) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 16.dp) + .navigationBarsPadding() + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontStyle = MaterialTheme.typography.titleLarge.fontStyle, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + letterSpacing = MaterialTheme.typography.titleLarge.letterSpacing + ) + ) { + append("Select a vault") + } + }, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + ) + OptionLayout( + modifier = Modifier.fillMaxWidth(), + optionList = vaultOptions + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt new file mode 100644 index 000000000..5efa38e0e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/EncryptedMediaViewScreen.kt @@ -0,0 +1,271 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dot.gallery.core.Constants +import com.dot.gallery.core.Constants.Animation.enterAnimation +import com.dot.gallery.core.Constants.Animation.exitAnimation +import com.dot.gallery.core.Constants.DEFAULT_LOW_VELOCITY_SWIPE_DURATION +import com.dot.gallery.core.Constants.HEADER_DATE_FORMAT +import com.dot.gallery.core.EncryptedMediaState +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.util.getDate +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.rememberWindowInsetsController +import com.dot.gallery.feature_node.presentation.util.toggleSystemBars +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.EncryptedMediaViewAppBar +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.EncryptedMediaViewBottomBar +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.media.MediaPreviewComponent +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.VideoPlayerController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EncryptedMediaViewScreen( + navigateUp: () -> Unit, + toggleRotate: () -> Unit, + paddingValues: PaddingValues, + isStandalone: Boolean = false, + mediaId: Long, + mediaState: StateFlow, + vault: StateFlow, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + deleteMedia: (Vault, EncryptedMedia) -> Unit +) { + var runtimeMediaId by rememberSaveable(mediaId) { mutableLongStateOf(mediaId) } + val state by mediaState.collectAsStateWithLifecycle() + val initialPage = rememberSaveable(runtimeMediaId) { + state.media.indexOfFirst { it.id == runtimeMediaId }.coerceAtLeast(0) + } + val pagerState = rememberPagerState( + initialPage = initialPage, + initialPageOffsetFraction = 0f, + pageCount = state.media::size + ) + val bottomSheetState = rememberAppBottomSheetState() + + val currentDate = rememberSaveable { mutableStateOf("") } + val currentMedia = rememberSaveable { mutableStateOf(null) } + val currentVault by vault.collectAsStateWithLifecycle() + + val showUI = rememberSaveable { mutableStateOf(true) } + val windowInsetsController = rememberWindowInsetsController() + + var lastIndex by remember { mutableIntStateOf(-1) } + val updateContent: (Int) -> Unit = { page -> + if (state.media.isNotEmpty()) { + val index = if (page == -1) 0 else page + if (lastIndex != -1) + runtimeMediaId = state.media[lastIndex.coerceAtMost(state.media.size - 1)].id + currentDate.value = state.media[index].timestamp.getDate(HEADER_DATE_FORMAT) + currentMedia.value = state.media[index] + } else if (!isStandalone) navigateUp() + } + val scope = rememberCoroutineScope() + + LaunchedEffect(pagerState, state.media) { + snapshotFlow { pagerState.currentPage }.collect { page -> + updateContent(page) + } + } + + BackHandler(!showUI.value) { + windowInsetsController.toggleSystemBars(show = true) + navigateUp() + } + + Box( + modifier = Modifier + .background(Color.Black) + .fillMaxSize() + ) { + HorizontalPager( + modifier = Modifier + .pointerInput(Unit) { + detectVerticalDragGestures { change, dragAmount -> + if (dragAmount < -5) { + change.consume() + scope.launch { + bottomSheetState.show() + } + } + } + }, + state = pagerState, + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + lowVelocityAnimationSpec = tween( + easing = FastOutLinearInEasing, + durationMillis = DEFAULT_LOW_VELOCITY_SWIPE_DURATION + ) + ), + key = { index -> + if (state.media.isNotEmpty()) { + state.media[index.coerceIn(0 until state.media.size)].id + } else "empty" + }, + pageSpacing = 16.dp, + ) { index -> + var playWhenReady by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { pagerState.currentPage } + .collectLatest { currentPage -> + playWhenReady = currentPage == index + } + } + + MediaPreviewComponent( + media = state.media[index], + uiEnabled = showUI.value, + playWhenReady = playWhenReady, + onItemClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars(showUI.value) + } + ) { player, isPlaying, currentTime, totalTime, buffer, frameRate -> + Box( + modifier = Modifier.fillMaxSize() + ) { + val displayMetrics = LocalContext.current.resources.displayMetrics + + //Width And Height Of Screen + val width = displayMetrics.widthPixels + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = width / 1.5f + } + .align(Alignment.TopEnd) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.value += 10 * 1000 + player.seekTo(currentTime.value) + delay(100) + player.play() + } + }, + onClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars(showUI.value) + } + ) + ) + + Spacer( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = -width / 1.5f + } + .align(Alignment.TopStart) + .clip(CircleShape) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onDoubleClick = { + scope.launch { + currentTime.value -= 10 * 1000 + player.seekTo(currentTime.value) + delay(100) + player.play() + } + }, + onClick = { + showUI.value = !showUI.value + windowInsetsController.toggleSystemBars(showUI.value) + } + ) + ) + + AnimatedVisibility( + visible = showUI.value, + enter = enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), + exit = exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), + modifier = Modifier.fillMaxSize() + ) { + VideoPlayerController( + paddingValues = paddingValues, + player = player, + isPlaying = isPlaying, + currentTime = currentTime, + totalTime = totalTime, + buffer = buffer, + toggleRotate = toggleRotate, + frameRate = frameRate + ) + } + } + } + } + EncryptedMediaViewAppBar( + showUI = showUI.value, + showDate = currentMedia.value?.timestamp != 0L, + currentDate = currentDate.value, + bottomSheetState = bottomSheetState, + paddingValues = paddingValues, + onGoBack = navigateUp + ) + EncryptedMediaViewBottomBar( + bottomSheetState = bottomSheetState, + paddingValues = paddingValues, + currentMedia = currentMedia.value, + currentVault = currentVault, + currentIndex = pagerState.currentPage, + deleteMedia = deleteMedia, + restoreMedia = restoreMedia, + onDeleteMedia = { + lastIndex = it + } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/AppBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/AppBar.kt new file mode 100644 index 000000000..bcc0c437e --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/AppBar.kt @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.Constants +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import com.dot.gallery.ui.theme.BlackScrim +import kotlinx.coroutines.launch + +@Composable +fun EncryptedMediaViewAppBar( + showUI: Boolean, + showDate: Boolean, + currentDate: String, + paddingValues: PaddingValues, + onGoBack: () -> Unit, + bottomSheetState: AppBottomSheetState, +) { + val scope = rememberCoroutineScope() + AnimatedVisibility( + visible = showUI, + enter = Constants.Animation.enterAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION), + exit = Constants.Animation.exitAnimation(Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION) + ) { + Row( + modifier = Modifier + .background( + Brush.verticalGradient( + colors = listOf(BlackScrim, Color.Transparent) + ) + ) + .padding(top = paddingValues.calculateTopPadding()) + .padding(start = 5.dp, end = 8.dp) + .padding(vertical = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton(onClick = onGoBack) { + Image( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = "Go back", + modifier = Modifier + .height(48.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (showDate) { + Text( + text = currentDate.uppercase(), + modifier = Modifier, + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + color = Color.White, + textAlign = TextAlign.End + ) + } + IconButton( + onClick = { + scope.launch { + bottomSheetState.show() + } + } + ) { + Image( + imageVector = Icons.Outlined.Info, + colorFilter = ColorFilter.tint(Color.White), + contentDescription = "info", + modifier = Modifier + .height(48.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt new file mode 100644 index 000000000..87f06a1be --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/BottomBar.kt @@ -0,0 +1,461 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.toUpperCase +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dot.gallery.R +import com.dot.gallery.core.Constants +import com.dot.gallery.core.presentation.components.DragHandle +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.domain.model.Vault +import com.dot.gallery.feature_node.presentation.trashed.components.TrashDialogAction +import com.dot.gallery.feature_node.presentation.util.AppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.getDate +import com.dot.gallery.feature_node.presentation.util.rememberAppBottomSheetState +import com.dot.gallery.feature_node.presentation.util.shareMedia +import com.dot.gallery.feature_node.presentation.vault.components.EncryptedTrashDialog +import com.dot.gallery.ui.theme.BlackScrim +import com.dot.gallery.ui.theme.Shapes +import kotlinx.coroutines.launch + +@Composable +fun BoxScope.EncryptedMediaViewBottomBar( + bottomSheetState: AppBottomSheetState, + paddingValues: PaddingValues, + currentMedia: EncryptedMedia?, + currentVault: Vault?, + currentIndex: Int = 0, + onDeleteMedia: ((Int) -> Unit)? = null, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + deleteMedia: (Vault, EncryptedMedia) -> Unit +) { + Row( + modifier = Modifier + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, BlackScrim) + ) + ) + .padding( + top = 24.dp, + bottom = paddingValues.calculateBottomPadding() + ) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .align(Alignment.BottomCenter), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + currentMedia?.let { + EncryptedMediaViewActions( + currentIndex = currentIndex, + currentMedia = it, + currentVault = currentVault!!, + onDeleteMedia = onDeleteMedia, + restoreMedia = restoreMedia, + deleteMedia = deleteMedia + ) + } + } + currentMedia?.let { + EncryptedMediaInfoBottomSheet( + media = it, + currentVault = currentVault!!, + restoreMedia = restoreMedia, + state = bottomSheetState + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EncryptedMediaInfoBottomSheet( + media: EncryptedMedia, + currentVault: Vault?, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + state: AppBottomSheetState +) { + val scope = rememberCoroutineScope() + if (state.isVisible) { + ModalBottomSheet( + onDismissRequest = { + scope.launch { + state.hide() + } + }, + dragHandle = { DragHandle() }, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetState = state.sheetState, + windowInsets = WindowInsets(0, 0, 0, 0) + ) { + BackHandler { + scope.launch { + state.hide() + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + EncryptedMediaViewInfoActions(media, restoreMedia, currentVault!!) + Spacer(modifier = Modifier.height(8.dp)) + EncryptedMediaInfoDateCaptionContainer(media) + Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } + } +} + +@Composable +fun EncryptedMediaInfoDateCaptionContainer( + media: EncryptedMedia +) { + Column { + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = Shapes.large + ) + .padding(vertical = 16.dp) + .padding(start = 16.dp, end = 12.dp), + ) { + Column( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = media.timestamp.getDate(Constants.EXIF_DATE_FORMAT), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp + ) + val defaultDesc = stringResource(R.string.image_add_description) + SelectionContainer { + Text( + text = defaultDesc, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(state = rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (media.isRaw) { + EncryptedMediaInfoChip( + text = media.fileExtension.toUpperCase(Locale.current), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EncryptedMediaInfoChip( + text: String, + contentColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + outlineInLightTheme: Boolean = true, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +) { + Text( + modifier = Modifier + .background( + color = containerColor, + shape = Shapes.extraLarge + ) + .then( + if (!isSystemInDarkTheme() && outlineInLightTheme) Modifier.border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = Shapes.extraLarge + ) else Modifier + ) + .clip(Shapes.extraLarge) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + text = text, + style = MaterialTheme.typography.bodyMedium, + color = contentColor + ) +} + +@Composable +private fun EncryptedMediaViewInfoActions( + media: EncryptedMedia, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + currentVault: Vault +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + // Share Component + ShareButton(media, followTheme = true) + // Use as or Open With + //OpenAsButton(media, followTheme = true) + // Restore + RestoreButton(media, currentVault, restoreMedia, followTheme = true) + } +} + +@Composable +private fun EncryptedMediaViewActions( + currentIndex: Int, + currentMedia: EncryptedMedia, + currentVault: Vault, + onDeleteMedia: ((Int) -> Unit)?, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + deleteMedia: (Vault, EncryptedMedia) -> Unit +) { + // Share Component + ShareButton(currentMedia) + // Un-hide Component + RestoreButton(currentMedia, currentVault, restoreMedia) + // Trash Component + TrashButton( + index = currentIndex, + media = currentMedia, + currentVault = currentVault, + onDeleteMedia = onDeleteMedia, + deleteMedia = deleteMedia + ) +} + + +@Composable +private fun ShareButton( + media: EncryptedMedia, + followTheme: Boolean = false +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + EncryptedBottomBarColumn( + currentMedia = media, + imageVector = Icons.Outlined.Share, + followTheme = followTheme, + title = stringResource(R.string.share) + ) { + scope.launch { + context.shareMedia(media = it) + } + } +} + +@Composable +private fun RestoreButton( + media: EncryptedMedia, + currentVault: Vault, + restoreMedia: (Vault, EncryptedMedia) -> Unit, + followTheme: Boolean = false +) { + val scope = rememberCoroutineScope() + EncryptedBottomBarColumn( + currentMedia = media, + imageVector = Icons.Outlined.Image, + followTheme = followTheme, + title = "Restore" + ) { + scope.launch { + restoreMedia(currentVault, it) + } + } +} + +/*@Composable +private fun OpenAsButton( + media: EncryptedMedia, + followTheme: Boolean = false +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + if (media.isVideo) { + EncryptedBottomBarColumn( + currentMedia = media, + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + followTheme = followTheme, + title = stringResource(R.string.open_with) + ) { + scope.launch { context.launchOpenWithIntent(it) } + } + } else { + EncryptedBottomBarColumn( + currentMedia = media, + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + followTheme = followTheme, + title = stringResource(R.string.use_as) + ) { + scope.launch { context.launchUseAsIntent(it) } + } + } +}*/ + +@Composable +private fun TrashButton( + index: Int, + media: EncryptedMedia, + currentVault: Vault, + followTheme: Boolean = false, + onDeleteMedia: ((Int) -> Unit)?, + deleteMedia: (Vault, EncryptedMedia) -> Unit +) { + val state = rememberAppBottomSheetState() + val scope = rememberCoroutineScope() + + EncryptedBottomBarColumn( + currentMedia = media, + imageVector = Icons.Outlined.DeleteOutline, + followTheme = followTheme, + title = stringResource(id = R.string.trash), + onItemLongClick = { + scope.launch { + state.show() + } + }, + onItemClick = { + scope.launch { + state.show() + } + } + ) + + EncryptedTrashDialog( + appBottomSheetState = state, + data = listOf(media), + action = TrashDialogAction.DELETE + ) { + it.forEach { media -> + deleteMedia(currentVault, media) + } + onDeleteMedia?.invoke(index) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EncryptedBottomBarColumn( + currentMedia: EncryptedMedia?, + imageVector: ImageVector, + title: String, + followTheme: Boolean = false, + onItemLongClick: ((EncryptedMedia) -> Unit)? = null, + onItemClick: (EncryptedMedia) -> Unit +) { + val tintColor = if (followTheme) MaterialTheme.colorScheme.onSurface else Color.White + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .defaultMinSize( + minWidth = 90.dp, + minHeight = 80.dp + ) + .combinedClickable( + onLongClick = { + currentMedia?.let { + onItemLongClick?.invoke(it) + } + }, + onClick = { + currentMedia?.let { + onItemClick.invoke(it) + } + } + ) + .padding(top = 12.dp, bottom = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + imageVector = imageVector, + colorFilter = ColorFilter.tint(tintColor), + contentDescription = title, + modifier = Modifier + .height(32.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = title, + modifier = Modifier, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, + color = tintColor, + textAlign = TextAlign.Center + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt new file mode 100644 index 000000000..314b3e4e5 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/MediaPreviewComponent.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.media + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.media3.exoplayer.ExoPlayer +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video.VideoPlayer + +@Composable +fun MediaPreviewComponent( + media: EncryptedMedia, + uiEnabled: Boolean, + playWhenReady: Boolean, + onItemClick: () -> Unit, + videoController: @Composable (ExoPlayer, MutableState, MutableState, Long, Int, Float) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (media.isVideo) { + VideoPlayer( + media = media, + playWhenReady = playWhenReady, + videoController = videoController, + onItemClick = onItemClick + ) + } else { + ZoomablePagerImage( + media = media, + uiEnabled = uiEnabled, + onItemClick = onItemClick + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt new file mode 100644 index 000000000..534585d5c --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/media/ZoomablePagerImage.kt @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.media + +import android.os.Build +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.size.Scale +import com.dot.gallery.core.Constants.DEFAULT_TOP_BAR_ANIMATION_DURATION +import com.dot.gallery.core.Settings +import com.dot.gallery.core.presentation.components.util.LocalBatteryStatus +import com.dot.gallery.core.presentation.components.util.ProvideBatteryStatus +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.ZoomableImage +import me.saket.telephoto.zoomable.ZoomableImageSource +import me.saket.telephoto.zoomable.coil3.coil3 +import me.saket.telephoto.zoomable.rememberZoomableImageState +import me.saket.telephoto.zoomable.rememberZoomableState +import net.engawapg.lib.zoomable.rememberZoomState + +@Composable +fun ZoomablePagerImage( + modifier: Modifier = Modifier, + media: EncryptedMedia, + uiEnabled: Boolean, + maxScale: Float = 10f, + onItemClick: () -> Unit +) { + val zoomState = rememberZoomState( + maxScale = maxScale, + ) + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(media.bytes) + .memoryCachePolicy(CachePolicy.ENABLED) + .placeholderMemoryCacheKey(media.toString()) + .scale(Scale.FILL) + .build(), + contentScale = ContentScale.Fit, + filterQuality = FilterQuality.None, + onSuccess = { + zoomState.setContentSize(it.painter.intrinsicSize) + } + ) + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec( + maxZoomFactor = maxScale + ) + ) + val state = rememberZoomableImageState( + zoomableState = zoomableState + ) + + Box(modifier = Modifier.fillMaxSize()) { + ProvideBatteryStatus { + val allowBlur by Settings.Misc.rememberAllowBlur() + val isPowerSavingMode = LocalBatteryStatus.current.isPowerSavingMode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && allowBlur && !isPowerSavingMode) { + val blurAlpha by animateFloatAsState( + animationSpec = tween(DEFAULT_TOP_BAR_ANIMATION_DURATION), + targetValue = if (uiEnabled) 0.7f else 0f, + label = "blurAlpha" + ) + Image( + modifier = Modifier + .fillMaxSize() + .alpha(blurAlpha) + .blur(100.dp), + painter = painter, + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + } + + ZoomableImage( + modifier = modifier.fillMaxSize(), + onClick = { onItemClick() }, + state = state, + image = ZoomableImageSource.coil3( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(media.bytes) + .placeholderMemoryCacheKey(media.toString()) + .build() + ), + contentScale = ContentScale.Fit, + contentDescription = media.label + ) + } + + +} diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoDurationHeader.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoDurationHeader.kt new file mode 100644 index 000000000..d67f661a7 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoDurationHeader.kt @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PlayCircle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp +import com.dot.gallery.core.presentation.components.util.advancedShadow +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import com.dot.gallery.feature_node.presentation.util.formatMinSec + +@Composable +fun VideoDurationHeader(modifier: Modifier = Modifier, media: EncryptedMedia) { + Row( + modifier = modifier + .padding(all = 8.dp) + .advancedShadow( + cornersRadius = 8.dp, + shadowBlurRadius = 6.dp, + alpha = 0.3f + ), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier, + text = media.duration.formatMinSec(), + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + Spacer(modifier = Modifier.size(2.dp)) + Image( + modifier = Modifier + .size(16.dp) + .advancedShadow( + cornersRadius = 2.dp, + shadowBlurRadius = 6.dp, + alpha = 0.1f, + offsetY = (-2).dp + ), + imageVector = Icons.Rounded.PlayCircle, + colorFilter = ColorFilter.tint(color = Color.White), + contentDescription = "Video" + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt new file mode 100644 index 000000000..e856af812 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayer.kt @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video + +import android.annotation.SuppressLint +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import com.dot.gallery.feature_node.domain.model.EncryptedMedia +import kotlinx.coroutines.delay +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.net.MalformedURLException +import java.net.URISyntaxException +import java.net.URL +import java.net.URLConnection +import java.net.URLStreamHandler +import kotlin.time.Duration.Companion.seconds + + +fun encryptedMediaToFileDescriptor(encryptedMedia: EncryptedMedia): FileDescriptor { + // Create a temporary file + val tempFile = File.createTempFile("${encryptedMedia.id}.temp", null) + + // Write the ByteArray to the temporary file + FileOutputStream(tempFile).use { fileOutputStream -> + fileOutputStream.write(encryptedMedia.bytes) + fileOutputStream.flush() + } + + // Get the FileDescriptor of the temporary file + return FileInputStream(tempFile).fd +} + +object UriByteDataHelper { + fun getUri(data: ByteArray?, isVideo: Boolean): Uri { + try { + val url = URL(null, "bytes:///" + if (isVideo) "video" else "image", BytesHandler(data)) + return Uri.parse(url.toURI().toString()) + } catch (e: MalformedURLException) { + throw RuntimeException(e) + } catch (e: URISyntaxException) { + throw RuntimeException(e) + } + } + + private class BytesHandler(private val data: ByteArray?) : URLStreamHandler() { + override fun openConnection(url: URL): URLConnection { + return object : URLConnection(url) { + override fun connect() {} + override fun getInputStream(): InputStream { + return ByteArrayInputStream(data) + } + } + } + } + + private class ByteUrlConnection(url: URL, private val data: ByteArray?) : URLConnection(url) { + override fun connect() {} + override fun getInputStream(): InputStream { + return ByteArrayInputStream(data) + } + } +} + +@SuppressLint("OpaqueUnitKey") +@OptIn(ExperimentalFoundationApi::class) +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun VideoPlayer( + media: EncryptedMedia, + playWhenReady: Boolean, + videoController: @Composable (ExoPlayer, MutableState, MutableState, Long, Int, Float) -> Unit, + onItemClick: () -> Unit +) { + var totalDuration by rememberSaveable { mutableLongStateOf(0L) } + val currentTime = rememberSaveable { mutableLongStateOf(0L) } + var bufferedPercentage by rememberSaveable { mutableIntStateOf(0) } + val isPlaying = rememberSaveable(playWhenReady) { mutableStateOf(playWhenReady) } + var lastPlayingState by rememberSaveable(isPlaying.value) { mutableStateOf(isPlaying.value) } + val context = LocalContext.current + + val metadata = remember(media) { + MediaMetadataRetriever().apply { + setDataSource(encryptedMediaToFileDescriptor(media)) + } + } + val frameRate = remember(metadata) { + metadata.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE + )?.toFloat() ?: 60f + } + + val exoPlayer = remember { + ExoPlayer.Builder(context) + .build().apply { + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + repeatMode = Player.REPEAT_MODE_ONE + setMediaItem(MediaItem.fromUri(UriByteDataHelper.getUri(media.bytes, true))) + prepare() + } + } + + val playerView = remember { PlayerView(context) } + + DisposableEffect( + Box { + AndroidView( + modifier = Modifier + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onItemClick, + ), + factory = { + playerView.apply { + useController = false + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + + player = exoPlayer + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + + keepScreenOn = true + } + } + ) + videoController( + exoPlayer, + isPlaying, + currentTime, + totalDuration, + bufferedPercentage, + frameRate + ) + } + ) { + exoPlayer.addListener( + object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + totalDuration = exoPlayer.duration.coerceAtLeast(0L) + lastPlayingState = isPlaying.value + isPlaying.value = player.isPlaying + } + } + ) + onDispose { + exoPlayer.release() + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && isPlaying.value) { + exoPlayer.play() + } else if (event == Lifecycle.Event.ON_STOP || event == Lifecycle.Event.ON_PAUSE) { + exoPlayer.pause() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(LocalConfiguration.current, isPlaying.value) { + if (exoPlayer.currentPosition != currentTime.longValue) { + exoPlayer.seekTo(currentTime.longValue) + } + + delay(50) + + exoPlayer.playWhenReady = isPlaying.value + } + + if (isPlaying.value) { + LaunchedEffect(Unit) { + while (true) { + currentTime.longValue = exoPlayer.currentPosition.coerceAtLeast(0L) + bufferedPercentage = exoPlayer.bufferedPercentage + delay(1.seconds / 30) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt new file mode 100644 index 000000000..c5620ecbf --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/encryptedmediaview/components/video/VideoPlayerController.kt @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2023 IacobIacob01 + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.dot.gallery.feature_node.presentation.vault.encryptedmediaview.components.video + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.VolumeMute +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.filled.PauseCircleFilled +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.media3.exoplayer.ExoPlayer +import com.dot.gallery.R +import com.dot.gallery.feature_node.domain.model.PlaybackSpeed +import com.dot.gallery.feature_node.presentation.util.formatMinSec +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun VideoPlayerController( + paddingValues: PaddingValues, + player: ExoPlayer, + isPlaying: MutableState, + currentTime: MutableState, + totalTime: Long, + buffer: Int, + toggleRotate: () -> Unit, + frameRate: Float +) { + val scope = rememberCoroutineScope() + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + ) { + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 16.dp) + .padding(bottom = paddingValues.calculateBottomPadding() + 72.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End + ) { + var isMuted by rememberSaveable(isPlaying) { mutableStateOf(player.volume == 0f) } + var currentVolume by rememberSaveable(player) { mutableFloatStateOf(player.volume) } + LaunchedEffect(LocalConfiguration.current, player.currentMediaItem) { + player.volume = if (isMuted) 0f else currentVolume + } + var auto by rememberSaveable { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } + var playbackSpeed by rememberSaveable { mutableFloatStateOf(1f) } + val ctx = LocalContext.current + val playbackSpeeds = remember { + listOf( + PlaybackSpeed(1f / (frameRate / 30f), ctx.getString(R.string.auto), true), + PlaybackSpeed(0.125f, "0.125x"), + PlaybackSpeed(0.25f, "0.25x"), + PlaybackSpeed(0.5f, "0.5x"), + PlaybackSpeed(1f, "1x"), + PlaybackSpeed(2f, "2x") + ) + } + LaunchedEffect(playbackSpeed) { + player.setPlaybackSpeed(playbackSpeed) + showMenu = false + } + + Box(contentAlignment = Alignment.TopEnd) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { + showMenu = false + } + ) { + playbackSpeeds.forEach { speed -> + DropdownMenuItem( + modifier = Modifier.padding(end = 16.dp), + onClick = { + playbackSpeed = speed.speed + auto = speed.isAuto + }, + leadingIcon = { + RadioButton( + selected = playbackSpeed == speed.speed && !speed.isAuto, + onClick = { + playbackSpeed = speed.speed + auto = speed.isAuto + } + ) + }, + text = { Text(text = speed.label) } + ) + } + } + IconButton( + onClick = { + showMenu = !showMenu + } + ) { + Icon( + imageVector = Icons.Outlined.Speed, + tint = Color.White, + contentDescription = stringResource(R.string.change_playback_speed_cd) + ) + } + } + IconButton( + onClick = { + if (isMuted) { + player.volume = currentVolume + isMuted = false + } else { + currentVolume = player.volume + player.volume = 0f + isMuted = true + } + } + ) { + Icon( + imageVector = if (isMuted) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Outlined.VolumeUp, + tint = Color.White, + contentDescription = stringResource( + R.string.toggle_audio_cd + ) + ) + } + IconButton( + onClick = { toggleRotate() } + ) { + Icon( + imageVector = Icons.Outlined.ScreenRotation, + tint = Color.White, + contentDescription = stringResource( + R.string.rotate_screen_cd + ) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Text( + modifier = Modifier.width(52.dp), + text = currentTime.value.formatMinSec(), + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center + ) + Box(Modifier.weight(1f)) { + Slider( + modifier = Modifier.fillMaxWidth(), + value = buffer.toFloat(), + enabled = false, + onValueChange = {}, + valueRange = 0f..100f, + colors = + SliderDefaults.colors( + disabledThumbColor = Color.Transparent, + disabledInactiveTrackColor = Color.DarkGray.copy(alpha = 0.4f), + disabledActiveTrackColor = Color.Gray + ) + ) + Slider( + modifier = Modifier.fillMaxWidth(), + value = currentTime.value.toFloat(), + onValueChange = { + scope.launch { + currentTime.value = it.toLong() + player.seekTo(it.toLong()) + delay(50) + player.play() + } + }, + valueRange = 0f..totalTime.toFloat(), + colors = + SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White, + activeTickColor = Color.White, + inactiveTrackColor = Color.Transparent + ) + ) + } + Text( + modifier = Modifier.width(52.dp), + text = totalTime.formatMinSec(), + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } + + IconButton( + onClick = { isPlaying.value = !isPlaying.value }, + modifier = Modifier + .align(Alignment.Center) + .size(64.dp) + ) { + if (isPlaying.value) { + Image( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Filled.PauseCircleFilled, + contentDescription = stringResource(R.string.pause_video), + colorFilter = ColorFilter.tint(Color.White) + ) + } else { + Image( + modifier = Modifier.fillMaxSize(), + imageVector = Icons.Filled.PlayCircleFilled, + contentDescription = stringResource(R.string.play_video), + colorFilter = ColorFilter.tint(Color.White) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt new file mode 100644 index 000000000..4e2481ccc --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/feature_node/presentation/vault/utils/BiometricManagerExt.kt @@ -0,0 +1,89 @@ +package com.dot.gallery.feature_node.presentation.vault.utils + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +@Composable +fun rememberBiometricManager(): BiometricManager { + val context = LocalContext.current + return remember(context) { + BiometricManager.from(context) + } +} + +@Composable +fun rememberBiometricCallback( + onSuccess: () -> Unit, + onFailed: () -> Unit +) = remember { + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + onFailed() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onFailed() + } + } +} + +@Composable +fun rememberBiometricPrompt(biometricPromptCallback: BiometricPrompt.AuthenticationCallback): BiometricPrompt { + val context = LocalContext.current + val executor = remember { ContextCompat.getMainExecutor(context) } + + return remember(context, executor, biometricPromptCallback) { + BiometricPrompt( + context as FragmentActivity, executor, biometricPromptCallback + ) + } +} + +@Composable +fun rememberBiometricState( + biometricPromptInfo: BiometricPrompt.PromptInfo, + onSuccess: () -> Unit, + onFailed: () -> Unit +): BiometricState { + val biometricManager = rememberBiometricManager() + val callback = rememberBiometricCallback(onSuccess, onFailed) + val prompt = rememberBiometricPrompt(callback) + val promptInfo = remember { biometricPromptInfo } + return remember(biometricManager, biometricPromptInfo) { + BiometricState( + biometricManager = biometricManager, + promptInfo = promptInfo, + prompt = prompt + ) + } +} + +class BiometricState( + biometricManager: BiometricManager, + private val promptInfo: BiometricPrompt.PromptInfo, + private val prompt: BiometricPrompt +) { + val canAllowAccess = biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + + fun authenticate() { + if (canAllowAccess) { + prompt.authenticate(promptInfo) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt b/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt index 4af85c59d..70a688d8f 100644 --- a/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt +++ b/app/src/main/kotlin/com/dot/gallery/injection/AppModule.kt @@ -12,9 +12,11 @@ import androidx.room.Room import coil.ImageLoader import coil.request.ImageRequest import com.dot.gallery.feature_node.data.data_source.InternalDatabase +import com.dot.gallery.feature_node.data.data_source.KeychainHolder import com.dot.gallery.feature_node.data.repository.MediaRepositoryImpl import com.dot.gallery.feature_node.domain.repository.MediaRepository import com.dot.gallery.feature_node.domain.use_case.MediaUseCases +import com.dot.gallery.feature_node.domain.use_case.VaultUseCases import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -38,19 +40,32 @@ object AppModule { .build() } + @Provides + @Singleton + fun provideKeychainHolder(@ApplicationContext context: Context): KeychainHolder { + return KeychainHolder(context) + } + @Provides @Singleton fun provideMediaUseCases(repository: MediaRepository, @ApplicationContext context: Context): MediaUseCases { return MediaUseCases(context, repository) } + @Provides + @Singleton + fun provideVaultUseCases(repository: MediaRepository): VaultUseCases { + return VaultUseCases(repository) + } + @Provides @Singleton fun provideMediaRepository( @ApplicationContext context: Context, - database: InternalDatabase + database: InternalDatabase, + keychainHolder: KeychainHolder ): MediaRepository { - return MediaRepositoryImpl(context, database) + return MediaRepositoryImpl(context, database, keychainHolder) } @Provides diff --git a/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Albums.kt b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Albums.kt new file mode 100644 index 000000000..2107a6b45 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Albums.kt @@ -0,0 +1,79 @@ +package com.dot.gallery.ui.core.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.dot.gallery.ui.core.Icons + +public val Icons.Albums: ImageVector + get() { + if (_albums != null) { + return _albums!! + } + _albums = Builder(name = "Albums", defaultWidth = 25.0.dp, defaultHeight = 24.0.dp, + viewportWidth = 25.0f, viewportHeight = 24.0f).apply { + path(fill = SolidColor(Color(0xFF49454F)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(3.6665f, 11.0f) + verticalLineTo(19.0f) + horizontalLineTo(13.6665f) + verticalLineTo(21.0f) + horizontalLineTo(3.6665f) + curveTo(2.5619f, 21.0f, 1.6665f, 20.1046f, 1.6665f, 19.0f) + verticalLineTo(11.0f) + curveTo(1.6665f, 9.8954f, 2.5619f, 9.0f, 3.6665f, 9.0f) + horizontalLineTo(7.6665f) + verticalLineTo(11.0f) + horizontalLineTo(3.6665f) + close() + } + path(fill = SolidColor(Color(0xFF49454F)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(17.6665f, 5.0f) + horizontalLineTo(9.6665f) + lineTo(9.6665f, 21.0f) + horizontalLineTo(7.6665f) + verticalLineTo(5.0f) + curveTo(7.6665f, 3.8954f, 8.5619f, 3.0f, 9.6665f, 3.0f) + horizontalLineTo(17.6665f) + curveTo(18.7711f, 3.0f, 19.6665f, 3.8954f, 19.6665f, 5.0f) + verticalLineTo(13.0f) + horizontalLineTo(17.6665f) + verticalLineTo(5.0f) + close() + } + path(fill = SolidColor(Color(0xFF49454F)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = EvenOdd) { + moveTo(21.6665f, 15.0f) + horizontalLineTo(13.6665f) + lineTo(13.6665f, 19.0f) + horizontalLineTo(21.6665f) + verticalLineTo(15.0f) + close() + moveTo(13.6665f, 13.0f) + curveTo(12.5619f, 13.0f, 11.6665f, 13.8954f, 11.6665f, 15.0f) + verticalLineTo(19.0f) + curveTo(11.6665f, 20.1046f, 12.5619f, 21.0f, 13.6665f, 21.0f) + horizontalLineTo(21.6665f) + curveTo(22.7711f, 21.0f, 23.6665f, 20.1046f, 23.6665f, 19.0f) + verticalLineTo(15.0f) + curveTo(23.6665f, 13.8954f, 22.7711f, 13.0f, 21.6665f, 13.0f) + horizontalLineTo(13.6665f) + close() + } + } + .build() + return _albums!! + } + +private var _albums: ImageVector? = null diff --git a/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt new file mode 100644 index 000000000..6ae2c9270 --- /dev/null +++ b/app/src/main/kotlin/com/dot/gallery/ui/core/icons/Encrypted.kt @@ -0,0 +1,66 @@ +package com.dot.gallery.ui.core.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import com.dot.gallery.ui.core.Icons + +public val Icons.Encrypted: ImageVector + get() { + if (_encrypted != null) { + return _encrypted!! + } + _encrypted = Builder(name = "Encrypted", + defaultWidth = 24.0.dp, defaultHeight = 24.0.dp, viewportWidth = 960.0f, + viewportHeight = 960.0f).apply { + path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero) { + moveTo(420.0f, 600.0f) + horizontalLineToRelative(120.0f) + lineToRelative(-23.0f, -129.0f) + quadToRelative(20.0f, -10.0f, 31.5f, -29.0f) + reflectiveQuadToRelative(11.5f, -42.0f) + quadToRelative(0.0f, -33.0f, -23.5f, -56.5f) + reflectiveQuadTo(480.0f, 320.0f) + quadToRelative(-33.0f, 0.0f, -56.5f, 23.5f) + reflectiveQuadTo(400.0f, 400.0f) + quadToRelative(0.0f, 23.0f, 11.5f, 42.0f) + reflectiveQuadToRelative(31.5f, 29.0f) + lineToRelative(-23.0f, 129.0f) + close() + moveTo(480.0f, 880.0f) + quadToRelative(-139.0f, -35.0f, -229.5f, -159.5f) + reflectiveQuadTo(160.0f, 444.0f) + verticalLineToRelative(-244.0f) + lineToRelative(320.0f, -120.0f) + lineToRelative(320.0f, 120.0f) + verticalLineToRelative(244.0f) + quadToRelative(0.0f, 152.0f, -90.5f, 276.5f) + reflectiveQuadTo(480.0f, 880.0f) + close() + moveTo(480.0f, 796.0f) + quadToRelative(104.0f, -33.0f, 172.0f, -132.0f) + reflectiveQuadToRelative(68.0f, -220.0f) + verticalLineToRelative(-189.0f) + lineToRelative(-240.0f, -90.0f) + lineToRelative(-240.0f, 90.0f) + verticalLineToRelative(189.0f) + quadToRelative(0.0f, 121.0f, 68.0f, 220.0f) + reflectiveQuadToRelative(172.0f, 132.0f) + close() + moveTo(480.0f, 480.0f) + close() + } + } + .build() + return _encrypted!! + } + +private var _encrypted: ImageVector? = null diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 485db0d89..e998b480f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,18 @@ • Manage All Files " • Allows the app to create, modify, and delete files and albums that were previously restricted by the system. (Includes albums outside Pictures and DCIM folders and the ones from the SD Card" Allow to Manage All Files + Vault + Tools + Library + Biometric Authentication + Unlock your Vault + Add Media to Vault + Vault %1$s (%2$s) exists + Granted + New Vault + Delete Vault + Unknown Vault + Hide %s item %s items