+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
index de1edcb..be86ea9 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,8 @@
-> [!IMPORTANT]
-> Truvark will soon be free and open source software (FOSS). The code will be made available in this repository.
+
@@ -84,7 +85,6 @@ Finally, Realm was chosen for the database because it supports database encrypti
> [!NOTE]
> Many other vault apps use an unencrypted database!
----
+## License
-> [!IMPORTANT]
-> Truvark will soon be free and open source software (FOSS). The code will be made available in this repository.
+This app is released under [*GPL-3.0-or-later*](LICENSES/GPL-3.0-or-later.txt).
diff --git a/REUSE.toml b/REUSE.toml
new file mode 100644
index 0000000..5530705
--- /dev/null
+++ b/REUSE.toml
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: 2025 Lukas Pieper
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+version = 1
+
+[[annotations]]
+path = [
+ "android/src/main/**/ic_launcher*.png",
+ ".idea/**",
+ ".detekt/detekt-baseline.xml"
+]
+SPDX-FileCopyrightText = "2022 Lukas Pieper"
+SPDX-License-Identifier = "GPL-3.0-or-later"
+
+[[annotations]]
+path = "gradle/wrapper/gradle-wrapper.jar"
+SPDX-FileCopyrightText = "2015-2021 the original authors"
+SPDX-License-Identifier = "Apache-2.0"
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..3bd33c5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,19 @@
+
+
+# Security Policy
+
+## Supported Versions
+
+Only the latest version is supported. However, vulnerability reports for older versions are still welcome for transparency and containment purposes (if possible).
+
+## Reporting a Vulnerability
+
+Please report any potential vulnerabilities privately using the **Security** tab on this GitHub repository. The documentation for this GitHub feature can be found
+[here](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability).
+
+> [!CAUTION]
+> Do not disclose a vulnerability through a public issue, discussion, pull request, or the like.
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..db1ba5a
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: 2022 Lukas Pieper
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+/build
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..4c6645e
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,168 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+plugins {
+ alias(libs.plugins.android.gradle)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.kotlin.kapt)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.android.hilt)
+ id("com.google.android.gms.oss-licenses-plugin")
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+android {
+ namespace = "de.lukaspieper.truvark"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 29
+ targetSdk = 34
+ versionCode = 15
+ versionName = "1.0.1"
+
+ ndk {
+ // Tink does not support 32-bit architectures (https://developers.google.com/tink/faq/support_for_32bit)
+ abiFilters += listOf("arm64-v8a", "x86_64")
+ }
+ }
+
+ buildTypes {
+ debug {
+ applicationIdSuffix = ".dev"
+ versionNameSuffix = "-DEV"
+ resValue("string", "app_name", "Truvark DEV")
+
+ // Can be enabled for testing
+ isMinifyEnabled = false
+ isShrinkResources = false
+ isDebuggable = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ release {
+ resValue("string", "app_name", "Truvark")
+
+ isMinifyEnabled = true
+ isShrinkResources = true
+ isDebuggable = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ ndk {
+ debugSymbolLevel = "FULL"
+ }
+ }
+ }
+
+ buildFeatures {
+ buildConfig = true
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+
+ lint {
+ sarifReport = true
+ abortOnError = false
+ checkDependencies = true
+ }
+}
+
+composeCompiler {
+ reportsDestination = layout.buildDirectory.dir("compose_compiler")
+ metricsDestination = layout.buildDirectory.dir("compose_compiler")
+}
+
+dependencies {
+ val composeBom = platform(libs.android.compose.bom)
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation(project(":common"))
+
+ // Foundation
+ implementation(libs.android.core.ktx)
+ implementation(libs.android.core.splashscreen)
+ implementation(libs.android.activity.compose)
+ implementation(libs.android.lifecycle.process)
+
+ // Coroutines
+ implementation(libs.kotlin.coroutines.android)
+ implementation(libs.android.workmanager)
+
+ // Data and storage
+ implementation(libs.android.datastore.preferences)
+ // Required for classes that inherit from @Serializable classes
+ implementation(libs.kotlin.serialization.json)
+
+ // Dependency Injection
+ implementation(libs.dagger.hilt)
+ kapt(libs.dagger.hilt.compiler)
+
+ implementation(libs.android.hilt.workmanager)
+ kapt(libs.android.hilt.compiler)
+ implementation(libs.android.hilt.navigation.compose)
+
+ // Compose
+ implementation(libs.android.ui)
+ implementation(libs.android.ui.graphics)
+ implementation(libs.android.ui.tooling.preview)
+ implementation(libs.android.material3)
+ implementation(libs.android.lifecycle.runtime.compose)
+ implementation(libs.android.compose.material.icons.extended)
+ debugImplementation(libs.android.ui.tooling)
+
+ implementation(libs.google.material)
+ implementation(libs.google.accompanist.permissions)
+
+ // Adaptive
+ implementation(libs.android.compose.adaptive)
+ implementation(libs.android.compose.adaptive.layout)
+ implementation(libs.android.compose.adaptive.navigation)
+
+ // Navigation
+ implementation(libs.android.navigation.compose)
+
+ // Media player
+ implementation(libs.telephoto)
+ implementation(libs.google.accompanist.drawablepainter)
+ implementation(libs.bundles.coil)
+
+ // Cryptography
+ implementation(libs.argon2.android)
+ implementation(libs.android.biometric.ktx)
+}
+
+kapt {
+ correctErrorTypes = true
+}
+
+android.applicationVariants.configureEach {
+ val variantName = name
+
+ val copyLicensesTask = tasks.register("${variantName}CopyLicenses") {
+ from("$rootDir/LICENSES")
+ // TODO: Don't depend on oss-licenses-plugin's directory. Haven't figured `registerGeneratedResFolders` out yet.
+ into(layout.buildDirectory.dir("generated/third_party_licenses/$variantName/res/raw"))
+
+ rename { fileName ->
+ fileName.lowercase()
+ .removeSuffix(".txt")
+ .replace("-", "_")
+ .replace(".", "_")
+ }
+ }
+
+ tasks.named("preBuild") {
+ dependsOn(copyLicensesTask)
+ }
+}
diff --git a/android/proguard-rules.pro b/android/proguard-rules.pro
new file mode 100644
index 0000000..ef78f4f
--- /dev/null
+++ b/android/proguard-rules.pro
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2022 Lukas Pieper
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+# All warnings come from okhttp3
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
\ No newline at end of file
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..567c7ab
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/src/main/ic_launcher-playstore.png b/android/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000000000000000000000000000000000000..97d2e3609cd5c9d46d34511caf5fe7878a690e93
GIT binary patch
literal 8054
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 {
+ LauncherPage(
+ navigateAndClearBackStack = { route ->
+ navController.navigateSafely(route) {
+ popUpTo(0)
+ }
+ }
+ )
+ }
+ composable {
+ BrowserPage(navigate = { route -> navController.navigateSafely(route) })
+ }
+ composable { navigation ->
+ val route: Page.Presenter = navigation.toRoute()
+
+ PresenterPage(
+ parameters = route,
+ navigateBack = navController::popBackStack
+ )
+ }
+ composable {
+ SettingsHomePage(
+ navigateBack = navController::popBackStack
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun NavController.navigateSafely(route: Page, builder: NavOptionsBuilder.() -> Unit = {}) {
+ // This approach prevents multi-touch navigation, e.g. touching multiple files in the file browser would
+ // lead to multiple navigation events. Navigating back would need to go through all of them.
+ if (currentDestination?.hasRoute(route::class) == false) {
+ navigate(route, builder)
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/App.kt b/android/src/main/java/de/lukaspieper/truvark/App.kt
new file mode 100644
index 0000000..5d6742e
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/App.kt
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark
+
+import android.app.Application
+import android.os.StrictMode
+import android.os.StrictMode.VmPolicy
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import com.google.crypto.tink.streamingaead.StreamingAeadConfig
+import dagger.hilt.android.HiltAndroidApp
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.logging.AndroidLogcatLogger
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+@HiltAndroidApp
+class App : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var preferences: PersistentPreferences
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ override val workManagerConfiguration: Configuration
+ get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) {
+ // StrictMode.enableDefaults();
+
+ StrictMode.setVmPolicy(
+ VmPolicy.Builder()
+ .detectLeakedClosableObjects()
+ .penaltyLog()
+ .build()
+ )
+ }
+
+ initLogging()
+ initTink()
+ }
+
+ private fun initLogging() {
+ runBlocking {
+ val isLoggingAllowed = preferences.loggingAllowed.first()
+
+ if (isLoggingAllowed) {
+ AndroidLogcatLogger.installWithDefaultPriority()
+ }
+ }
+
+ // Obviously, this message is not printed when NoLog is active.
+ logcat(LogPriority.INFO) { "Logging is enabled." }
+ }
+
+ private fun initTink() {
+ StreamingAeadConfig.register()
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/Page.kt b/android/src/main/java/de/lukaspieper/truvark/Page.kt
new file mode 100644
index 0000000..37c80b0
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/Page.kt
@@ -0,0 +1,24 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed interface Page {
+ @Serializable
+ data object Launcher : Page
+
+ @Serializable
+ data object Browser : Page
+
+ @Serializable
+ data class Presenter(val folderId: String, val fileId: String) : Page
+
+ @Serializable
+ data object SettingsHome : Page
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/common/NotificationChannel.kt b/android/src/main/java/de/lukaspieper/truvark/common/NotificationChannel.kt
new file mode 100644
index 0000000..28b5ca5
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/common/NotificationChannel.kt
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.common
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.graphics.Color
+import androidx.annotation.StringRes
+import androidx.core.app.NotificationCompat
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.logcat
+import java.security.SecureRandom
+
+class NotificationChannel(
+ private val appContext: Context,
+ private val channelId: String,
+ @StringRes private val channelNameResId: Int
+) {
+ private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ private val random = SecureRandom()
+
+ private val isChannelCreated by lazy { createNotificationChannel() }
+
+ private fun createNotificationChannel(): Boolean {
+ val channel = NotificationChannel(
+ channelId,
+ appContext.getString(channelNameResId),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ channel.lightColor = Color.GREEN
+ channel.lockscreenVisibility = Notification.VISIBILITY_SECRET
+
+ notificationManager.createNotificationChannel(channel)
+
+ return true
+ }
+
+ fun generateNotificationId(): Int {
+ return random.nextInt()
+ }
+
+ fun provideNotificationBuilder(): NotificationCompat.Builder {
+ return NotificationCompat.Builder(appContext, channelId)
+ }
+
+ fun notify(notificationId: Int, notification: Notification) {
+ if (notificationManager.areNotificationsEnabled() && isChannelCreated) {
+ notificationManager.notify(notificationId, notification)
+ } else {
+ logcat(LogPriority.WARN) { "Notification permission is not granted." }
+ }
+ }
+
+ fun cancel(notificationId: Int) {
+ notificationManager.cancel(notificationId)
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/data/database/DatabaseFileSynchronization.kt b/android/src/main/java/de/lukaspieper/truvark/data/database/DatabaseFileSynchronization.kt
new file mode 100644
index 0000000..3dda70f
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/data/database/DatabaseFileSynchronization.kt
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.data.database
+
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.data.io.FileSystem
+import de.lukaspieper.truvark.work.DatabaseSyncingWorker
+import de.lukaspieper.truvark.work.WorkScheduler
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.File
+
+/**
+ * Because there is no database implementation with SAF, the database in the vault directory is copied to the internal
+ * storage for direct access. As a consequence both database files must be synchronized before usage and after
+ * modifications.
+ *
+ * This class handles the synchronization before usage.
+ */
+class DatabaseFileSynchronization(
+ private val workScheduler: WorkScheduler,
+ private val fileSystem: FileSystem
+) {
+ @Throws(IllegalStateException::class)
+ fun synchronizeDatabaseFiles(vaultDatabaseFile: FileInfo, internalDatabaseFile: File) {
+ when {
+ vaultDatabaseFileNeedsRecovery(vaultDatabaseFile, internalDatabaseFile) -> exportInternalDatabaseFile()
+
+ canImportVaultDatabaseFile(vaultDatabaseFile, internalDatabaseFile) -> importVaultDatabaseFile(
+ vaultDatabaseFile,
+ internalDatabaseFile
+ )
+
+ else -> error("Database is not available")
+ }
+ }
+
+ private fun vaultDatabaseFileNeedsRecovery(vaultDatabaseFile: FileInfo, internalDatabaseFile: File): Boolean {
+ return vaultDatabaseFile.size == 0L && internalDatabaseFile.length() > 0L
+ }
+
+ private fun canImportVaultDatabaseFile(vaultDatabaseFile: FileInfo, internalDatabaseFile: File): Boolean {
+ return vaultDatabaseFile.size > 0L && internalDatabaseFile.length() >= 0L
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private fun exportInternalDatabaseFile() {
+ GlobalScope.launch {
+ // TODO: It can happen that the synchronization starts before the vault creation is finished. In this case,
+ // the app will crash. The delay is a workaround for this issue.
+ delay(2000)
+
+ workScheduler.schedule(
+ DatabaseSyncingWorker.EmptyWorkBundle(),
+ WorkScheduler.AndroidSchedulerMetadata(R.string.sync_database)
+ )
+ }
+ }
+
+ private fun importVaultDatabaseFile(vaultDatabaseFile: FileInfo, internalDatabaseFile: File) {
+ internalDatabaseFile.parentFile!!.mkdirs()
+
+ fileSystem.openInputStream(vaultDatabaseFile).use { inputStream ->
+ internalDatabaseFile.outputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/data/io/AndroidFileSystem.kt b/android/src/main/java/de/lukaspieper/truvark/data/io/AndroidFileSystem.kt
new file mode 100644
index 0000000..d76d712
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/data/io/AndroidFileSystem.kt
@@ -0,0 +1,298 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.data.io
+
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.database.Cursor
+import android.media.MediaMetadataRetriever
+import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
+import android.net.Uri
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Document.*
+import de.lukaspieper.truvark.common.data.io.DirectoryInfo
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.data.io.FileSystem
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * A [FileSystem] implementation for Android's Storage Access Framework (content:// URIs).
+ */
+class AndroidFileSystem(private val context: Context) : FileSystem() {
+
+ fun appFilesDir(): File {
+ return context.filesDir
+ }
+
+ fun takePersistableUriPermission(uri: Uri) {
+ require(uri != Uri.EMPTY) { "uri must not be empty" }
+
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ }
+
+ fun fileInfo(uri: Uri): FileInfo {
+ require(uri != Uri.EMPTY) { "uri must not be empty" }
+
+ context.contentResolver.query(
+ uri,
+ arrayOf(
+ COLUMN_DISPLAY_NAME,
+ COLUMN_MIME_TYPE,
+ COLUMN_SIZE
+ )
+ ) { cursor ->
+ if (cursor?.moveToFirst() == true && cursor.getString(COLUMN_MIME_TYPE) != MIME_TYPE_DIR) {
+ val mimeType = cursor.getString(COLUMN_MIME_TYPE)
+ var mediaDuration: Duration? = null
+
+ if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) {
+ val retriever = MediaMetadataRetriever()
+ try {
+ retriever.setDataSource(context, uri)
+ mediaDuration = retriever.extractMetadata(METADATA_KEY_DURATION)?.toLongOrNull()?.milliseconds
+ } catch (e: Exception) {
+ logcat(LogPriority.WARN) { e.asLog() }
+ } finally {
+ retriever.release()
+ }
+ }
+
+ return FileInfo(
+ uri = uri,
+ fullName = cursor.getString(COLUMN_DISPLAY_NAME),
+ mimeType = cursor.getString(COLUMN_MIME_TYPE),
+ size = cursor.getLong(COLUMN_SIZE),
+ mediaDuration = mediaDuration
+ )
+ }
+ }
+
+ throw FileNotFoundException()
+ }
+
+ @Throws(Exception::class)
+ fun directoryInfo(treeUri: Uri): DirectoryInfo {
+ require(treeUri != Uri.EMPTY) { "uri must not be empty" }
+
+ val uri = convertTreeUriToDocumentUri(treeUri)
+
+ context.contentResolver.query(
+ uri,
+ arrayOf(
+ COLUMN_DOCUMENT_ID,
+ COLUMN_DISPLAY_NAME,
+ COLUMN_MIME_TYPE
+ )
+ ) { cursor ->
+ if (cursor?.moveToFirst() == true && cursor.getString(COLUMN_MIME_TYPE) == MIME_TYPE_DIR) {
+ return DirectoryInfo(
+ uri = DocumentsContract.buildDocumentUriUsingTree(
+ uri,
+ cursor.getString(COLUMN_DOCUMENT_ID)
+ ),
+ name = cursor.getString(COLUMN_DISPLAY_NAME)
+ )
+ }
+ }
+
+ throw FileNotFoundException()
+ }
+
+ private fun convertTreeUriToDocumentUri(uri: Uri): Uri {
+ val documentId = when {
+ DocumentsContract.isDocumentUri(context, uri) -> DocumentsContract.getDocumentId(uri)
+ else -> DocumentsContract.getTreeDocumentId(uri)
+ }
+
+ return DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ }
+
+ @Throws(Exception::class)
+ override fun createFile(directoryInfo: DirectoryInfo, name: String, mimeType: String): FileInfo {
+ val uri = directoryInfo.uri as Uri
+ if (findFileOrNull(directoryInfo, name) != null) throw IOException("File already exists")
+
+ val fileUri = DocumentsContract.createDocument(context.contentResolver, uri, mimeType, name)
+ ?: throw IOException("Could not create file")
+
+ return FileInfo(fileUri, name, 0, mimeType)
+ }
+
+ @Throws(Exception::class)
+ override fun findOrCreateDirectory(directoryInfo: DirectoryInfo, name: String): DirectoryInfo {
+ return findDirectoryOrNull(directoryInfo, name) ?: createDirectory(directoryInfo, name)
+ }
+
+ @Throws(Exception::class)
+ private fun createDirectory(directoryInfo: DirectoryInfo, name: String): DirectoryInfo {
+ val uri = directoryInfo.uri as Uri
+
+ val directoryUri = DocumentsContract.createDocument(
+ context.contentResolver,
+ uri,
+ MIME_TYPE_DIR,
+ name
+ ) ?: throw IOException()
+
+ // In case a directory with that name already exists, a number is appended. The method is private and only used
+ // from findOrCreateDirectory(), for now this should be safe.
+ return DirectoryInfo(directoryUri, name)
+ }
+
+ override fun listFiles(directoryInfo: DirectoryInfo): List {
+ val uri = directoryInfo.uri as Uri
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri))
+
+ context.contentResolver.query(
+ childrenUri,
+ arrayOf(
+ COLUMN_DOCUMENT_ID,
+ COLUMN_DISPLAY_NAME,
+ COLUMN_SIZE,
+ COLUMN_MIME_TYPE
+ ),
+ ) { cursor ->
+ if (cursor == null) return emptyList()
+
+ // cursor also contains directories, however overhead should be small enough to use it for initialization.
+ return ArrayList(cursor.count).apply {
+ while (cursor.moveToNext()) {
+ val documentType = cursor.getString(COLUMN_MIME_TYPE)
+
+ if (documentType != MIME_TYPE_DIR) {
+ add(
+ FileInfo(
+ uri = DocumentsContract.buildDocumentUriUsingTree(
+ uri,
+ cursor.getString(COLUMN_DOCUMENT_ID)
+ ),
+ fullName = cursor.getString(COLUMN_DISPLAY_NAME),
+ size = cursor.getLong(COLUMN_SIZE),
+ mimeType = documentType
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun listDirectories(directoryInfo: DirectoryInfo): List {
+ val uri = directoryInfo.uri as Uri
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri))
+
+ context.contentResolver.query(
+ childrenUri,
+ arrayOf(
+ COLUMN_DOCUMENT_ID,
+ COLUMN_DISPLAY_NAME,
+ COLUMN_MIME_TYPE
+ )
+ ) { cursor ->
+ // Not initializing the ArrayList with the cursor count, because we expect the number of directories to be
+ // much smaller than the number of files.
+ return ArrayList().apply {
+ while (cursor?.moveToNext() == true) {
+ if (cursor.getString(COLUMN_MIME_TYPE) == MIME_TYPE_DIR) {
+ add(
+ DirectoryInfo(
+ uri = DocumentsContract.buildDocumentUriUsingTree(
+ uri,
+ cursor.getString(COLUMN_DOCUMENT_ID)
+ ),
+ name = cursor.getString(COLUMN_DISPLAY_NAME)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Throws(Exception::class)
+ override fun delete(fileInfo: FileInfo) {
+ val uri = fileInfo.uri as Uri
+ deleteDocument(uri)
+ }
+
+ @Throws(Exception::class)
+ override fun delete(directoryInfo: DirectoryInfo) {
+ val uri = directoryInfo.uri as Uri
+ deleteDocument(uri)
+ }
+
+ @Throws(Exception::class)
+ private fun deleteDocument(uri: Uri) {
+ try {
+ DocumentsContract.deleteDocument(context.contentResolver, uri)
+ } catch (e: IllegalArgumentException) {
+ // For some reason, the FileNotFound exception is wrapped in an IllegalArgumentException.
+ if (e.message?.contains("java.io.FileNotFoundException") == true) return
+ throw e
+ }
+ }
+
+ override fun relocate(
+ sourceFileInfo: FileInfo,
+ sourceParentDirectoryInfo: DirectoryInfo,
+ targetDirectoryInfo: DirectoryInfo
+ ) {
+ DocumentsContract.moveDocument(
+ context.contentResolver,
+ sourceFileInfo.uri as Uri,
+ sourceParentDirectoryInfo.uri as Uri,
+ targetDirectoryInfo.uri as Uri
+ )
+ }
+
+ @Throws(FileNotFoundException::class)
+ override fun openInputStream(fileInfo: FileInfo): InputStream {
+ val uri = fileInfo.uri as Uri
+ return context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException()
+ }
+
+ @Throws(FileNotFoundException::class)
+ override fun openOutputStream(fileInfo: FileInfo): OutputStream {
+ val uri = fileInfo.uri as Uri
+ return context.contentResolver.openOutputStream(uri, "rwt") ?: throw FileNotFoundException()
+ }
+
+ //region Extension methods
+
+ /**
+ * A simple wrapper around [ContentResolver.query] that automatically closes the cursor. Note that `selection` is
+ * not available because [Android's FileSystemProvider does not support it](https://stackoverflow.com/a/61214849).
+ */
+ inline fun ContentResolver.query(uri: Uri, projection: Array, block: (Cursor?) -> R): R {
+ return query(uri, projection, null, null, null).use(block)
+ }
+
+ @Throws(IllegalArgumentException::class)
+ fun Cursor.getString(columnName: String): String {
+ return getString(getColumnIndexOrThrow(columnName))
+ }
+
+ @Throws(IllegalArgumentException::class)
+ fun Cursor.getLong(columnName: String): Long {
+ return getLong(getColumnIndexOrThrow(columnName))
+ }
+
+ //endregion
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/data/preferences/PersistentPreferences.kt b/android/src/main/java/de/lukaspieper/truvark/data/preferences/PersistentPreferences.kt
new file mode 100644
index 0000000..203dfe8
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/data/preferences/PersistentPreferences.kt
@@ -0,0 +1,96 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.data.preferences
+
+import android.content.Context
+import android.net.Uri
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import de.lukaspieper.truvark.domain.crypto.BiometricConfig
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore by preferencesDataStore(name = "AppPreferences")
+
+/**
+ * Persistent preferences based on [DataStore].
+ */
+class PersistentPreferences(context: Context) {
+ private val dataStore: DataStore = context.dataStore
+
+ companion object {
+ val LAST_USED_VAULT_ROOT_URI = stringPreferencesKey("PREF_VAULT_ROOT_URI")
+ val BIOMETRIC_CONFIG = stringPreferencesKey("PREF_BIOMETRY_CONFIG")
+ val LOGGING_ALLOWED = booleanPreferencesKey("PREF_LOGGING_ALLOWED")
+ val IS_LIST_LAYOUT = booleanPreferencesKey("PREF_IS_LIST_LAYOUT")
+ val IMAGES_FIT_SCREEN = booleanPreferencesKey("PREF_IMAGES_FIT_SCREEN")
+ }
+
+ suspend fun saveLastUsedVaultRootUri(uri: Uri) {
+ dataStore.edit { preferences ->
+ preferences[LAST_USED_VAULT_ROOT_URI] = uri.toString()
+ }
+ }
+
+ val lastUsedVaultRootUri: Flow = dataStore.data.map { preferences ->
+ val lastUsedVaultRootUri = preferences[LAST_USED_VAULT_ROOT_URI]
+
+ when {
+ lastUsedVaultRootUri.isNullOrBlank() -> Uri.EMPTY
+ else -> Uri.parse(lastUsedVaultRootUri)
+ }
+ }
+
+ suspend fun saveBiometricConfig(config: BiometricConfig) {
+ dataStore.edit { preferences ->
+ preferences[BIOMETRIC_CONFIG] = config.toJson()
+ }
+ }
+
+ val biometricConfig: Flow = dataStore.data.map { preferences ->
+ val json = preferences[BIOMETRIC_CONFIG]
+
+ when {
+ json.isNullOrBlank() -> null
+ else -> BiometricConfig.fromJson(json)
+ }
+ }
+
+ suspend fun saveLoggingAllowed(allowed: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[LOGGING_ALLOWED] = allowed
+ }
+ }
+
+ val loggingAllowed: Flow = dataStore.data.map { preferences ->
+ preferences[LOGGING_ALLOWED] ?: false
+ }
+
+ suspend fun saveIsListLayout(isListLayout: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[IS_LIST_LAYOUT] = isListLayout
+ }
+ }
+
+ val isListLayout: Flow = dataStore.data.map { preferences ->
+ preferences[IS_LIST_LAYOUT] ?: false
+ }
+
+ suspend fun saveImagesFitScreen(fitScreen: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[IMAGES_FIT_SCREEN] = fitScreen
+ }
+ }
+
+ val imagesFitScreen: Flow = dataStore.data.map { preferences ->
+ preferences[IMAGES_FIT_SCREEN] ?: true
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/di/ApplicationModule.kt b/android/src/main/java/de/lukaspieper/truvark/di/ApplicationModule.kt
new file mode 100644
index 0000000..0937821
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/di/ApplicationModule.kt
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import de.lukaspieper.truvark.common.crypto.Argon2
+import de.lukaspieper.truvark.common.data.io.FileSystem
+import de.lukaspieper.truvark.common.domain.IdGenerator
+import de.lukaspieper.truvark.common.domain.ThumbnailProvider
+import de.lukaspieper.truvark.common.domain.vault.VaultFactory
+import de.lukaspieper.truvark.common.work.Scheduler
+import de.lukaspieper.truvark.data.database.DatabaseFileSynchronization
+import de.lukaspieper.truvark.data.io.AndroidFileSystem
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.domain.AndroidThumbnailProvider
+import de.lukaspieper.truvark.domain.crypto.AndroidArgon2
+import de.lukaspieper.truvark.domain.crypto.BiometricCryptoProvider
+import de.lukaspieper.truvark.work.WorkScheduler
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ApplicationModule {
+
+ @Reusable
+ @Provides
+ fun provideAndroidFileSystem(@ApplicationContext appContext: Context): AndroidFileSystem {
+ return AndroidFileSystem(appContext)
+ }
+
+ @Provides
+ fun provideFileSystem(fileSystem: AndroidFileSystem): FileSystem {
+ return fileSystem
+ }
+
+ @Singleton
+ @Provides
+ fun providePersistentPreferences(@ApplicationContext appContext: Context): PersistentPreferences {
+ return PersistentPreferences(appContext)
+ }
+
+ @Singleton
+ @Provides
+ fun provideWorkScheduler(@ApplicationContext appContext: Context): WorkScheduler {
+ return WorkScheduler(appContext)
+ }
+
+ @Provides
+ fun provideScheduler(workScheduler: WorkScheduler): Scheduler {
+ return workScheduler
+ }
+
+ @Singleton
+ @Provides
+ fun provideThumbnailProvider(@ApplicationContext appContext: Context): ThumbnailProvider {
+ return AndroidThumbnailProvider(appContext)
+ }
+
+ @Reusable
+ @Provides
+ fun provideBiometricCryptoProvider(@ApplicationContext appContext: Context): BiometricCryptoProvider {
+ return BiometricCryptoProvider(appContext)
+ }
+
+ @Provides
+ fun provideIdGenerator(): IdGenerator {
+ return IdGenerator.Default
+ }
+
+ @Reusable
+ @Provides
+ fun provideArgon2(): Argon2 {
+ return AndroidArgon2()
+ }
+
+ @Provides
+ fun provideVaultFactory(
+ argon2: Argon2,
+ fileSystem: FileSystem,
+ idGenerator: IdGenerator,
+ thumbnailProvider: ThumbnailProvider,
+ scheduler: Scheduler
+ ): VaultFactory {
+ return VaultFactory(
+ argon2 = argon2,
+ fileSystem = fileSystem,
+ idGenerator = idGenerator,
+ thumbnailProvider = thumbnailProvider,
+ scheduler = scheduler
+ )
+ }
+
+ @Provides
+ fun provideDatabaseFileSynchronization(
+ workScheduler: WorkScheduler,
+ fileSystem: FileSystem
+ ): DatabaseFileSynchronization {
+ return DatabaseFileSynchronization(workScheduler, fileSystem)
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/di/VaultModule.kt b/android/src/main/java/de/lukaspieper/truvark/di/VaultModule.kt
new file mode 100644
index 0000000..c90661b
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/di/VaultModule.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.di
+
+import android.content.Context
+import android.content.Intent
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import de.lukaspieper.truvark.common.domain.vault.Vault
+
+// TODO: Get rid of this module
+@Module
+@InstallIn(SingletonComponent::class)
+object VaultModule {
+ private var vault: Vault? = null
+
+ fun initializeVaultModule(vault: Vault) {
+ this.vault = vault
+ }
+
+ @Provides
+ fun provideVault(@ApplicationContext appContext: Context): Vault {
+ if (vault == null) {
+ // I can't reproduce this case, and it should never happen, but crashes have been reported by Google Play
+ // on devices known to kill apps in the background (https://dontkillmyapp.com/). Attempt app restart.
+ appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)?.let { intent ->
+ val mainIntent = Intent.makeRestartActivityTask(intent.component)
+ appContext.startActivity(mainIntent)
+ Runtime.getRuntime().exit(0)
+ }
+ }
+
+ return vault ?: error("Vault not initialized")
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/AndroidThumbnailProvider.kt b/android/src/main/java/de/lukaspieper/truvark/domain/AndroidThumbnailProvider.kt
new file mode 100644
index 0000000..a8ce0e5
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/AndroidThumbnailProvider.kt
@@ -0,0 +1,75 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import coil.ImageLoader
+import coil.decode.SvgDecoder
+import coil.decode.VideoFrameDecoder
+import coil.request.CachePolicy
+import coil.request.ErrorResult
+import coil.request.ImageRequest
+import coil.request.SuccessResult
+import coil.request.videoFramePercent
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.domain.ThumbnailProvider
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import java.io.ByteArrayOutputStream
+
+class AndroidThumbnailProvider(private val context: Context) : ThumbnailProvider {
+
+ companion object {
+ const val THUMBNAIL_QUALITY = 50
+ const val THUMBNAIL_SIZE = 280
+ }
+
+ private val imageLoader = ImageLoader.Builder(context)
+ .components {
+ add(SvgDecoder.Factory())
+ add(VideoFrameDecoder.Factory())
+ }
+ .diskCachePolicy(CachePolicy.DISABLED)
+ .memoryCachePolicy(CachePolicy.DISABLED)
+ .build()
+
+ override suspend fun createThumbnail(file: FileInfo): ByteArray? {
+ try {
+ val thumbnail = createThumbnailFromFile(file)
+ return compressBitmapToByteArray(thumbnail)
+ } catch (e: Exception) {
+ logcat(LogPriority.WARN) { e.asLog() }
+ }
+
+ return null
+ }
+
+ private suspend fun createThumbnailFromFile(file: FileInfo): Bitmap {
+ val request = ImageRequest.Builder(context)
+ .data(file.uri)
+ .allowConversionToBitmap(true)
+ .size(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
+ .videoFramePercent(0.07)
+ .build()
+
+ val result = imageLoader.execute(request)
+ when (result) {
+ is SuccessResult -> return (result.drawable as BitmapDrawable).bitmap
+ is ErrorResult -> throw result.throwable
+ }
+ }
+
+ private fun compressBitmapToByteArray(thumbnail: Bitmap): ByteArray {
+ ByteArrayOutputStream().use { byteArrayBitmapStream ->
+ thumbnail.compress(Bitmap.CompressFormat.WEBP, THUMBNAIL_QUALITY, byteArrayBitmapStream)
+ return byteArrayBitmapStream.toByteArray()
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/AndroidArgon2.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/AndroidArgon2.kt
new file mode 100644
index 0000000..31b724d
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/AndroidArgon2.kt
@@ -0,0 +1,50 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto
+
+import com.lambdapioneer.argon2kt.Argon2Kt
+import com.lambdapioneer.argon2kt.Argon2Mode
+import com.lambdapioneer.argon2kt.Argon2Version
+import de.lukaspieper.truvark.common.crypto.Argon2
+
+class AndroidArgon2 : Argon2() {
+ private val argon2Kt = Argon2Kt()
+
+ @Throws(IllegalArgumentException::class)
+ override fun hashPassword(password: ByteArray, salt: ByteArray, config: Config): Hash {
+ val hashResult = argon2Kt.hash(
+ mode = when (config.type) {
+ "argon2id" -> Argon2Mode.ARGON2_ID
+ "argon2i" -> Argon2Mode.ARGON2_I
+ "argon2d" -> Argon2Mode.ARGON2_D
+ else -> throw IllegalArgumentException("Unknown Argon2 type: ${config.type}")
+ },
+ password = password,
+ salt = salt,
+ tCostInIterations = config.iterations,
+ mCostInKibibyte = config.memoryCostInKibibyte,
+ parallelism = config.parallelism,
+ hashLengthInBytes = HASH_SIZE,
+ version = when (config.version) {
+ 0x10 -> Argon2Version.V10
+ 0x13 -> Argon2Version.V13
+ else -> throw IllegalArgumentException("Unknown Argon2 version: ${config.version}")
+ }
+ )
+
+ return object : Hash {
+ override fun toRaw(): ByteArray {
+ return hashResult.rawHashAsByteArray()
+ }
+
+ override fun toEncodedConfigAndSalt(): String {
+ return hashResult.encodedOutputAsString()
+ .substring(0, hashResult.encodedOutputAsString().lastIndexOf('$'))
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricConfig.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricConfig.kt
new file mode 100644
index 0000000..d9985d5
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricConfig.kt
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class BiometricConfig(
+ @SerialName("VaultId")
+ val vaultId: String,
+
+ @SerialName("IV")
+ val iv: ByteArray,
+
+ @SerialName("AccessKey")
+ val accessKey: ByteArray
+) {
+ companion object {
+
+ fun fromJson(json: String): BiometricConfig {
+ return Json.decodeFromString(serializer(), json)
+ }
+ }
+
+ fun toJson(): String {
+ return Json.encodeToString(serializer(), this)
+ }
+
+ // generated
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as BiometricConfig
+
+ if (vaultId != other.vaultId) return false
+ if (!iv.contentEquals(other.iv)) return false
+ if (!accessKey.contentEquals(other.accessKey)) return false
+
+ return true
+ }
+
+ // generated
+ override fun hashCode(): Int {
+ var result = vaultId.hashCode()
+ result = 31 * result + iv.contentHashCode()
+ result = 31 * result + accessKey.contentHashCode()
+ return result
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricCryptoProvider.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricCryptoProvider.kt
new file mode 100644
index 0000000..be9529c
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/BiometricCryptoProvider.kt
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties.*
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Provider for secure encrypting and decrypting [BiometricPrompt.CryptoObject] featuring AES GCM and user
+ * authentication backed by the Android [KeyStore].
+ */
+class BiometricCryptoProvider(context: Context) {
+
+ companion object {
+ private const val BIOMETRIC_KEY_ALIAS = "BIOMETRIC_ACCESS_KEY"
+
+ private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+ private const val TRANSFORMATION = "$KEY_ALGORITHM_AES/$BLOCK_MODE_GCM/$ENCRYPTION_PADDING_NONE"
+ private const val TAG_LENGTH = 128
+ }
+
+ private val biometricManager by lazy { BiometricManager.from(context) }
+ private val keyStore by lazy { KeyStore.getInstance(ANDROID_KEY_STORE).also { it.load(null) } }
+
+ /**
+ * Checks if the device supports biometric authentication. Returns an *AuthenticationStatus* as defined in
+ * [BiometricManager].
+ */
+ fun checkBiometricSupport(): Int {
+ return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+ }
+
+ @Throws(Exception::class)
+ fun createEncryptingPromptObject(): BiometricPrompt.CryptoObject {
+ // If the user changes the biometric settings in the Android settings (lock screen), the key becomes invalid
+ // but is not removed, therefore the key is always overwritten during biometric setup.
+ if (keyStore.containsAlias(BIOMETRIC_KEY_ALIAS)) {
+ keyStore.deleteEntry(BIOMETRIC_KEY_ALIAS)
+ }
+
+ with(KeyGenerator.getInstance(KEY_ALGORITHM_AES, ANDROID_KEY_STORE)) {
+ val keyGenParameterSpec = KeyGenParameterSpec
+ .Builder(BIOMETRIC_KEY_ALIAS, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
+ .setBlockModes(BLOCK_MODE_GCM)
+ .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
+ .setUserAuthenticationRequired(true)
+ .build()
+
+ init(keyGenParameterSpec)
+ generateKey()
+ }
+
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, keyStore.getKey(BIOMETRIC_KEY_ALIAS, null))
+ return BiometricPrompt.CryptoObject(cipher)
+ }
+
+ @Throws(Exception::class)
+ fun createDecryptingPromptObject(iv: ByteArray): BiometricPrompt.CryptoObject {
+ check(keyStore.containsAlias(BIOMETRIC_KEY_ALIAS)) { "KeyStore does not contain required key." }
+
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ keyStore.getKey(BIOMETRIC_KEY_ALIAS, null),
+ GCMParameterSpec(TAG_LENGTH, iv)
+ )
+ return BiometricPrompt.CryptoObject(cipher)
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/DecryptingFileHandle.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/DecryptingFileHandle.kt
new file mode 100644
index 0000000..e6b7a0a
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/DecryptingFileHandle.kt
@@ -0,0 +1,101 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto.decryption
+
+import android.content.ContentResolver
+import android.net.Uri
+import de.lukaspieper.truvark.common.constants.FixedValues
+import de.lukaspieper.truvark.common.domain.entities.OriginalFileMetadata
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import okio.FileHandle
+import okio.ForwardingSource
+import okio.Source
+import java.nio.ByteBuffer
+import java.nio.channels.SeekableByteChannel
+
+/**
+ * A [FileHandle] that decrypts the content of a file. The header is read and decrypted during initialization and
+ * it's content is available via [header].
+ *
+ * To support random file access, this class utilizes [SeekableByteChannel] that requires Android SDK 24 (N).
+ */
+class DecryptingFileHandle(
+ contentResolver: ContentResolver,
+ vault: Vault,
+ uri: Uri
+) : FileHandle(false) {
+
+ private var assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r")
+ private var fileInputStream = assetFileDescriptor!!.createInputStream()
+ private var decryptingByteChannel = vault.newSeekableDecryptingChannel(fileInputStream.channel)
+
+ val header: OriginalFileMetadata
+
+ init {
+ val headerByteArray = ByteArray(FixedValues.ENCRYPTED_FILE_HEADER_SIZE)
+ val headerBuffer = ByteBuffer.wrap(headerByteArray)
+ val readBytes = decryptingByteChannel.read(headerBuffer)
+
+ check(readBytes == FixedValues.ENCRYPTED_FILE_HEADER_SIZE) {
+ "Could not read header of encrypted file"
+ }
+
+ header = OriginalFileMetadata.fromJsonOrNull(String(headerByteArray))!!
+ }
+
+ override fun protectedRead(fileOffset: Long, array: ByteArray, arrayOffset: Int, byteCount: Int): Int {
+ if (byteCount == 0) return 0
+ if (fileOffset >= protectedSize()) return -1
+
+ synchronized(decryptingByteChannel) {
+ val sizeToRead = minOf(byteCount, (size() - fileOffset).toInt())
+ decryptingByteChannel.position(fileOffset + FixedValues.ENCRYPTED_FILE_HEADER_SIZE)
+
+ val destination = ByteBuffer.wrap(array, arrayOffset, sizeToRead)
+ return decryptingByteChannel.read(destination)
+ }
+ }
+
+ override fun protectedSize(): Long {
+ return header.fileSize
+ }
+
+ override fun protectedWrite(fileOffset: Long, array: ByteArray, arrayOffset: Int, byteCount: Int) {
+ throw NotImplementedError("This file handle is read-only.")
+ }
+
+ override fun protectedFlush() {
+ throw NotImplementedError("This file handle is read-only.")
+ }
+
+ override fun protectedResize(size: Long) {
+ throw NotImplementedError("This file handle is read-only.")
+ }
+
+ override fun protectedClose() {
+ decryptingByteChannel.close()
+ fileInputStream.close()
+ assetFileDescriptor?.close()
+ }
+
+ /**
+ * In some cases it's not possible to close the [FileHandle] and the [FileHandle.FileHandleSource] when using
+ * [source]. This method returns a [Source] that closes the [FileHandle] when it's closed.
+ */
+ fun singleSource(): Source {
+ // There does not seem to be a way to check thru the base class if the file handle is closed.
+ checkNotNull(assetFileDescriptor) { "closed" }
+ return ClosingFileHandleSource(this)
+ }
+
+ private class ClosingFileHandleSource(private val fileHandle: FileHandle) : ForwardingSource(fileHandle.source()) {
+ override fun close() {
+ super.close()
+ fileHandle.close()
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/FileHandleMediaDataSource.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/FileHandleMediaDataSource.kt
new file mode 100644
index 0000000..7d0d378
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/FileHandleMediaDataSource.kt
@@ -0,0 +1,27 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto.decryption
+
+import android.media.MediaDataSource
+import okio.FileHandle
+
+class FileHandleMediaDataSource(
+ private val fileHandle: FileHandle
+) : MediaDataSource() {
+
+ override fun getSize(): Long {
+ return fileHandle.size()
+ }
+
+ override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
+ return fileHandle.read(position, buffer, offset, size)
+ }
+
+ override fun close() {
+ fileHandle.close()
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherFileFetcher.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherFileFetcher.kt
new file mode 100644
index 0000000..3ea1c12
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherFileFetcher.kt
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto.decryption.coil
+
+import android.content.Context
+import android.net.Uri
+import coil.ImageLoader
+import coil.decode.DataSource
+import coil.decode.ImageSource
+import coil.fetch.FetchResult
+import coil.fetch.Fetcher
+import coil.fetch.SourceResult
+import coil.request.Options
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.domain.crypto.decryption.DecryptingFileHandle
+import okio.buffer
+
+class CipherFileFetcher(
+ private val fileInfo: FileInfo,
+ private val context: Context,
+ private val vault: Vault
+) : Fetcher {
+
+ override suspend fun fetch(): FetchResult {
+ // TODO: Make this FileSystem-agnostic, e.g. by splitting file access and decryption and adding a method
+ // returning a FileHandle
+ require(fileInfo.uri is Uri)
+
+ val decryptingFileHandle = DecryptingFileHandle(context.contentResolver, vault, fileInfo.uri as Uri)
+ val imageSource = ImageSource(decryptingFileHandle.singleSource().buffer(), context)
+
+ return SourceResult(
+ source = imageSource,
+ mimeType = decryptingFileHandle.header.mimeType,
+ dataSource = DataSource.DISK
+ )
+ }
+
+ class Factory(
+ private val context: Context,
+ private val vault: Vault
+ ) : Fetcher.Factory {
+
+ override fun create(data: FileInfo, options: Options, imageLoader: ImageLoader): Fetcher {
+ return CipherFileFetcher(data, context, vault)
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherZoomableImageSource.kt b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherZoomableImageSource.kt
new file mode 100644
index 0000000..28234c3
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/domain/crypto/decryption/coil/CipherZoomableImageSource.kt
@@ -0,0 +1,166 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.domain.crypto.decryption.coil
+
+import android.content.ContentResolver
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.RememberObserver
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.request.ImageResult
+import coil.request.SuccessResult
+import coil.size.Dimension
+import com.google.accompanist.drawablepainter.DrawablePainter
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.domain.crypto.decryption.DecryptingFileHandle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import me.saket.telephoto.subsamplingimage.ImageBitmapOptions
+import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
+import me.saket.telephoto.zoomable.ZoomableImageSource
+import me.saket.telephoto.zoomable.ZoomableImageSource.ResolveResult
+import kotlin.math.roundToInt
+import kotlin.time.Duration
+import coil.size.Size as CoilSize
+
+/**
+ * A [ZoomableImageSource] for Telephoto to load encrypted images with support for subsampling while keeping fallback to
+ * Coil for other image types like GIFs, SVGs, etc.
+ */
+internal class CipherZoomableImageSource(
+ private val model: FileInfo,
+ private val mimeType: String,
+ private val vault: Vault,
+ private val contentResolver: ContentResolver,
+ private val imageLoader: ImageLoader,
+) : ZoomableImageSource {
+
+ @Composable
+ override fun resolve(canvasSize: Flow): ResolveResult {
+ val context = LocalContext.current
+ val resolver = remember(this) {
+ Resolver(
+ request = ImageRequest.Builder(context)
+ .data(model)
+ .size { canvasSize.first().toCoilSize() }
+ .build(),
+ mimeType = mimeType,
+ imageLoader = imageLoader,
+ vault = vault,
+ contentResolver = contentResolver
+ )
+ }
+ return resolver.resolved
+ }
+
+ private fun Size.toCoilSize() = CoilSize(
+ width = if (width.isFinite()) Dimension(width.roundToInt()) else Dimension.Undefined,
+ height = if (height.isFinite()) Dimension(height.roundToInt()) else Dimension.Undefined
+ )
+}
+
+private class Resolver(
+ private val request: ImageRequest,
+ private val mimeType: String,
+ private val imageLoader: ImageLoader,
+ private val vault: Vault,
+ private val contentResolver: ContentResolver,
+) : RememberObserver {
+ private var scope: CoroutineScope? = null
+ private val subSamplingMimeTypes = listOf(
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/heif",
+ "image/heic",
+ )
+
+ var resolved: ResolveResult by mutableStateOf(
+ ResolveResult(delegate = null)
+ )
+
+ private suspend fun work() {
+ val result = imageLoader.execute(request)
+ val imageSource = result.toSubSamplingImageSource()
+
+ resolved = resolved.copy(
+ delegate = if (result is SuccessResult && imageSource != null) {
+ ZoomableImageSource.SubSamplingDelegate(
+ source = imageSource,
+ imageOptions = ImageBitmapOptions(from = (result.drawable as BitmapDrawable).bitmap)
+ )
+ } else {
+ ZoomableImageSource.PainterDelegate(
+ painter = result.drawable?.asPainter()
+ )
+ }
+ )
+ }
+
+ private fun ImageResult.toSubSamplingImageSource(): SubSamplingImageSource? {
+ if (this !is SuccessResult) return null
+
+ // TODO: Make this FileSystem-agnostic, e.g. by splitting file access and decryption and adding a method
+ // returning a FileHandle
+ (request.data as? FileInfo)?.let { fileInfo ->
+ if (fileInfo.uri is Uri && subSamplingMimeTypes.contains(mimeType)) {
+ return SubSamplingImageSource.rawSource(
+ source = { DecryptingFileHandle(contentResolver, vault, fileInfo.uri as Uri).singleSource() },
+ )
+ }
+ }
+
+ return null
+ }
+
+ private fun Drawable.asPainter(): Painter {
+ return DrawablePainter(mutate())
+ }
+
+ //region RememberWorker and extension
+
+ override fun onRemembered() {
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ scope!!.launch { work() }
+ }
+
+ override fun onAbandoned() {
+ scope?.cancel()
+ }
+
+ override fun onForgotten() {
+ scope?.cancel()
+ }
+
+ private fun ResolveResult.copy(
+ delegate: ZoomableImageSource.ImageDelegate? = this.delegate,
+ crossfadeDuration: Duration = this.crossfadeDuration,
+ placeholder: Painter? = this.placeholder,
+ ) = ResolveResult(
+ delegate = delegate,
+ crossfadeDuration = crossfadeDuration,
+ placeholder = placeholder,
+ )
+
+ //endregion
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/logging/AndroidLogcatLogger.kt b/android/src/main/java/de/lukaspieper/truvark/logging/AndroidLogcatLogger.kt
new file mode 100644
index 0000000..de8efe7
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/logging/AndroidLogcatLogger.kt
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: 2021 Square Inc.
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// https://github.com/square/logcat/blob/main/logcat/src/main/java/logcat/AndroidLogcatLogger.kt
+
+package de.lukaspieper.truvark.logging
+
+import android.util.Log
+import de.lukaspieper.truvark.BuildConfig
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.LogPriority.*
+import de.lukaspieper.truvark.common.logging.LogcatLogger
+import de.lukaspieper.truvark.logging.AndroidLogcatLogger.Companion.installWithDefaultPriority
+import kotlin.math.min
+
+private const val MAX_LOG_LENGTH = 4000
+
+/**
+ * A [logcat] logger that delegates to [android.util.Log] for any log with a priority of
+ * at least [minPriorityInt], and is otherwise a no-op.
+ *
+ * Handles special cases for [LogPriority.ASSERT] (which requires sending to Log.wtf) and
+ * splitting logs to be at most 4000 characters per line (otherwise logcat just truncates).
+ *
+ * Call [installWithDefaultPriority] to make sure you never log in release builds.
+ *
+ * The implementation is based on Timber DebugTree.
+ */
+class AndroidLogcatLogger private constructor(minPriority: LogPriority) : LogcatLogger {
+
+ private val minPriorityInt: Int = minPriority.priorityInt
+
+ override fun isLoggable(priority: LogPriority): Boolean =
+ priority.priorityInt >= minPriorityInt
+
+ override fun log(
+ priority: LogPriority,
+ tag: String,
+ message: String
+ ) {
+ if (message.length < MAX_LOG_LENGTH) {
+ logToLogcat(priority.priorityInt, tag, message)
+ return
+ }
+
+ // Split by line, then ensure each line can fit into Log's maximum length.
+ var i = 0
+ val length = message.length
+ while (i < length) {
+ var newline = message.indexOf('\n', i)
+ newline = if (newline != -1) newline else length
+ do {
+ val end = min(newline, i + MAX_LOG_LENGTH)
+ val part = message.substring(i, end)
+ logToLogcat(priority.priorityInt, tag, part)
+ i = end
+ } while (i < newline)
+ i++
+ }
+ }
+
+ private fun logToLogcat(
+ priority: Int,
+ tag: String,
+ part: String
+ ) {
+ if (priority == Log.ASSERT) {
+ Log.wtf(tag, part)
+ } else {
+ Log.println(priority, tag, part)
+ }
+ }
+
+ companion object {
+ fun installWithDefaultPriority() {
+ if (LogcatLogger.isInstalled) {
+ LogcatLogger.uninstall()
+ }
+
+ val priority = if (BuildConfig.DEBUG) VERBOSE else INFO
+ LogcatLogger.install(AndroidLogcatLogger(priority))
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/LabeledSwitch.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/LabeledSwitch.kt
new file mode 100644
index 0000000..bafa6aa
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/LabeledSwitch.kt
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchColors
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LabeledSwitch(
+ text: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ switchColors: SwitchColors = SwitchDefaults.colors()
+) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge
+ )
+
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = switchColors
+ )
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/MaterialDialog.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/MaterialDialog.kt
new file mode 100644
index 0000000..47a0a96
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/MaterialDialog.kt
@@ -0,0 +1,129 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.paddings
+
+@Composable
+fun MaterialDialog(
+ modifier: Modifier = Modifier,
+ onDismissRequest: () -> Unit = {},
+ @StringRes title: Int? = null,
+ confirmButton: @Composable (() -> Unit)? = null,
+ dismissButton: @Composable (() -> Unit)? = null,
+ isLoadingIndicator: Boolean = false,
+ content: @Composable () -> Unit
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Card(
+ shape = MaterialTheme.shapes.extraLarge,
+ modifier = modifier
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(MaterialTheme.paddings.extraLarge)
+ .verticalScroll(rememberScrollState())
+ ) {
+ if (title != null) {
+ Text(
+ text = stringResource(title),
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(bottom = MaterialTheme.paddings.large)
+ )
+ }
+
+ if (isLoadingIndicator) {
+ CircularProgressIndicator()
+ } else {
+ content()
+ }
+
+ if (confirmButton != null || dismissButton != null) {
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = MaterialTheme.paddings.extraLarge)
+ ) {
+ if (dismissButton != null) {
+ dismissButton()
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (confirmButton != null) {
+ confirmButton()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun CreateNewFolderDialogPreview() = PreviewHost {
+ MaterialDialog(
+ title = R.string.create_new_folder,
+ confirmButton = {
+ Button(onClick = { }) {
+ Text(text = "Create")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { }) {
+ Text(text = "Cancel")
+ }
+ },
+ ) {
+ var text by remember { mutableStateOf("") }
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@PagePreviews
+@Composable
+private fun LoadingIndicatorPreview() = PreviewHost {
+ MaterialDialog(
+ isLoadingIndicator = true,
+ content = { }
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/PasswordField.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/PasswordField.kt
new file mode 100644
index 0000000..40dcc81
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/PasswordField.kt
@@ -0,0 +1,63 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import de.lukaspieper.truvark.R
+
+@Composable
+fun PasswordField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ imeAction: ImeAction = ImeAction.Done,
+ passwordIsIncorrect: Boolean = false,
+ onKeyboardDone: () -> Unit = {},
+ @StringRes label: Int = R.string.enter_password,
+ @StringRes incorrectPasswordText: Int = R.string.incorrect_password
+) {
+ var passwordHidden by rememberSaveable { mutableStateOf(true) }
+ TextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = { onValueChange(it) },
+ singleLine = true,
+ label = { Text(stringResource(label)) },
+ isError = passwordIsIncorrect,
+ supportingText = { if (passwordIsIncorrect) Text(stringResource(incorrectPasswordText)) },
+ visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
+ keyboardActions = KeyboardActions(onDone = { onKeyboardDone() }),
+ trailingIcon = {
+ IconButton(onClick = { passwordHidden = !passwordHidden }) {
+ val visibilityIcon =
+ if (passwordHidden) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
+ Icon(imageVector = visibilityIcon, contentDescription = null)
+ }
+ }
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/SafeDrawingScaffold.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/SafeDrawingScaffold.kt
new file mode 100644
index 0000000..9fcc336
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/SafeDrawingScaffold.kt
@@ -0,0 +1,205 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+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.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
+import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
+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.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.text.style.TextOverflow
+import de.lukaspieper.truvark.ui.extensions.plus
+import de.lukaspieper.truvark.ui.extensions.safeDrawingEnd
+import de.lukaspieper.truvark.ui.extensions.safeDrawingEndTop
+import de.lukaspieper.truvark.ui.extensions.safeDrawingStart
+import de.lukaspieper.truvark.ui.extensions.safeDrawingStartTop
+import de.lukaspieper.truvark.ui.extensions.safeDrawingTopAppBar
+import de.lukaspieper.truvark.ui.theme.paddings
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SafeDrawingScaffold(
+ largeTopAppBarTitle: String,
+ modifier: Modifier = Modifier,
+ largeTopAppBarActions: @Composable RowScope.() -> Unit = {},
+ largeTopAppBarNavigationIcon: @Composable () -> Unit = {},
+ bottomOverlay: @Composable () -> Unit = {},
+ content: @Composable (PaddingValues) -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+ Scaffold(
+ contentWindowInsets = WindowInsets.safeDrawing,
+ modifier = modifier
+ .fillMaxSize() // https://stackoverflow.com/a/76916130
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ windowInsets = WindowInsets.safeDrawingTopAppBar,
+ title = {
+ Text(
+ text = largeTopAppBarTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ scrollBehavior = scrollBehavior,
+ actions = {
+ Row {
+ largeTopAppBarActions()
+ }
+ },
+ navigationIcon = largeTopAppBarNavigationIcon
+ )
+ },
+ content = {
+ Box {
+ val paddingValues = it + PaddingValues(MaterialTheme.paddings.large)
+ content(paddingValues)
+
+ Box(
+ contentAlignment = Alignment.BottomEnd,
+ modifier = Modifier
+ .matchParentSize()
+ .padding(paddingValues)
+ ) {
+ bottomOverlay()
+ }
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun SafeDrawingListDetailPaneScaffold(
+ scaffoldNavigator: ThreePaneScaffoldNavigator<*>,
+ listPaneTopAppBarTitle: String,
+ listPaneContent: @Composable (PaddingValues) -> Unit,
+ detailPaneTopAppBarTitle: String,
+ detailPaneContent: @Composable (PaddingValues) -> Unit,
+ modifier: Modifier = Modifier,
+ listPaneTopAppBarNavigationIcon: @Composable () -> Unit = {},
+ detailPaneTopAppBarNavigationIcon: @Composable () -> Unit = {},
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val isTwoPane = remember(scaffoldNavigator.scaffoldState.currentState) {
+ with(scaffoldNavigator.scaffoldState.currentState) {
+ primary == PaneAdaptedValue.Expanded && secondary == PaneAdaptedValue.Expanded
+ }
+ }
+
+ BackHandler(scaffoldNavigator.canNavigateBack()) {
+ coroutineScope.launch { scaffoldNavigator.navigateBack() }
+ }
+
+ ListDetailPaneScaffold(
+ modifier = modifier.fillMaxSize(), // https://stackoverflow.com/a/76916130
+ directive = scaffoldNavigator.scaffoldDirective,
+ value = scaffoldNavigator.scaffoldValue,
+ listPane = {
+ AnimatedPane(
+ // Match NavHost transitions
+ enterTransition = fadeIn(animationSpec = tween(700)),
+ exitTransition = fadeOut(animationSpec = tween(700)),
+ ) {
+ Column {
+ TopAppBar(
+ windowInsets = WindowInsets.safeDrawingStartTop,
+ title = {
+ Text(
+ text = listPaneTopAppBarTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = listPaneTopAppBarNavigationIcon
+ )
+
+ val padding = when {
+ isTwoPane -> PaddingValues(
+ start = MaterialTheme.paddings.large + MaterialTheme.paddings.extraSmall,
+ top = MaterialTheme.paddings.large,
+ bottom = MaterialTheme.paddings.large
+ )
+
+ else -> PaddingValues(MaterialTheme.paddings.medium)
+ }
+ listPaneContent(WindowInsets.safeDrawingStart.asPaddingValues() + padding)
+ }
+ }
+ },
+ detailPane = {
+ AnimatedPane(
+ // Match NavHost transitions
+ enterTransition = fadeIn(animationSpec = tween(700)),
+ exitTransition = fadeOut(animationSpec = tween(700)),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = when {
+ isTwoPane -> MaterialTheme.colorScheme.surfaceContainer
+ else -> MaterialTheme.colorScheme.surface
+ }
+ ) {
+ Column {
+ TopAppBar(
+ windowInsets = WindowInsets.safeDrawingEndTop,
+ colors = TopAppBarDefaults.topAppBarColors().copy(containerColor = Color.Transparent),
+ title = {
+ Text(
+ text = detailPaneTopAppBarTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ if (!isTwoPane) {
+ detailPaneTopAppBarNavigationIcon()
+ }
+ }
+ )
+
+ detailPaneContent(
+ WindowInsets.safeDrawingEnd.asPaddingValues() + PaddingValues(MaterialTheme.paddings.large)
+ )
+ }
+ }
+ }
+ }
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaPlayerState.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaPlayerState.kt
new file mode 100644
index 0000000..c5d28c6
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaPlayerState.kt
@@ -0,0 +1,147 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls.mediaplayer
+
+import android.media.MediaDataSource
+import android.media.MediaPlayer
+import android.view.SurfaceHolder
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import de.lukaspieper.truvark.common.logging.LogPriority.DEBUG
+import de.lukaspieper.truvark.common.logging.logcat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlin.math.max
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.minutes
+
+class MediaPlayerState(
+ private val mediaDataSource: MediaDataSource,
+ private val coroutineScope: CoroutineScope,
+ val areControlsVisible: State
+) {
+ private var pollMediaPositionJob: Job? = null
+ private var seekMode = MediaPlayer.SEEK_CLOSEST_SYNC
+
+ private var isPlayerReleased = false
+
+ val player = MediaPlayer().apply {
+ setDataSource(mediaDataSource)
+ isLooping = true
+
+ setOnPreparedListener {
+ logcat(DEBUG) { "MediaPlayer: onPrepared" }
+ setScreenOnWhilePlaying(true)
+ seekTo(0L, MediaPlayer.SEEK_PREVIOUS_SYNC)
+ mediaDuration = duration.milliseconds
+
+ if (mediaDuration < 5.minutes) {
+ seekMode = MediaPlayer.SEEK_CLOSEST
+ }
+
+ pollMediaPositionJob = coroutineScope.launch {
+ snapshotFlow { areControlsVisible.value }.collectLatest { visible ->
+ while (visible) {
+ mediaPosition = currentPosition.milliseconds
+ delay(100)
+ }
+ }
+ }
+ }
+
+ setOnVideoSizeChangedListener { _, width, height ->
+ mediaWith = width
+ mediaHeight = height
+ aspectRatio = width / max(1f, height.toFloat())
+ }
+ }
+
+ var isPlaying by mutableStateOf(false)
+ private set
+
+ var mediaPosition by mutableStateOf(Duration.ZERO)
+ private set
+
+ var mediaDuration by mutableStateOf(Duration.ZERO)
+ private set
+
+ var aspectRatio by mutableFloatStateOf(0f)
+ private set
+
+ var mediaWith by mutableIntStateOf(0)
+ private set
+
+ var mediaHeight by mutableIntStateOf(0)
+ private set
+
+ val surfaceCallback by mutableStateOf(
+ object : SurfaceHolder.Callback {
+
+ override fun surfaceCreated(holder: SurfaceHolder) {
+ logcat(DEBUG) { "MediaPlayer: surfaceCreated" }
+ player.setDisplay(holder)
+ player.prepareAsync()
+ }
+
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+ // Intentionally empty.
+ }
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ logcat(DEBUG) { "MediaPlayer: surfaceDestroyed" }
+ pollMediaPositionJob?.cancel()
+ // TODO: Is there a way to re-initialize the player? E.g. after switching back from another app.
+ player.release()
+ isPlayerReleased = true
+ }
+ }
+ )
+
+ fun play() {
+ if (!isPlayerReleased) {
+ player.start()
+ isPlaying = true
+ }
+ }
+
+ fun pause() {
+ if (!isPlayerReleased) {
+ player.pause()
+ isPlaying = false
+ }
+ }
+
+ fun forward() {
+ if (!isPlayerReleased) {
+ seekTo(player.currentPosition + 10_000F)
+ }
+ }
+
+ fun rewind() {
+ if (!isPlayerReleased) {
+ seekTo(player.currentPosition - 10_000F)
+ }
+ }
+
+ fun seekTo(position: Float) {
+ if (!isPlayerReleased) {
+ position.toLong().let { milliseconds ->
+ mediaPosition = milliseconds.milliseconds
+ player.seekTo(milliseconds, seekMode)
+ }
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaView.kt b/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaView.kt
new file mode 100644
index 0000000..c2deed5
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/controls/mediaplayer/MediaView.kt
@@ -0,0 +1,191 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.controls.mediaplayer
+
+import android.view.SurfaceView
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+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.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Forward10
+import androidx.compose.material.icons.rounded.Pause
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Replay10
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+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.platform.LocalConfiguration
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import de.lukaspieper.truvark.ui.theme.DarkColorScheme
+import de.lukaspieper.truvark.ui.theme.paddings
+import kotlin.time.Duration
+
+@Composable
+fun MediaView(
+ state: MediaPlayerState,
+ modifier: Modifier = Modifier
+) {
+ val configuration = LocalConfiguration.current
+
+ AndroidView(
+ factory = { context ->
+ SurfaceView(context).apply {
+ holder.addCallback(state.surfaceCallback)
+ }
+ },
+ update = { view ->
+ view.holder.setFixedSize(
+ state.mediaWith,
+ state.mediaHeight
+ )
+ },
+ modifier = modifier.then(
+ when (state.aspectRatio) {
+ 0f -> Modifier
+ else -> {
+ val screenAspectRatio = configuration.screenWidthDp.toFloat() / configuration.screenHeightDp
+ Modifier.aspectRatio(
+ ratio = state.aspectRatio,
+ matchHeightConstraintsFirst = state.aspectRatio < screenAspectRatio
+ )
+ }
+ }
+ )
+ )
+
+ AnimatedVisibility(
+ visible = state.areControlsVisible.value,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ MediaViewController(state)
+ }
+}
+
+@Composable
+private fun MediaViewController(state: MediaPlayerState) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(0.30f))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Spacer(Modifier.fillMaxWidth())
+ PlaybackControl(state)
+
+ TimelineControl(
+ modifier = Modifier.padding(bottom = MaterialTheme.paddings.extraLarge),
+ state = state
+ )
+ }
+}
+
+@Composable
+private fun PlaybackControl(state: MediaPlayerState) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ IconButton(
+ modifier = Modifier
+ .size(BigIconButtonSize)
+ .padding(10.dp),
+ onClick = state::rewind
+ ) {
+ Icon(
+ modifier = Modifier.fillMaxSize(),
+ imageVector = Icons.Rounded.Replay10,
+ contentDescription = null,
+ tint = DarkColorScheme.onBackground
+ )
+ }
+ IconButton(
+ modifier = Modifier.size(BigIconButtonSize),
+ onClick = { if (state.isPlaying) state.pause() else state.play() }
+ ) {
+ Icon(
+ modifier = Modifier.fillMaxSize(),
+ imageVector = if (state.isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
+ contentDescription = null,
+ tint = DarkColorScheme.onBackground
+ )
+ }
+ IconButton(
+ modifier = Modifier
+ .size(BigIconButtonSize)
+ .padding(10.dp),
+ onClick = state::forward
+ ) {
+ Icon(
+ modifier = Modifier.fillMaxSize(),
+ imageVector = Icons.Rounded.Forward10,
+ contentDescription = null,
+ tint = DarkColorScheme.onBackground
+ )
+ }
+ }
+}
+
+@Composable
+private fun TimelineControl(
+ state: MediaPlayerState,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = state.mediaPosition.prettyVideoTimestamp(),
+ color = DarkColorScheme.onBackground
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Slider(
+ modifier = Modifier
+ .weight(1F)
+ .height(2.dp)
+ .padding(horizontal = 4.dp),
+ value = state.mediaPosition.inWholeMilliseconds.toFloat(),
+ valueRange = 0f..state.mediaDuration.inWholeMilliseconds.toFloat(),
+ onValueChange = state::seekTo
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = state.mediaDuration.prettyVideoTimestamp(),
+ color = DarkColorScheme.onBackground
+ )
+ }
+ }
+}
+
+private fun Duration.prettyVideoTimestamp(): String {
+ toComponents { minutes, seconds, _ ->
+ return "$minutes:${seconds.toString().padStart(2, '0')}"
+ }
+}
+
+val BigIconButtonSize = 48.dp
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/extensions/PaddingValuesExtensions.kt b/android/src/main/java/de/lukaspieper/truvark/ui/extensions/PaddingValuesExtensions.kt
new file mode 100644
index 0000000..e20395a
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/extensions/PaddingValuesExtensions.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.extensions
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.ui.unit.LayoutDirection
+
+operator fun PaddingValues.plus(other: PaddingValues): PaddingValues = PaddingValues(
+ start = this.calculateStartPadding(LayoutDirection.Ltr) + other.calculateStartPadding(LayoutDirection.Ltr),
+ top = this.calculateTopPadding() + other.calculateTopPadding(),
+ end = this.calculateEndPadding(LayoutDirection.Ltr) + other.calculateEndPadding(LayoutDirection.Ltr),
+ bottom = this.calculateBottomPadding() + other.calculateBottomPadding(),
+)
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/extensions/WindowInsetsExtensions.kt b/android/src/main/java/de/lukaspieper/truvark/ui/extensions/WindowInsetsExtensions.kt
new file mode 100644
index 0000000..1095adb
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/extensions/WindowInsetsExtensions.kt
@@ -0,0 +1,33 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.extensions
+
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.runtime.Composable
+
+val WindowInsets.Companion.safeDrawingTopAppBar: WindowInsets
+ @Composable
+ get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
+
+val WindowInsets.Companion.safeDrawingStart: WindowInsets
+ @Composable
+ get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Start)
+
+val WindowInsets.Companion.safeDrawingStartTop: WindowInsets
+ @Composable
+ get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Start + WindowInsetsSides.Top)
+
+val WindowInsets.Companion.safeDrawingEnd: WindowInsets
+ @Composable
+ get() = WindowInsets.safeDrawing.only(WindowInsetsSides.End)
+
+val WindowInsets.Companion.safeDrawingEndTop: WindowInsets
+ @Composable
+ get() = WindowInsets.safeDrawing.only(WindowInsetsSides.End + WindowInsetsSides.Top)
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/preview/BooleanPreviewParameterProvider.kt b/android/src/main/java/de/lukaspieper/truvark/ui/preview/BooleanPreviewParameterProvider.kt
new file mode 100644
index 0000000..1267ef8
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/preview/BooleanPreviewParameterProvider.kt
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.preview
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class BooleanPreviewParameterProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(false, true)
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewHost.kt b/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewHost.kt
new file mode 100644
index 0000000..32bd185
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewHost.kt
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.preview
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import de.lukaspieper.truvark.ui.controls.SafeDrawingListDetailPaneScaffold
+import de.lukaspieper.truvark.ui.extensions.plus
+import de.lukaspieper.truvark.ui.extensions.safeDrawingEnd
+import de.lukaspieper.truvark.ui.theme.AppTheme
+import de.lukaspieper.truvark.ui.theme.paddings
+
+@Composable
+fun PreviewHost(
+ modifier: Modifier = Modifier.fillMaxSize(),
+ backgroundColor: Color? = null,
+ content: @Composable () -> Unit
+) {
+ AppTheme {
+ Surface(
+ color = backgroundColor ?: MaterialTheme.colorScheme.surface,
+ modifier = modifier
+ ) {
+ content()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun DetailPanePreviewHost(
+ modifier: Modifier = Modifier.fillMaxSize(),
+ backgroundColor: Color? = null,
+ content: @Composable (PaddingValues) -> Unit
+) {
+ PreviewHost(
+ modifier = modifier,
+ backgroundColor = backgroundColor
+ ) {
+ val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator(
+ initialDestinationHistory = listOf(ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail))
+ )
+
+ SafeDrawingListDetailPaneScaffold(
+ scaffoldNavigator = scaffoldNavigator,
+ listPaneTopAppBarTitle = "List Pane",
+ listPaneContent = { },
+ listPaneTopAppBarNavigationIcon = {
+ IconButton(
+ onClick = { },
+ content = { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
+ )
+ },
+ detailPaneTopAppBarTitle = "Detail Pane",
+ detailPaneContent = {
+ content(
+ WindowInsets.safeDrawingEnd.asPaddingValues() + PaddingValues(MaterialTheme.paddings.large)
+ )
+ },
+ detailPaneTopAppBarNavigationIcon = {
+ IconButton(
+ onClick = { },
+ content = { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
+ )
+ },
+ )
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewSampleData.kt b/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewSampleData.kt
new file mode 100644
index 0000000..0c9ceec
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/preview/PreviewSampleData.kt
@@ -0,0 +1,46 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.preview
+
+import de.lukaspieper.truvark.common.domain.entities.CipherFileEntity
+import de.lukaspieper.truvark.common.domain.entities.CipherFolderEntity
+import de.lukaspieper.truvark.ui.views.browser.BrowserViewModel
+
+object PreviewSampleData {
+
+ private val cipherFolderEntities: List = List(5) {
+ object : CipherFolderEntity {
+ override val id: String = "folder$it"
+ override val displayName: String = "Personal Folder $it"
+ }
+ }
+
+ val cipherFileEntities: List = List(30) {
+ object : CipherFileEntity {
+ override val id: String = "file$it"
+ override val thumbnail: ByteArray? = null
+ override val folder: CipherFolderEntity? = null
+ override var name: String = "Top Secret File $it"
+ override var fileExtension: String = "file"
+ override var mimeType: String = "application/octet-stream"
+ override var fileSize: Long = 0L
+ override val mediaDurationSeconds: Long? = if (it % 7 == 0) 62L else null
+ }
+ }
+
+ val folderHierarchyLevel: BrowserViewModel.FolderHierarchyLevel
+ get() = BrowserViewModel.FolderHierarchyLevel(
+ folder = object : CipherFolderEntity {
+ override val id: String = "" // This is the root folder id, usually contains only folders, no files.
+ override val displayName: String = "Vault"
+ },
+ folders = cipherFolderEntities,
+ folderIds = cipherFolderEntities.map { it.id }.toSet(),
+ files = cipherFileEntities,
+ fileIds = cipherFileEntities.map { it.id }.toSet()
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/preview/Previews.kt b/android/src/main/java/de/lukaspieper/truvark/ui/preview/Previews.kt
new file mode 100644
index 0000000..11f4a67
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/preview/Previews.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.preview
+
+import android.content.res.Configuration
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(name = "Phone - Day", device = Devices.PIXEL_6, showSystemUi = true)
+@Preview(name = "Tablet - Day", device = Devices.PIXEL_TABLET, showSystemUi = true)
+@Preview(
+ name = "Phone - Night",
+ device = Devices.PIXEL_6,
+ showSystemUi = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+@Preview(
+ name = "Tablet - Night",
+ device = Devices.PIXEL_TABLET,
+ showSystemUi = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+annotation class PagePreviews
+
+// Same as above, but without `showSystemUi`
+@Preview(name = "Phone - Day", device = Devices.PIXEL_6)
+@Preview(name = "Tablet - Day", device = Devices.PIXEL_TABLET)
+@Preview(
+ name = "Phone - Night",
+ device = Devices.PIXEL_6,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+@Preview(
+ name = "Tablet - Night",
+ device = Devices.PIXEL_TABLET,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+annotation class ElementPreviews
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/theme/Color.kt b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Color.kt
new file mode 100644
index 0000000..f32f18f
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Color.kt
@@ -0,0 +1,73 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val md_theme_light_primary = Color(0xFF006E1C)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFF94F990)
+val md_theme_light_onPrimaryContainer = Color(0xFF002204)
+val md_theme_light_secondary = Color(0xFF52634F)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFD5E8CF)
+val md_theme_light_onSecondaryContainer = Color(0xFF111F0F)
+val md_theme_light_tertiary = Color(0xFF38656A)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFBCEBF0)
+val md_theme_light_onTertiaryContainer = Color(0xFF002023)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFCFDF6)
+val md_theme_light_onBackground = Color(0xFF1A1C19)
+val md_theme_light_surface = Color(0xFFFCFDF6)
+val md_theme_light_onSurface = Color(0xFF1A1C19)
+val md_theme_light_surfaceVariant = Color(0xFFDEE5D8)
+val md_theme_light_onSurfaceVariant = Color(0xFF424940)
+val md_theme_light_outline = Color(0xFF72796F)
+val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB)
+val md_theme_light_inverseSurface = Color(0xFF2F312D)
+val md_theme_light_inversePrimary = Color(0xFF78DC77)
+val md_theme_light_shadow = Color(0xFF000000)
+val md_theme_light_surfaceTint = Color(0xFF006E1C)
+val md_theme_light_outlineVariant = Color(0xFFC2C9BD)
+val md_theme_light_scrim = Color(0xFF000000)
+
+val md_theme_dark_primary = Color(0xFF78DC77)
+val md_theme_dark_onPrimary = Color(0xFF00390A)
+val md_theme_dark_primaryContainer = Color(0xFF005313)
+val md_theme_dark_onPrimaryContainer = Color(0xFF94F990)
+val md_theme_dark_secondary = Color(0xFFBACCB3)
+val md_theme_dark_onSecondary = Color(0xFF253423)
+val md_theme_dark_secondaryContainer = Color(0xFF3B4B38)
+val md_theme_dark_onSecondaryContainer = Color(0xFFD5E8CF)
+val md_theme_dark_tertiary = Color(0xFFA0CFD4)
+val md_theme_dark_onTertiary = Color(0xFF00363B)
+val md_theme_dark_tertiaryContainer = Color(0xFF1F4D52)
+val md_theme_dark_onTertiaryContainer = Color(0xFFBCEBF0)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF1A1C19)
+val md_theme_dark_onBackground = Color(0xFFE2E3DD)
+val md_theme_dark_surface = Color(0xFF1A1C19)
+val md_theme_dark_onSurface = Color(0xFFE2E3DD)
+val md_theme_dark_surfaceVariant = Color(0xFF424940)
+val md_theme_dark_onSurfaceVariant = Color(0xFFC2C9BD)
+val md_theme_dark_outline = Color(0xFF8C9388)
+val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
+val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
+val md_theme_dark_inversePrimary = Color(0xFF006E1C)
+val md_theme_dark_shadow = Color(0xFF000000)
+val md_theme_dark_surfaceTint = Color(0xFF78DC77)
+val md_theme_dark_outlineVariant = Color(0xFF424940)
+val md_theme_dark_scrim = Color(0xFF000000)
+
+val seed = Color(0xFF4CAF50)
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/theme/Paddings.kt b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Paddings.kt
new file mode 100644
index 0000000..6042d26
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Paddings.kt
@@ -0,0 +1,31 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Stable
+class Paddings(
+ val extraSmall: Dp = 4.dp,
+ val small: Dp = 8.dp,
+ val medium: Dp = 12.dp,
+ val large: Dp = 16.dp,
+ val extraLarge: Dp = 24.dp,
+)
+
+private val LocalPaddings = staticCompositionLocalOf { Paddings() }
+
+val MaterialTheme.paddings: Paddings
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalPaddings.current
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/theme/README.md b/android/src/main/java/de/lukaspieper/truvark/ui/theme/README.md
new file mode 100644
index 0000000..fcc737c
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/theme/README.md
@@ -0,0 +1,7 @@
+
+
+Theme is generated by using https://m3.material.io/theme-builder#/custom
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/theme/Theme.kt b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Theme.kt
new file mode 100644
index 0000000..dad19e7
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/theme/Theme.kt
@@ -0,0 +1,111 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val LightColorScheme = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim,
+)
+
+val DarkColorScheme = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim,
+)
+
+@Composable
+fun AppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val colorScheme = when {
+ dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
+ dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/ActivityResultContracts.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/ActivityResultContracts.kt
new file mode 100644
index 0000000..fc9ff29
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/ActivityResultContracts.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.result.contract.ActivityResultContracts
+
+interface ActivityResultContracts {
+
+ class OpenMultipleDocumentsWithFlags : ActivityResultContracts.OpenMultipleDocuments() {
+
+ override fun createIntent(context: Context, input: Array): Intent {
+ val intent = super.createIntent(context, input)
+
+ intent.removeExtra(Intent.EXTRA_MIME_TYPES)
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+ return intent
+ }
+ }
+
+ class OpenDocumentTreeWithFlags : ActivityResultContracts.OpenDocumentTree() {
+
+ override fun createIntent(context: Context, input: Uri?): Intent {
+ val intent = super.createIntent(context, input)
+
+ intent.addFlags(
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ )
+
+ return intent
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserDialogs.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserDialogs.kt
new file mode 100644
index 0000000..7e6d4b9
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserDialogs.kt
@@ -0,0 +1,274 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.browser
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.controls.LabeledSwitch
+import de.lukaspieper.truvark.ui.controls.MaterialDialog
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.views.ActivityResultContracts
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+enum class BrowserDialogs {
+ NONE,
+ NEW_FOLDER,
+ RENAME_FOLDER,
+ ENCRYPT,
+ DELETE_SELECTION
+}
+
+@OptIn(DelicateCoroutinesApi::class)
+@Composable
+fun NewFolderDialog(
+ hideDialog: () -> Unit,
+ createFolder: suspend (String) -> Boolean,
+ modifier: Modifier = Modifier
+) {
+ val focusRequester = remember { FocusRequester() }
+ var folderName by rememberSaveable { mutableStateOf("") }
+ var isInputValid by rememberSaveable { mutableStateOf(true) }
+
+ MaterialDialog(
+ modifier = modifier,
+ onDismissRequest = { hideDialog() },
+ confirmButton = {
+ Button(onClick = {
+ GlobalScope.launch {
+ isInputValid = createFolder(folderName)
+ if (isInputValid) hideDialog()
+ }
+ }) {
+ Text(text = stringResource(R.string.create))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { hideDialog() }) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ title = R.string.create_new_folder
+ ) {
+ OutlinedTextField(
+ value = folderName,
+ onValueChange = { folderName = it },
+ label = { Text(stringResource(R.string.folder_name)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = {
+ GlobalScope.launch {
+ isInputValid = createFolder(folderName)
+ if (isInputValid) hideDialog()
+ }
+ }),
+ isError = isInputValid.not(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun NewFolderDialogPreview() = PreviewHost {
+ NewFolderDialog(
+ hideDialog = {},
+ createFolder = { true }
+ )
+}
+
+@OptIn(DelicateCoroutinesApi::class)
+@Composable
+fun RenameFolderDialog(
+ hideDialog: () -> Unit,
+ folderName: String,
+ renameFolder: suspend (String) -> Boolean,
+ modifier: Modifier = Modifier
+) {
+ val focusRequester = remember { FocusRequester() }
+ var editableFolderName by rememberSaveable(folderName) { mutableStateOf(folderName) }
+ var isInputValid by rememberSaveable { mutableStateOf(true) }
+
+ MaterialDialog(
+ modifier = modifier,
+ onDismissRequest = { hideDialog() },
+ confirmButton = {
+ Button(onClick = {
+ GlobalScope.launch {
+ isInputValid = renameFolder(editableFolderName)
+ if (isInputValid) hideDialog()
+ }
+ }) {
+ Text(text = stringResource(R.string.rename))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { hideDialog() }) {
+ Text(text = stringResource(R.string.cancel))
+ }
+ },
+ title = R.string.rename_folder
+ ) {
+ OutlinedTextField(
+ value = editableFolderName,
+ onValueChange = { editableFolderName = it },
+ label = { Text(stringResource(R.string.folder_name)) },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = {
+ GlobalScope.launch {
+ isInputValid = renameFolder(editableFolderName)
+ if (isInputValid) hideDialog()
+ }
+ }),
+ isError = isInputValid.not(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun RenameFolderDialogPreview() = PreviewHost {
+ RenameFolderDialog(
+ hideDialog = {},
+ folderName = "Preview Folder",
+ renameFolder = { true }
+ )
+}
+
+@Composable
+fun EncryptFilesDialog(
+ hideDialog: () -> Unit,
+ encryptUris: (List, Boolean) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var deleteSourceFiles by rememberSaveable { mutableStateOf(false) }
+ val openDocumentTreeLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.OpenMultipleDocumentsWithFlags(),
+ onResult = { uris ->
+ hideDialog()
+ encryptUris(uris, deleteSourceFiles)
+ }
+ )
+
+ MaterialDialog(
+ modifier = modifier,
+ onDismissRequest = { hideDialog() },
+ title = R.string.encrypt_files,
+ confirmButton = {
+ Button(
+ onClick = { openDocumentTreeLauncher.launch(emptyArray()) },
+ content = { Text(stringResource(R.string.select)) }
+ )
+ },
+ dismissButton = {
+ OutlinedButton(
+ onClick = { hideDialog() },
+ content = { Text(stringResource(R.string.cancel)) }
+ )
+ }
+ ) {
+ LabeledSwitch(
+ text = stringResource(R.string.delete_source_files),
+ checked = deleteSourceFiles,
+ onCheckedChange = { deleteSourceFiles = it },
+ switchColors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.onErrorContainer,
+ checkedTrackColor = MaterialTheme.colorScheme.errorContainer
+ )
+ )
+ }
+}
+
+@PagePreviews
+@Composable
+private fun EncryptFilesDialogPreview() = PreviewHost {
+ EncryptFilesDialog(
+ hideDialog = {},
+ encryptUris = { _, _ -> }
+ )
+}
+
+@Composable
+fun DeleteSelectionDialog(
+ hideDialog: () -> Unit,
+ deleteSelectedCipherEntities: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ MaterialDialog(
+ modifier = modifier,
+ onDismissRequest = { hideDialog() },
+ title = R.string.confirm_deletion,
+ confirmButton = {
+ Button(
+ onClick = {
+ deleteSelectedCipherEntities()
+ hideDialog()
+ },
+ colors = ButtonDefaults.outlinedButtonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ ),
+ content = { Text(stringResource(R.string.delete)) }
+ )
+ },
+ dismissButton = {
+ OutlinedButton(
+ onClick = { hideDialog() },
+ content = { Text(stringResource(R.string.cancel)) }
+ )
+ }
+ ) {
+ Text(stringResource(R.string.delete_warning))
+ }
+}
+
+@PagePreviews
+@Composable
+private fun DeleteSelectionDialogPreview() = PreviewHost {
+ DeleteSelectionDialog(
+ hideDialog = {},
+ deleteSelectedCipherEntities = {}
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserPage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserPage.kt
new file mode 100644
index 0000000..bef645e
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserPage.kt
@@ -0,0 +1,419 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.browser
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.DriveFileMove
+import androidx.compose.material.icons.automirrored.filled.ViewList
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.ViewModule
+import androidx.compose.material.icons.outlined.CreateNewFolder
+import androidx.compose.material.icons.outlined.FileOpen
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.saveable.rememberSaveable
+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.FontStyle
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.lukaspieper.truvark.Page
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.domain.entities.CipherFileEntity
+import de.lukaspieper.truvark.common.domain.entities.CipherFolderEntity
+import de.lukaspieper.truvark.ui.controls.SafeDrawingScaffold
+import de.lukaspieper.truvark.ui.preview.BooleanPreviewParameterProvider
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.preview.PreviewSampleData
+import de.lukaspieper.truvark.ui.theme.paddings
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
+@Composable
+fun BrowserPage(
+ navigate: (Page) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: BrowserViewModel = hiltViewModel()
+) {
+ BackHandler(enabled = viewModel.isRootLevel.not()) {
+ viewModel.navigateToParentFolder()
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.checkForVaultNameUpdates()
+ }
+
+ BrowserView(
+ folderHierarchyLevel = viewModel.currentFolderHierarchyLevel,
+ selectionState = viewModel.selectionState,
+ isRootLevel = viewModel.isRootLevel,
+ isListLayoutState = viewModel.isListLayout,
+ createFolder = viewModel::createCipherFolderEntity,
+ renameFolder = viewModel::renameCipherFolderEntity,
+ encryptUris = viewModel::encryptUris,
+ decryptSelectedCipherEntities = viewModel::decryptSelectedCipherEntities,
+ deleteSelectedCipherEntities = viewModel::deleteSelectedCipherEntities,
+ relocateSelectedCipherEntities = viewModel::relocateSelectedCipherEntities,
+ updateIsListLayout = viewModel::updateIsListLayout,
+ navigateToSettings = { navigate(Page.SettingsHome) },
+ navigateToFilePresenter = { cipherFileEntity ->
+ navigate(
+ Page.Presenter(
+ viewModel.currentFolderHierarchyLevel.folder.id,
+ cipherFileEntity.id
+ )
+ )
+ },
+ navigateToFolder = viewModel::navigateToFolder,
+ navigateToParentFolder = viewModel::navigateToParentFolder,
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun BrowserView(
+ folderHierarchyLevel: BrowserViewModel.FolderHierarchyLevel,
+ selectionState: SelectionState,
+ isRootLevel: Boolean,
+ isListLayoutState: Flow,
+ createFolder: suspend (String) -> Boolean,
+ renameFolder: suspend (String) -> Boolean,
+ encryptUris: (List, Boolean) -> Unit,
+ decryptSelectedCipherEntities: () -> Unit,
+ deleteSelectedCipherEntities: () -> Unit,
+ relocateSelectedCipherEntities: () -> Unit,
+ updateIsListLayout: (Boolean) -> Unit,
+ navigateToSettings: () -> Unit,
+ navigateToFilePresenter: (CipherFileEntity) -> Unit,
+ navigateToFolder: (String, CipherFolderEntity) -> Unit,
+ navigateToParentFolder: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val isListLayout by isListLayoutState.collectAsStateWithLifecycle(false)
+ var visibleDialog by rememberSaveable { mutableStateOf(BrowserDialogs.NONE) }
+
+ SafeDrawingScaffold(
+ modifier = modifier,
+ largeTopAppBarTitle = folderHierarchyLevel.folder.displayName,
+ largeTopAppBarActions = {
+ IconButton(
+ onClick = { updateIsListLayout(isListLayout.not()) },
+ content = {
+ if (isListLayout) {
+ Icon(Icons.Default.ViewModule, null)
+ } else {
+ Icon(Icons.AutoMirrored.Default.ViewList, null)
+ }
+ }
+ )
+
+ if (isRootLevel) {
+ IconButton(
+ onClick = navigateToSettings,
+ content = { Icon(Icons.Default.Settings, null) }
+ )
+ } else {
+ IconButton(
+ onClick = { visibleDialog = BrowserDialogs.RENAME_FOLDER },
+ content = { Icon(Icons.Default.Edit, null) }
+ )
+ }
+ },
+ largeTopAppBarNavigationIcon = {
+ if (!isRootLevel) {
+ IconButton(
+ onClick = navigateToParentFolder,
+ content = { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
+ )
+ }
+ },
+ bottomOverlay = {
+ when (selectionState.mode) {
+ SelectionState.SelectionMode.NONE -> {
+ FloatingActionsButtons(
+ isEncryptionAllowed = isRootLevel.not(),
+ showEncryptFilesDialog = { visibleDialog = BrowserDialogs.ENCRYPT },
+ showNewFolderDialog = { visibleDialog = BrowserDialogs.NEW_FOLDER }
+ )
+ }
+
+ SelectionState.SelectionMode.SELECTION -> {
+ val numberOfCipherEntities = remember(folderHierarchyLevel) {
+ folderHierarchyLevel.folders.size + folderHierarchyLevel.files.size
+ }
+
+ SelectionModeBar(
+ numberOfSelections = selectionState.numberOfSelections,
+ numberOfCipherEntities = numberOfCipherEntities,
+ disableSelectionMode = selectionState::disableSelectionMode,
+ enableRelocationMode = { selectionState.enableRelocationMode(folderHierarchyLevel.folder) },
+ selectAll = {
+ selectionState.selectFolders(folderHierarchyLevel.folderIds)
+ selectionState.selectFiles(folderHierarchyLevel.fileIds)
+ },
+ showDeleteSelectionDialog = { visibleDialog = BrowserDialogs.DELETE_SELECTION },
+ decryptSelectedCipherEntities = decryptSelectedCipherEntities
+ )
+ }
+
+ SelectionState.SelectionMode.RELOCATION -> {
+ // User should not be able to move entities to their current location and must not be able to move
+ // files to the root of the vault. The root only supports folders.
+ val isRelocationAllowed = remember(isRootLevel, selectionState, folderHierarchyLevel) {
+ folderHierarchyLevel.folder != selectionState.relocationSourceFolder &&
+ (!isRootLevel || selectionState.selectedFileIds.isEmpty())
+ }
+
+ RelocationModeBar(
+ numberOfSelectedFolders = selectionState.selectedFolderIds.size,
+ numberOfSelectedFiles = selectionState.selectedFileIds.size,
+ isRelocationAllowed = isRelocationAllowed,
+ disableSelectionMode = selectionState::disableSelectionMode,
+ relocateSelectedCipherEntities = relocateSelectedCipherEntities
+ )
+ }
+ }
+ }
+ ) { paddingValues ->
+ CipherEntityGrid(
+ folderHierarchyLevel = folderHierarchyLevel,
+ selectionState = selectionState,
+ isListLayout = isListLayout,
+ onFileClick = navigateToFilePresenter,
+ onFolderClick = navigateToFolder,
+ contentPadding = paddingValues
+ )
+ }
+
+ when (visibleDialog) {
+ BrowserDialogs.NONE -> {
+ // Show nothing.
+ }
+
+ BrowserDialogs.NEW_FOLDER -> NewFolderDialog(
+ hideDialog = { visibleDialog = BrowserDialogs.NONE },
+ createFolder = createFolder
+ )
+
+ BrowserDialogs.RENAME_FOLDER -> RenameFolderDialog(
+ hideDialog = { visibleDialog = BrowserDialogs.NONE },
+ folderName = folderHierarchyLevel.folder.displayName,
+ renameFolder = renameFolder
+ )
+
+ BrowserDialogs.ENCRYPT -> EncryptFilesDialog(
+ hideDialog = { visibleDialog = BrowserDialogs.NONE },
+ encryptUris = encryptUris
+ )
+
+ BrowserDialogs.DELETE_SELECTION -> DeleteSelectionDialog(
+ hideDialog = { visibleDialog = BrowserDialogs.NONE },
+ deleteSelectedCipherEntities = deleteSelectedCipherEntities
+ )
+ }
+}
+
+@Composable
+private fun FloatingActionsButtons(
+ isEncryptionAllowed: Boolean,
+ showEncryptFilesDialog: () -> Unit,
+ showNewFolderDialog: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ verticalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ horizontalAlignment = Alignment.End,
+ modifier = modifier,
+ ) {
+ if (isEncryptionAllowed) {
+ FloatingActionButton(
+ onClick = showEncryptFilesDialog,
+ content = { Icon(Icons.Outlined.FileOpen, null) }
+ )
+ }
+
+ FloatingActionButton(
+ onClick = showNewFolderDialog,
+ content = { Icon(Icons.Outlined.CreateNewFolder, null) }
+ )
+ }
+}
+
+@Composable
+private fun SelectionModeBar(
+ numberOfSelections: Int,
+ numberOfCipherEntities: Int,
+ disableSelectionMode: () -> Unit,
+ enableRelocationMode: () -> Unit,
+ selectAll: () -> Unit,
+ showDeleteSelectionDialog: () -> Unit,
+ decryptSelectedCipherEntities: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ shape = FloatingActionButtonDefaults.shape,
+ modifier = modifier.requiredHeight(56.dp)
+ ) {
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.extraSmall),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(horizontal = MaterialTheme.paddings.extraSmall)
+ ) {
+ IconButton(
+ onClick = disableSelectionMode,
+ content = { Icon(Icons.Default.Close, null, tint = MaterialTheme.colorScheme.primary) },
+ )
+ Text(
+ text = "$numberOfSelections/$numberOfCipherEntities"
+ )
+ IconButton(
+ onClick = selectAll,
+ content = { Icon(Icons.Default.SelectAll, null) }
+ )
+ IconButton(
+ onClick = decryptSelectedCipherEntities,
+ content = { Icon(Icons.Default.LockOpen, null) }
+ )
+ IconButton(
+ onClick = enableRelocationMode,
+ content = { Icon(Icons.AutoMirrored.Filled.DriveFileMove, null) }
+ )
+ IconButton(
+ onClick = showDeleteSelectionDialog,
+ content = { Icon(Icons.Default.Delete, null) }
+ )
+ }
+ }
+}
+
+@Composable
+fun RelocationModeBar(
+ numberOfSelectedFolders: Int,
+ numberOfSelectedFiles: Int,
+ isRelocationAllowed: Boolean,
+ disableSelectionMode: () -> Unit,
+ relocateSelectedCipherEntities: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ shape = FloatingActionButtonDefaults.shape,
+ modifier = modifier
+ .sizeIn(maxWidth = 450.dp)
+ .width(IntrinsicSize.Max)
+ ) {
+ Column(
+ modifier = Modifier.padding(MaterialTheme.paddings.extraSmall)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ IconButton(
+ onClick = disableSelectionMode,
+ content = { Icon(Icons.Default.Close, null, tint = MaterialTheme.colorScheme.primary) },
+ )
+
+ Text(
+ text = stringResource(
+ R.string.folders_and_files_selected,
+ numberOfSelectedFolders,
+ numberOfSelectedFiles
+ ),
+ modifier = Modifier.padding(end = MaterialTheme.paddings.extraSmall)
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (numberOfSelectedFiles > 0) {
+ Text(
+ text = stringResource(R.string.note_relocate_files_to_root),
+ fontStyle = FontStyle.Italic,
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ modifier = Modifier
+ .padding(start = MaterialTheme.paddings.medium, end = MaterialTheme.paddings.extraSmall)
+ .weight(1f)
+ )
+ } else {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+
+ Button(
+ enabled = isRelocationAllowed,
+ onClick = relocateSelectedCipherEntities,
+ content = { Text(stringResource(R.string.move_here)) }
+ )
+ }
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun NonRootFolderPreview(
+ @PreviewParameter(BooleanPreviewParameterProvider::class) isListLayout: Boolean
+) = PreviewHost {
+ BrowserView(
+ folderHierarchyLevel = PreviewSampleData.folderHierarchyLevel,
+ selectionState = SelectionState(),
+ isRootLevel = false,
+ isListLayoutState = flowOf(isListLayout),
+ createFolder = { false },
+ renameFolder = { false },
+ encryptUris = { _, _ -> },
+ decryptSelectedCipherEntities = {},
+ deleteSelectedCipherEntities = {},
+ relocateSelectedCipherEntities = {},
+ updateIsListLayout = {},
+ navigateToSettings = {},
+ navigateToFilePresenter = {},
+ navigateToFolder = { _, _ -> },
+ navigateToParentFolder = {}
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserViewModel.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserViewModel.kt
new file mode 100644
index 0000000..2d572dd
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/BrowserViewModel.kt
@@ -0,0 +1,257 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.browser
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.domain.entities.CipherFileEntity
+import de.lukaspieper.truvark.common.domain.entities.CipherFolderEntity
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.data.io.AndroidFileSystem
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.work.WorkScheduler
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class BrowserViewModel @Inject constructor(
+ private val vault: Vault,
+ private val fileSystem: AndroidFileSystem,
+ private val preferences: PersistentPreferences
+) : ViewModel() {
+ private val stack = ArrayDeque()
+ private var updateFolderHierarchyLevelJob: Job
+
+ var currentFolderHierarchyLevel by mutableStateOf(
+ runBlocking { FolderHierarchyLevel(vault.findCipherFolderEntity("")) }
+ )
+ private set
+
+ val selectionState: SelectionState = SelectionState()
+
+ var isRootLevel by mutableStateOf(true)
+ private set
+
+ val isListLayout = preferences.isListLayout
+
+ init {
+ stack.addLast(currentFolderHierarchyLevel)
+ updateFolderHierarchyLevelJob = updateFolderHierarchyLevel()
+ }
+
+ fun navigateToFolder(currentFolderId: String, folder: CipherFolderEntity) {
+ synchronized(stack) {
+ // Prevent parallel folders from being added to the navigation stack, happens with Multi-Touch.
+ if (currentFolderId != currentFolderHierarchyLevel.folder.id) {
+ return
+ }
+
+ updateFolderHierarchyLevelJob.cancel()
+
+ val folderHierarchyLevel = FolderHierarchyLevel(folder)
+ stack.addLast(folderHierarchyLevel)
+ currentFolderHierarchyLevel = folderHierarchyLevel
+ isRootLevel = false
+ }
+
+ updateFolderHierarchyLevelJob = updateFolderHierarchyLevel()
+ }
+
+ fun navigateToParentFolder() {
+ if (stack.size > 1) {
+ synchronized(stack) {
+ updateFolderHierarchyLevelJob.cancel()
+ if (selectionState.mode != SelectionState.SelectionMode.RELOCATION) {
+ selectionState.disableSelectionMode()
+ }
+
+ stack.removeLast()
+ currentFolderHierarchyLevel = stack.last()
+ isRootLevel = (stack.size == 1)
+ }
+
+ updateFolderHierarchyLevelJob = updateFolderHierarchyLevel()
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ private fun updateFolderHierarchyLevel(): Job {
+ return viewModelScope.launch {
+ launch {
+ vault.findCipherFileEntitySubFolders(currentFolderHierarchyLevel.folder.id)
+ .debounce(250)
+ .collect { folders ->
+ val folderIds = folders.map { it.id }.toSet()
+
+ withContext(Dispatchers.Main) {
+ updatePeekOfStack {
+ currentFolderHierarchyLevel.copy(
+ folders = folders,
+ folderIds = folderIds
+ )
+ }
+ }
+ }
+ }
+
+ launch {
+ vault.findCipherFileEntitiesForFolder(currentFolderHierarchyLevel.folder.id)
+ .debounce(250)
+ .collect { files ->
+ val fileIds = files.map { it.id }.toSet()
+
+ withContext(Dispatchers.Main) {
+ updatePeekOfStack {
+ currentFolderHierarchyLevel.copy(
+ files = files,
+ fileIds = fileIds
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun checkForVaultNameUpdates() {
+ if (isRootLevel && currentFolderHierarchyLevel.folder.displayName != vault.displayName) {
+ updatePeekOfStack {
+ currentFolderHierarchyLevel.copy(
+ folder = runBlocking { vault.findCipherFolderEntity("") }
+ )
+ }
+ }
+ }
+
+ private fun updatePeekOfStack(updatePeek: () -> FolderHierarchyLevel) {
+ synchronized(stack) {
+ val folderHierarchyLevel = updatePeek()
+
+ // Update peek of stack
+ stack.removeLast()
+ stack.addLast(folderHierarchyLevel)
+ currentFolderHierarchyLevel = folderHierarchyLevel
+
+ // No need to update the job, because the parent folder id is the same.
+ }
+ }
+
+ suspend fun createCipherFolderEntity(displayName: String): Boolean {
+ try {
+ vault.createFolder(displayName, currentFolderHierarchyLevel.folder)
+ return true
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { e.asLog() }
+ return false
+ }
+ }
+
+ suspend fun renameCipherFolderEntity(newDisplayName: String): Boolean {
+ try {
+ vault.renameFolder(currentFolderHierarchyLevel.folder, newDisplayName)
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { e.asLog() }
+ return false
+ }
+
+ updatePeekOfStack {
+ currentFolderHierarchyLevel.copy(
+ folder = runBlocking { vault.findCipherFolderEntity(currentFolderHierarchyLevel.folder.id) }
+ )
+ }
+ return true
+ }
+
+ fun encryptUris(uris: List, deleteSourceFiles: Boolean) {
+ vault.scheduleEncryption(
+ metadata = WorkScheduler.AndroidSchedulerMetadata(R.string.encrypting_files),
+ destination = currentFolderHierarchyLevel.folder,
+ sources = uris.reversed().map { { fileSystem.fileInfo(it) } },
+ deleteSources = deleteSourceFiles
+ )
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ fun decryptSelectedCipherEntities() {
+ GlobalScope.launch {
+ vault.scheduleDecryption(
+ metadata = WorkScheduler.AndroidSchedulerMetadata(
+ notificationTitle = R.string.decrypting_files,
+ notificationFinishTitle = R.string.decrypted_files,
+ notificationActionText = R.string.open_directory,
+ notificationAction = Intent(Intent.ACTION_VIEW, vault.fileSystem.decryptionRootDirectory.uri as Uri)
+ ),
+ parentFolder = currentFolderHierarchyLevel.folder,
+ files = selectionState.selectedFileIds.map { vault.findCipherFileEntity(it) },
+ folders = selectionState.selectedFolderIds.map { vault.findCipherFolderEntity(it) }
+ )
+
+ selectionState.disableSelectionMode()
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ fun deleteSelectedCipherEntities() {
+ GlobalScope.launch {
+ vault.scheduleDeletion(
+ metadata = WorkScheduler.AndroidSchedulerMetadata(R.string.deleting_files),
+ files = selectionState.selectedFileIds.map { vault.findCipherFileEntity(it) },
+ folders = selectionState.selectedFolderIds.map { vault.findCipherFolderEntity(it) }
+ )
+
+ selectionState.disableSelectionMode()
+ }
+ }
+
+ @OptIn(DelicateCoroutinesApi::class)
+ fun relocateSelectedCipherEntities() {
+ GlobalScope.launch {
+ vault.scheduleRelocation(
+ metadata = WorkScheduler.AndroidSchedulerMetadata(R.string.relocating_files),
+ destination = currentFolderHierarchyLevel.folder,
+ files = selectionState.selectedFileIds.map { vault.findCipherFileEntity(it) },
+ folders = selectionState.selectedFolderIds.map { vault.findCipherFolderEntity(it) }
+ )
+
+ selectionState.disableSelectionMode()
+ }
+ }
+
+ fun updateIsListLayout(isListLayout: Boolean) {
+ viewModelScope.launch {
+ preferences.saveIsListLayout(isListLayout)
+ }
+ }
+
+ @Immutable
+ data class FolderHierarchyLevel(
+ val folder: CipherFolderEntity,
+ val folders: List = emptyList(),
+ val folderIds: Set = emptySet(),
+ val files: List = emptyList(),
+ val fileIds: Set = emptySet()
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/CipherEntityGrid.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/CipherEntityGrid.kt
new file mode 100644
index 0000000..d9fcb98
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/CipherEntityGrid.kt
@@ -0,0 +1,497 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.browser
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.gestures.scrollBy
+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.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+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.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.round
+import androidx.compose.ui.unit.toIntRect
+import coil.ImageLoader
+import coil.compose.SubcomposeAsyncImage
+import coil.request.CachePolicy
+import coil.request.ImageRequest
+import de.lukaspieper.truvark.common.domain.entities.CipherFileEntity
+import de.lukaspieper.truvark.common.domain.entities.CipherFolderEntity
+import de.lukaspieper.truvark.ui.preview.BooleanPreviewParameterProvider
+import de.lukaspieper.truvark.ui.preview.ElementPreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.preview.PreviewSampleData
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.browser.SelectionState.SelectionMode
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlin.time.Duration.Companion.seconds
+
+@Composable
+fun CipherEntityGrid(
+ folderHierarchyLevel: BrowserViewModel.FolderHierarchyLevel,
+ selectionState: SelectionState,
+ isListLayout: Boolean,
+ onFileClick: (CipherFileEntity) -> Unit,
+ onFolderClick: (String, CipherFolderEntity) -> Unit,
+ contentPadding: PaddingValues,
+ modifier: Modifier = Modifier
+) {
+ val gridState = rememberLazyGridState()
+ val gridCells = remember(isListLayout) {
+ if (isListLayout) GridCells.Fixed(1) else GridCells.Adaptive(minSize = 150.dp)
+ }
+
+ val context = LocalContext.current
+ val imageLoader = remember {
+ ImageLoader.Builder(context)
+ .diskCachePolicy(CachePolicy.DISABLED)
+ .memoryCachePolicy(CachePolicy.ENABLED)
+ .build()
+ }
+
+ var isDragging by remember { mutableStateOf(false) }
+ val autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
+ var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
+ LaunchedEffect(autoScrollSpeed) {
+ if (autoScrollSpeed != 0f) {
+ while (isActive) {
+ gridState.scrollBy(autoScrollSpeed)
+ delay(10)
+ }
+ }
+ }
+
+ LazyVerticalGrid(
+ state = gridState,
+ columns = gridCells,
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ contentPadding = contentPadding,
+ modifier = modifier
+ .fillMaxSize()
+ .cipherEntityGridDragHandler(
+ lazyGridState = gridState,
+ folderHierarchyLevel = folderHierarchyLevel,
+ selectionState = selectionState,
+ autoScrollThreshold = autoScrollThreshold,
+ updateAutoScrollSpeed = { autoScrollSpeed = it },
+ updateIsDragging = { isDragging = it }
+ )
+ ) {
+ items(folderHierarchyLevel.folders, key = { it.id }) { cipherFolderEntity ->
+ val isSelected by remember(selectionState.selectedFolderIds) {
+ derivedStateOf { selectionState.selectedFolderIds.contains(cipherFolderEntity.id) }
+ }
+
+ // cipherFolderEntity may got updated (e.g. renamed)
+ val clickableModifier = remember(selectionState.mode, isDragging, isSelected, cipherFolderEntity) {
+ if (isDragging) return@remember Modifier
+ // User should not be able to select subfolder of selected folder as destination.
+ if (isSelected && selectionState.mode == SelectionMode.RELOCATION) return@remember Modifier
+
+ Modifier.clickable {
+ when (selectionState.mode) {
+ SelectionMode.SELECTION -> selectionState.switchFolderSelection(cipherFolderEntity.id)
+ else -> onFolderClick(folderHierarchyLevel.folder.id, cipherFolderEntity)
+ }
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .requiredHeight(56.dp)
+ .animateItem()
+ .clip(CardDefaults.shape)
+ .then(clickableModifier)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.aspectRatio(1F)
+ ) {
+ Surface(
+ shape = CardDefaults.shape,
+ color = if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(MaterialTheme.paddings.extraSmall)
+ ) {
+ Icon(
+ if (isSelected) Icons.Default.CheckCircle else Icons.Default.Folder,
+ contentDescription = null,
+ modifier = Modifier.requiredSize(28.dp)
+ )
+ }
+ }
+
+ Text(
+ text = cipherFolderEntity.displayName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+
+ item(span = { GridItemSpan(currentLineSpan = maxCurrentLineSpan) }) {
+ }
+
+ items(folderHierarchyLevel.files, key = { it.id }) { cipherFileEntity ->
+ val isSelected by remember(selectionState.selectedFileIds) {
+ derivedStateOf { selectionState.selectedFileIds.contains(cipherFileEntity.id) }
+ }
+
+ val clickableModifier = remember(selectionState.mode, isDragging) {
+ if (isDragging) return@remember Modifier
+
+ when (selectionState.mode) {
+ SelectionMode.NONE -> Modifier.clickable { onFileClick(cipherFileEntity) }
+ SelectionMode.SELECTION -> Modifier.clickable {
+ selectionState.switchFileSelection(cipherFileEntity.id)
+ }
+
+ SelectionMode.RELOCATION -> Modifier
+ }
+ }
+
+ val thumbnail = remember {
+ ImageRequest.Builder(context)
+ .data(cipherFileEntity.thumbnail)
+ .memoryCacheKey(cipherFileEntity.id)
+ .build()
+ }
+
+ Card(
+ modifier = Modifier
+ .animateItem()
+ .clip(CardDefaults.shape)
+ .then(clickableModifier)
+ .then(if (isListLayout) Modifier else Modifier.aspectRatio(1F))
+ ) {
+ if (isListLayout) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ var isError by remember { mutableStateOf(false) }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.requiredSize(56.dp)
+ ) {
+ SubcomposeAsyncImage(
+ model = thumbnail,
+ imageLoader = imageLoader,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ onError = { isError = true }
+ )
+
+ if (isError || isSelected) {
+ Surface(
+ shape = CardDefaults.shape,
+ color = if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(MaterialTheme.paddings.extraSmall)
+ ) {
+ Icon(
+ if (isSelected) Icons.Default.CheckCircle else Icons.AutoMirrored.Default.InsertDriveFile,
+ contentDescription = null,
+ modifier = Modifier.requiredSize(28.dp)
+ )
+ }
+ }
+ }
+
+ Column {
+ Text(
+ text = cipherFileEntity.fullName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.large)) {
+ Text(
+ text = cipherFileEntity.mimeType,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontStyle = FontStyle.Italic,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize
+ )
+
+ cipherFileEntity.mediaDurationSeconds?.let { mediaDurationSeconds ->
+ Text(
+ text = mediaDurationSeconds.seconds.toString(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontStyle = FontStyle.Italic,
+ fontSize = MaterialTheme.typography.bodyMedium.fontSize
+ )
+ }
+ }
+ }
+ }
+ } else {
+ Box {
+ SubcomposeAsyncImage(
+ model = thumbnail,
+ imageLoader = imageLoader,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ error = {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(MaterialTheme.paddings.large)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Default.InsertDriveFile,
+ contentDescription = null,
+ modifier = Modifier.defaultMinSize(64.dp, 64.dp)
+ )
+ Spacer(modifier = Modifier.size(MaterialTheme.paddings.medium))
+
+ Text(
+ text = cipherFileEntity.fullName(),
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ )
+
+ if (isSelected) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.requiredSize(56.dp)
+ ) {
+ Surface(
+ shape = CardDefaults.shape,
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(MaterialTheme.paddings.extraSmall)
+ ) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.requiredSize(28.dp)
+ )
+ }
+ }
+ }
+
+ cipherFileEntity.mediaDurationSeconds?.let { mediaDurationSeconds ->
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ ),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(
+ bottom = MaterialTheme.paddings.extraSmall,
+ end = MaterialTheme.paddings.extraSmall
+ )
+ ) {
+ Text(
+ text = mediaDurationSeconds.seconds.toString(),
+ modifier = Modifier.padding(MaterialTheme.paddings.extraSmall)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun Modifier.cipherEntityGridDragHandler(
+ lazyGridState: LazyGridState,
+ folderHierarchyLevel: BrowserViewModel.FolderHierarchyLevel,
+ selectionState: SelectionState,
+ autoScrollThreshold: Float,
+ updateAutoScrollSpeed: (Float) -> Unit,
+ updateIsDragging: (Boolean) -> Unit
+): Modifier {
+ if (selectionState.mode == SelectionMode.RELOCATION) return this
+
+ return this.pointerInput(folderHierarchyLevel, selectionState) {
+ fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): LazyGridItemInfo? {
+ val paddingAwareHitPoint = hitPoint.copy(
+ y = hitPoint.y + lazyGridState.layoutInfo.viewportStartOffset
+ )
+
+ return layoutInfo.visibleItemsInfo.find { itemInfo ->
+ itemInfo.size.toIntRect().contains(paddingAwareHitPoint.round() - itemInfo.offset)
+ }
+ }
+
+ val cipherEntitiesSize = folderHierarchyLevel.folders.size + folderHierarchyLevel.files.size
+ val allGridEntitiesIds = folderHierarchyLevel.folderIds.toList()
+ .plus(List(lazyGridState.layoutInfo.totalItemsCount - cipherEntitiesSize) { null })
+ .plus(folderHierarchyLevel.fileIds)
+
+ var initial: LazyGridItemInfo? = null
+ var previous: LazyGridItemInfo? = null
+
+ detectDragGesturesAfterLongPress(
+ onDragStart = { offset ->
+ lazyGridState.gridItemKeyAtPosition(offset)?.let { current ->
+ initial = current
+ previous = current
+ updateIsDragging(true)
+
+ val id = current.key as? String
+ when {
+ folderHierarchyLevel.folderIds.contains(id) -> selectionState.selectFolders(setOf(id!!))
+ folderHierarchyLevel.fileIds.contains(id) -> selectionState.selectFiles(setOf(id!!))
+ }
+ }
+ },
+ onDragCancel = {
+ initial = null
+ updateAutoScrollSpeed(0f)
+ updateIsDragging(false)
+ },
+ onDragEnd = {
+ initial = null
+ updateAutoScrollSpeed(0f)
+ updateIsDragging(false)
+ },
+ onDrag = { change, _ ->
+ if (initial != null) {
+ val distFromBottom = lazyGridState.layoutInfo.viewportSize.height - change.position.y
+ val distFromTop = change.position.y
+ val newAutoScrollSpeed = when {
+ distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom
+ distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop)
+ else -> 0f
+ }
+ updateAutoScrollSpeed(newAutoScrollSpeed)
+
+ lazyGridState.gridItemKeyAtPosition(change.position)?.let { current ->
+ if (previous != current) {
+ val currentIndices = when {
+ initial!!.index < current.index -> initial!!.index..current.index
+ else -> current.index..initial!!.index
+ }
+ val previousIndices = when {
+ initial!!.index < previous!!.index -> initial!!.index..previous!!.index
+ else -> previous!!.index..initial!!.index
+ }
+
+ val folderIds = HashSet()
+ val fileIds = HashSet()
+
+ // Items to select
+ (currentIndices - previousIndices).mapNotNull { allGridEntitiesIds.getOrNull(it) }.forEach {
+ when {
+ folderHierarchyLevel.folderIds.contains(it) -> folderIds.add(it)
+ folderHierarchyLevel.fileIds.contains(it) -> fileIds.add(it)
+ }
+ }
+ selectionState.selectFolders(folderIds)
+ selectionState.selectFiles(fileIds)
+
+ folderIds.clear()
+ fileIds.clear()
+
+ // Items to deselect
+ (previousIndices - currentIndices).mapNotNull { allGridEntitiesIds.getOrNull(it) }.forEach {
+ when {
+ folderHierarchyLevel.folderIds.contains(it) -> folderIds.add(it)
+ folderHierarchyLevel.fileIds.contains(it) -> fileIds.add(it)
+ }
+ }
+ selectionState.deselectFolders(folderIds)
+ selectionState.deselectFiles(fileIds)
+
+ previous = current
+ }
+ }
+ }
+ }
+ )
+ }
+}
+
+@ElementPreviews
+@Composable
+private fun CipherEntityGridSelectionPreviews(
+ @PreviewParameter(BooleanPreviewParameterProvider::class) isListLayout: Boolean
+) = PreviewHost {
+ CipherEntityGrid(
+ folderHierarchyLevel = PreviewSampleData.folderHierarchyLevel,
+ selectionState = SelectionState(
+ initialSelectedFolderIds = setOf("folder1", "folder3", "folder4"),
+ initialSelectedFileIds = setOf("file0", "file3", "file4", "file6", "file11", "file18", "file21")
+ ),
+ isListLayout = isListLayout,
+ onFileClick = {},
+ onFolderClick = { _, _ -> },
+ contentPadding = PaddingValues(MaterialTheme.paddings.large),
+ modifier = Modifier
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/SelectionState.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/SelectionState.kt
new file mode 100644
index 0000000..80f5e32
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/browser/SelectionState.kt
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.browser
+
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import de.lukaspieper.truvark.common.domain.entities.CipherFolderEntity
+
+class SelectionState(
+ initialSelectedFolderIds: Set = emptySet(),
+ initialSelectedFileIds: Set = emptySet(),
+) {
+ var relocationSourceFolder by mutableStateOf(null)
+ private set
+
+ var selectedFolderIds by mutableStateOf(initialSelectedFolderIds)
+ private set
+
+ var selectedFileIds by mutableStateOf(initialSelectedFileIds)
+ private set
+
+ val numberOfSelections by derivedStateOf { selectedFolderIds.size + selectedFileIds.size }
+
+ val mode by derivedStateOf {
+ when {
+ relocationSourceFolder != null -> SelectionMode.RELOCATION
+ numberOfSelections > 0 -> SelectionMode.SELECTION
+ else -> SelectionMode.NONE
+ }
+ }
+
+ /**
+ * Switches the selection mode to [SelectionMode.NONE].
+ */
+ fun disableSelectionMode() {
+ selectedFolderIds = emptySet()
+ selectedFileIds = emptySet()
+ relocationSourceFolder = null
+ }
+
+ fun enableRelocationMode(sourceFolder: CipherFolderEntity) {
+ relocationSourceFolder = sourceFolder
+ }
+
+ fun selectFolders(folderIds: Set) {
+ if (folderIds.isEmpty()) return
+ selectedFolderIds = selectedFolderIds + folderIds
+ }
+
+ fun deselectFolders(folderIds: Set) {
+ if (folderIds.isEmpty()) return
+ selectedFolderIds = selectedFolderIds - folderIds
+ }
+
+ fun selectFiles(fileIds: Set) {
+ if (fileIds.isEmpty()) return
+ selectedFileIds = selectedFileIds + fileIds
+ }
+
+ fun deselectFiles(fileIds: Set) {
+ if (fileIds.isEmpty()) return
+ selectedFileIds = selectedFileIds - fileIds
+ }
+
+ fun switchFolderSelection(folderId: String) {
+ selectedFolderIds = when {
+ selectedFolderIds.contains(folderId) -> selectedFolderIds - folderId
+ else -> selectedFolderIds + folderId
+ }
+ }
+
+ fun switchFileSelection(fileId: String) {
+ selectedFileIds = when {
+ selectedFileIds.contains(fileId) -> selectedFileIds - fileId
+ else -> selectedFileIds + fileId
+ }
+ }
+
+ enum class SelectionMode {
+ NONE,
+ SELECTION,
+ RELOCATION
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherPage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherPage.kt
new file mode 100644
index 0000000..7710051
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherPage.kt
@@ -0,0 +1,476 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.launcher
+
+import android.Manifest
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.LocalActivity
+import androidx.annotation.StringRes
+import androidx.biometric.BiometricPrompt
+import androidx.biometric.auth.AuthPromptCallback
+import androidx.biometric.auth.authenticateWithClass3Biometrics
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.outlined.FolderOpen
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.PermissionState
+import com.google.accompanist.permissions.PermissionStatus
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import de.lukaspieper.truvark.Page
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.ui.controls.PasswordField
+import de.lukaspieper.truvark.ui.controls.SafeDrawingScaffold
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.DIRECTORY_SELECTION
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.DONE
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.NONE
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.PROCESSING
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.VAULT_CREATION
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun LauncherPage(
+ navigateAndClearBackStack: (Page) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: LauncherViewModel = hiltViewModel()
+) {
+ val activity = LocalActivity.current as FragmentActivity
+
+ LaunchedEffect(viewModel.state, navigateAndClearBackStack) {
+ if (viewModel.state == DONE) {
+ navigateAndClearBackStack(Page.Browser)
+ }
+ }
+
+ val notificationPermissionState = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
+ rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ else -> null
+ }
+
+ LauncherView(
+ notificationPermissionState = notificationPermissionState,
+ state = viewModel.state,
+ updateState = { viewModel.state = it },
+ vaultDisplayName = viewModel.vaultConfig?.displayName ?: "",
+ biometricUnlockingSupported = viewModel.supportsBiometricUnlocking,
+ unlockingErrorText = viewModel.unlockingErrorText,
+ unlockVaultWithPassword = viewModel::unlockVaultWithPassword,
+ showBiometricPrompt = { showBiometricPrompt(activity, viewModel) },
+ setupDialog = {
+ SetupDialog(
+ state = viewModel.state,
+ updateState = { viewModel.state = it },
+ inspectDirectory = viewModel::inspectDirectory,
+ createVault = viewModel::createVault
+ )
+ },
+ modifier = modifier
+ )
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+private fun LauncherView(
+ notificationPermissionState: PermissionState?,
+ state: LauncherViewModel.LauncherState,
+ vaultDisplayName: String,
+ biometricUnlockingSupported: Boolean,
+ unlockingErrorText: Int?,
+ updateState: (LauncherViewModel.LauncherState) -> Unit,
+ unlockVaultWithPassword: (ByteArray) -> Unit,
+ showBiometricPrompt: () -> Unit,
+ setupDialog: @Composable () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ SafeDrawingScaffold(
+ largeTopAppBarTitle = stringResource(R.string.app_name),
+ modifier = modifier,
+ ) { paddingValues ->
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = CenterHorizontally,
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(paddingValues)
+ ) {
+ Card(
+ modifier = Modifier.sizeIn(maxWidth = 550.dp)
+ ) {
+ if (notificationPermissionState?.status is PermissionStatus.Denied) {
+ NotificationPermissionView(notificationPermissionState)
+ } else {
+ if (vaultDisplayName.isNotBlank()) {
+ VaultUnlockCardView(
+ vaultDisplayName = vaultDisplayName,
+ biometricUnlockingSupported = biometricUnlockingSupported,
+ unlockingErrorText = unlockingErrorText,
+ unlockVaultWithPassword = unlockVaultWithPassword,
+ showBiometricPrompt = showBiometricPrompt
+ )
+ } else {
+ NoVaultCardView()
+ }
+ }
+ }
+
+ if (notificationPermissionState?.status !is PermissionStatus.Denied) {
+ OutlinedButton(
+ onClick = { updateState(DIRECTORY_SELECTION) },
+ modifier = Modifier
+ .align(CenterHorizontally)
+ .padding(top = 32.dp)
+ ) {
+ Icon(Icons.Outlined.FolderOpen, null)
+ Text(
+ text = stringResource(R.string.create_or_open_vault),
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+
+ if (state in listOf(DIRECTORY_SELECTION, VAULT_CREATION, PROCESSING)) {
+ setupDialog()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+private fun NotificationPermissionView(notificationPermissionState: PermissionState, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ var requestPermissionCounter by rememberSaveable { mutableIntStateOf(0) }
+ var permissionRequestCompleted by rememberSaveable { mutableStateOf(false) }
+
+ with(notificationPermissionState) {
+ LaunchedEffect(status) {
+ if (requestPermissionCounter > 0) {
+ permissionRequestCompleted = true
+ }
+ }
+
+ Column(
+ modifier = modifier.padding(all = MaterialTheme.paddings.large),
+ verticalArrangement = spacedBy(MaterialTheme.paddings.medium)
+ ) {
+ Text(
+ text = stringResource(R.string.notification_permission_description),
+ textAlign = TextAlign.Justify,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Button(
+ onClick = {
+ if (requestPermissionCounter > 1 || (permissionRequestCompleted && !status.shouldShowRationale)) {
+ context.startActivity(
+ Intent().apply {
+ action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
+ data = Uri.fromParts("package", context.packageName, null)
+ }
+ )
+ } else {
+ launchPermissionRequest()
+ requestPermissionCounter++
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = when {
+ requestPermissionCounter > 1 || (permissionRequestCompleted && !status.shouldShowRationale) -> {
+ stringResource(R.string.open_app_settings)
+ }
+
+ else -> stringResource(R.string.grant_permission)
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun NoVaultCardView(modifier: Modifier = Modifier) {
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.large),
+ modifier = modifier
+ .height(IntrinsicSize.Min)
+ .padding(MaterialTheme.paddings.large)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_locker),
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxHeight()
+ .sizeIn(minHeight = 80.dp, maxWidth = 80.dp)
+ )
+ Text(
+ text = stringResource(R.string.no_existing_vault_info),
+ textAlign = TextAlign.Justify,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.align(Alignment.CenterVertically)
+ )
+ }
+}
+
+@Composable
+private fun VaultUnlockCardView(
+ vaultDisplayName: String,
+ biometricUnlockingSupported: Boolean,
+ unlockingErrorText: Int?,
+ unlockVaultWithPassword: (ByteArray) -> Unit,
+ showBiometricPrompt: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .height(100.dp)
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.primary)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_locker),
+ contentDescription = null,
+ modifier = Modifier.padding(MaterialTheme.paddings.extraLarge)
+ )
+ Text(
+ text = vaultDisplayName,
+ modifier = Modifier.padding(end = MaterialTheme.paddings.large),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.headlineLarge
+ )
+ }
+
+ Column(
+ verticalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = Modifier.padding(MaterialTheme.paddings.large)
+ ) {
+ PasswordUnlockView(unlockVaultWithPassword, unlockingErrorText)
+
+ if (biometricUnlockingSupported) {
+ Button(
+ onClick = showBiometricPrompt,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.biometric_unlocking),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Icon(Icons.Default.Fingerprint, null)
+ }
+ }
+ }
+}
+
+@Composable
+private fun PasswordUnlockView(
+ unlockWithPassword: (ByteArray) -> Unit,
+ @StringRes errorMessageResource: Int?
+) {
+ if (errorMessageResource != null && errorMessageResource != R.string.incorrect_password) {
+ Surface(
+ shape = CardDefaults.shape,
+ color = MaterialTheme.colorScheme.errorContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(errorMessageResource),
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(MaterialTheme.paddings.small)
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ var password by rememberSaveable { mutableStateOf("") }
+ PasswordField(
+ value = password,
+ onValueChange = { password = it },
+ onKeyboardDone = {
+ keyboardController?.hide()
+ unlockWithPassword(password.toByteArray())
+ },
+ passwordIsIncorrect = errorMessageResource == R.string.incorrect_password,
+ modifier = Modifier.weight(1f)
+ )
+
+ Button(
+ onClick = {
+ keyboardController?.hide()
+ unlockWithPassword(password.toByteArray())
+ },
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .height(TextFieldDefaults.MinHeight)
+ ) {
+ Icon(Icons.Default.LockOpen, null)
+ }
+ }
+}
+
+private fun showBiometricPrompt(activity: FragmentActivity, viewModel: LauncherViewModel) {
+ val callback = object : AuthPromptCallback() {
+
+ override fun onAuthenticationError(activity: FragmentActivity?, errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(activity, errorCode, errString)
+ logcat(LogPriority.WARN) { "Biometric unlocking failed: $errorCode '$errString'" }
+
+ val errorCodesCausedByUser = listOf(
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON,
+ BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_CANCELED
+ )
+ if (!errorCodesCausedByUser.contains(errorCode)) {
+ viewModel.disableBiometricUnlockingBecauseOfError()
+ }
+ }
+
+ override fun onAuthenticationSucceeded(
+ activity: FragmentActivity?,
+ result: BiometricPrompt.AuthenticationResult
+ ) {
+ super.onAuthenticationSucceeded(activity, result)
+
+ result.cryptoObject?.cipher?.let { cipher ->
+ viewModel.unlockWithCipher(cipher)
+ }
+ }
+ }
+
+ try {
+ val cryptoObject = viewModel.getCryptoObject()
+ activity.authenticateWithClass3Biometrics(
+ crypto = cryptoObject,
+ title = activity.getString(R.string.biometric_unlocking),
+ negativeButtonText = activity.getString(R.string.cancel),
+ callback = callback
+ )
+ } catch (e: Exception) {
+ logcat("LauncherPage", LogPriority.ERROR) { e.asLog() }
+ viewModel.disableBiometricUnlockingBecauseOfError()
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@PagePreviews
+@Composable
+private fun NoNotificationPermissionPreview() = PreviewHost {
+ LauncherView(
+ notificationPermissionState = object : PermissionState {
+ override val permission: String = ""
+ override val status: PermissionStatus = PermissionStatus.Denied(false)
+
+ override fun launchPermissionRequest() {
+ // Previews do not need implementations.
+ }
+ },
+ state = NONE,
+ vaultDisplayName = "",
+ biometricUnlockingSupported = false,
+ unlockingErrorText = null,
+ updateState = {},
+ unlockVaultWithPassword = {},
+ showBiometricPrompt = {},
+ setupDialog = {}
+ )
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@PagePreviews
+@Composable
+private fun NoVaultSelectedPreview() = PreviewHost {
+ LauncherView(
+ notificationPermissionState = null,
+ state = NONE,
+ vaultDisplayName = "",
+ biometricUnlockingSupported = false,
+ unlockingErrorText = null,
+ updateState = {},
+ unlockVaultWithPassword = {},
+ showBiometricPrompt = {},
+ setupDialog = {}
+ )
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@PagePreviews
+@Composable
+private fun VaultSelectedPreview() = PreviewHost {
+ LauncherView(
+ notificationPermissionState = null,
+ state = NONE,
+ vaultDisplayName = "Vault",
+ biometricUnlockingSupported = true,
+ unlockingErrorText = null,
+ updateState = {},
+ unlockVaultWithPassword = {},
+ showBiometricPrompt = {},
+ setupDialog = {}
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherViewModel.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherViewModel.kt
new file mode 100644
index 0000000..6fe4209
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/LauncherViewModel.kt
@@ -0,0 +1,228 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.launcher
+
+import android.net.Uri
+import androidx.biometric.BiometricPrompt
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.constants.FileNames
+import de.lukaspieper.truvark.common.constants.FixedValues
+import de.lukaspieper.truvark.common.data.io.DirectoryInfo
+import de.lukaspieper.truvark.common.domain.IdGenerator
+import de.lukaspieper.truvark.common.domain.vault.VaultConfig
+import de.lukaspieper.truvark.common.domain.vault.VaultFactory
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.LogPriority.DEBUG
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.data.database.DatabaseFileSynchronization
+import de.lukaspieper.truvark.data.io.AndroidFileSystem
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.di.VaultModule
+import de.lukaspieper.truvark.domain.crypto.BiometricConfig
+import de.lukaspieper.truvark.domain.crypto.BiometricCryptoProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.security.GeneralSecurityException
+import javax.crypto.Cipher
+import javax.inject.Inject
+
+@HiltViewModel
+class LauncherViewModel @Inject constructor(
+ private val preferences: PersistentPreferences,
+ private val fileSystem: AndroidFileSystem,
+ private val databaseFileSynchronization: DatabaseFileSynchronization,
+ private val idGenerator: IdGenerator,
+ private val vaultFactory: VaultFactory,
+ private val biometricCryptoProvider: BiometricCryptoProvider
+) : ViewModel() {
+ private var directory: DirectoryInfo? = null
+ private var directoryUri: Uri? = null
+ private var biometricConfig: BiometricConfig? = null
+
+ var vaultConfig by mutableStateOf(null)
+ private set
+
+ var state by mutableStateOf(LauncherState.PROCESSING)
+ var unlockingErrorText by mutableStateOf(null)
+
+ val supportsBiometricUnlocking by derivedStateOf {
+ unlockingErrorText != R.string.biometric_unlocking_failed && biometricConfig?.vaultId == vaultConfig?.id
+ }
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ preferences.lastUsedVaultRootUri.first().let { uri ->
+ try {
+ val selectedDirectory = fileSystem.directoryInfo(uri)
+ val vaultFile = fileSystem.findFileOrNull(selectedDirectory, FileNames.VAULT)
+ vaultFactory.tryReadVaultConfig(vaultFile!!)!!.let {
+ withContext(Dispatchers.Main) {
+ vaultConfig = it
+ directory = selectedDirectory
+ directoryUri = uri
+ state = LauncherState.NONE
+ }
+ }
+ } catch (e: Exception) {
+ logcat(DEBUG) { e.asLog() }
+
+ withContext(Dispatchers.Main) {
+ state = LauncherState.DIRECTORY_SELECTION
+ }
+ }
+ }
+ }
+ viewModelScope.launch(Dispatchers.IO) {
+ preferences.biometricConfig.collect { biometricConfig = it }
+ }
+ }
+
+ fun inspectDirectory(uri: Uri) = viewModelScope.launch(Dispatchers.IO) {
+ withContext(Dispatchers.Main) {
+ state = LauncherState.PROCESSING
+ vaultConfig = null
+ directory = null
+ directoryUri = null
+ }
+
+ val selectedDirectory = fileSystem.directoryInfo(uri)
+ val foundFiles = fileSystem.listFiles(selectedDirectory)
+ val vaultFile = foundFiles.firstOrNull { it.fullName == FileNames.VAULT }
+
+ if (vaultFile != null) {
+ vaultFactory.tryReadVaultConfig(vaultFile)?.let {
+ fileSystem.takePersistableUriPermission(uri)
+ preferences.saveLastUsedVaultRootUri(uri)
+
+ withContext(Dispatchers.Main) {
+ vaultConfig = it
+ directory = selectedDirectory
+ directoryUri = uri
+ state = LauncherState.NONE
+ }
+ }
+ } else if (foundFiles.isEmpty() && fileSystem.listDirectories(selectedDirectory).isEmpty()) {
+ withContext(Dispatchers.Main) {
+ directory = selectedDirectory
+ directoryUri = uri
+ state = LauncherState.VAULT_CREATION
+ }
+ } else {
+ withContext(Dispatchers.Main) {
+ state = LauncherState.DIRECTORY_SELECTION
+ }
+ }
+ }
+
+ fun createVault(password: String) = GlobalScope.launch(Dispatchers.IO) {
+ withContext(Dispatchers.Main) {
+ state = LauncherState.PROCESSING
+ }
+
+ directory!!.let { directory ->
+ val vaultId = idGenerator.createStringId(FixedValues.VAULT_ID_LENGTH)
+
+ val vault = vaultFactory.createVault(
+ vaultDirectory = directory,
+ password = password.toByteArray(),
+ databaseFile = fileSystem.appFilesDir().resolve(vaultId).resolve(FileNames.INDEX_REALM),
+ vaultId = vaultId
+ )
+
+ fileSystem.takePersistableUriPermission(directoryUri!!)
+ preferences.saveLastUsedVaultRootUri(directoryUri!!)
+
+ VaultModule.initializeVaultModule(vault)
+ withContext(Dispatchers.Main) {
+ state = LauncherState.DONE
+ }
+ }
+ }
+
+ @Throws(Exception::class)
+ fun getCryptoObject(): BiometricPrompt.CryptoObject {
+ check(biometricConfig?.iv != null)
+ return biometricCryptoProvider.createDecryptingPromptObject(biometricConfig!!.iv)
+ }
+
+ fun unlockWithCipher(cipher: Cipher) = viewModelScope.launch(Dispatchers.Default) {
+ withContext(Dispatchers.Main) {
+ state = LauncherState.PROCESSING
+ }
+
+ try {
+ val encryptedPassword = biometricConfig!!.accessKey
+ val password = cipher.doFinal(encryptedPassword)
+
+ unlockVaultWithPassword(password)
+ } catch (e: Exception) {
+ logcat(LogPriority.ERROR) { e.asLog() }
+ withContext(Dispatchers.Main) {
+ disableBiometricUnlockingBecauseOfError()
+ state = LauncherState.NONE
+ }
+ }
+ }
+
+ fun unlockVaultWithPassword(password: ByteArray) = viewModelScope.launch(Dispatchers.Default) {
+ withContext(Dispatchers.Main) {
+ state = LauncherState.PROCESSING
+ }
+
+ try {
+ val internalDatabaseFile = fileSystem.appFilesDir().resolve(vaultConfig!!.id).resolve(FileNames.INDEX_REALM)
+ databaseFileSynchronization.synchronizeDatabaseFiles(
+ vaultDatabaseFile = fileSystem.findOrCreateFile(directory!!, FileNames.INDEX_DATABASE),
+ internalDatabaseFile = internalDatabaseFile
+ )
+
+ val vault = vaultFactory.decryptVault(
+ directory!!,
+ password,
+ internalDatabaseFile
+ )
+
+ VaultModule.initializeVaultModule(vault)
+ withContext(Dispatchers.Main) {
+ state = LauncherState.DONE
+ }
+ } catch (exception: Exception) {
+ logcat(LogPriority.ERROR) { exception.asLog() }
+ withContext(Dispatchers.Main) {
+ unlockingErrorText = when (exception) {
+ is GeneralSecurityException -> R.string.incorrect_password
+ else -> R.string.error_unlocking_vault
+ }
+
+ state = LauncherState.NONE
+ }
+ }
+ }
+
+ fun disableBiometricUnlockingBecauseOfError() {
+ unlockingErrorText = R.string.biometric_unlocking_failed
+ }
+
+ enum class LauncherState {
+ NONE,
+ PROCESSING,
+ DIRECTORY_SELECTION,
+ VAULT_CREATION,
+ DONE
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/SetupDialog.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/SetupDialog.kt
new file mode 100644
index 0000000..274769e
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/launcher/SetupDialog.kt
@@ -0,0 +1,167 @@
+/*
+ * SPDX-FileCopyrightText: 2023 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.launcher
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.constants.FixedValues
+import de.lukaspieper.truvark.ui.controls.MaterialDialog
+import de.lukaspieper.truvark.ui.controls.PasswordField
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.views.ActivityResultContracts
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.DIRECTORY_SELECTION
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.NONE
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.PROCESSING
+import de.lukaspieper.truvark.ui.views.launcher.LauncherViewModel.LauncherState.VAULT_CREATION
+
+@Composable
+fun SetupDialog(
+ state: LauncherViewModel.LauncherState,
+ updateState: (LauncherViewModel.LauncherState) -> Unit,
+ inspectDirectory: (Uri) -> Unit,
+ createVault: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (state == LauncherViewModel.LauncherState.PROCESSING) {
+ MaterialDialog(
+ isLoadingIndicator = true,
+ modifier = modifier,
+ content = {}
+ )
+ } else if (state == LauncherViewModel.LauncherState.DIRECTORY_SELECTION) {
+ val openDocumentTreeLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocumentTreeWithFlags()
+ ) { uri ->
+ uri?.let { inspectDirectory(it) }
+ }
+
+ MaterialDialog(
+ title = R.string.create_or_open_vault,
+ modifier = modifier,
+ dismissButton = {
+ TextButton(
+ onClick = { updateState(NONE) },
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { openDocumentTreeLauncher.launch(null) },
+ ) {
+ Text(stringResource(R.string.choose_vault_root_dir))
+ }
+ }
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_vault_filesystem),
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+ contentDescription = null,
+ modifier = Modifier
+ .height(200.dp)
+ .fillMaxWidth()
+ )
+
+ Text(
+ text = stringResource(R.string.storage_location_text),
+ textAlign = TextAlign.Justify
+ )
+ }
+ } else if (state == VAULT_CREATION) {
+ var password by rememberSaveable { mutableStateOf("") }
+ var passwordConfirmation by rememberSaveable { mutableStateOf("") }
+ var errorText by rememberSaveable { mutableStateOf(null) }
+
+ MaterialDialog(
+ title = R.string.set_password,
+ confirmButton = {
+ Button(
+ onClick = {
+ if (password != passwordConfirmation) {
+ errorText = R.string.inputs_do_not_match
+ } else if (password.length < FixedValues.MIN_PASSWORD_LENGTH) {
+ errorText = R.string.password_length
+ } else {
+ createVault(password)
+ }
+ }
+ ) {
+ Text(stringResource(R.string.create_vault))
+ }
+ }
+ ) {
+ Text(
+ text = stringResource(R.string.setup_password_text),
+ textAlign = TextAlign.Justify
+ )
+
+ PasswordField(
+ value = password,
+ onValueChange = { password = it },
+ imeAction = ImeAction.Next,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ )
+
+ PasswordField(
+ value = passwordConfirmation,
+ onValueChange = { passwordConfirmation = it },
+ imeAction = ImeAction.Next,
+ label = R.string.repeat_password,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Text(
+ text = if (errorText != null) stringResource(errorText!!, FixedValues.MIN_PASSWORD_LENGTH) else "",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+}
+
+private class LauncherStatePreviewParameterProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(DIRECTORY_SELECTION, VAULT_CREATION, PROCESSING)
+}
+
+@PagePreviews
+@Composable
+private fun SetupDialogPreview(
+ @PreviewParameter(LauncherStatePreviewParameterProvider::class) state: LauncherViewModel.LauncherState
+) = PreviewHost {
+ SetupDialog(
+ state = state,
+ updateState = {},
+ inspectDirectory = {},
+ createVault = {}
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterPage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterPage.kt
new file mode 100644
index 0000000..3f50638
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterPage.kt
@@ -0,0 +1,284 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.presenter
+
+import android.annotation.SuppressLint
+import android.media.MediaDataSource
+import androidx.activity.compose.LocalActivity
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.ScaffoldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.zIndex
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.lukaspieper.truvark.Page
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.domain.crypto.decryption.coil.CipherZoomableImageSource
+import de.lukaspieper.truvark.ui.extensions.safeDrawingTopAppBar
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.preview.PreviewSampleData
+import de.lukaspieper.truvark.ui.views.presenter.views.FileNotFoundContentView
+import de.lukaspieper.truvark.ui.views.presenter.views.NotSupportedContentView
+import de.lukaspieper.truvark.ui.views.presenter.views.VideoContentView
+import me.saket.telephoto.zoomable.ZoomableImage
+
+@Composable
+fun PresenterPage(
+ parameters: Page.Presenter,
+ navigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: PresenterViewModel = hiltViewModel(
+ creationCallback = { factory: PresenterViewModel.Factory ->
+ factory.create(parameters.folderId)
+ }
+ )
+) {
+ val itemsData by viewModel.itemsData.collectAsStateWithLifecycle()
+ val imagesFitScreen by viewModel.imagesFitScreen.collectAsStateWithLifecycle(true)
+
+ PresenterView(
+ itemsData = itemsData,
+ createCipherZoomableImageSource = viewModel::createCipherZoomableImageSource,
+ createMediaDataSource = viewModel::createMediaDataSource,
+ imagesFitScreen = imagesFitScreen,
+ initialFileId = parameters.fileId,
+ navigateBack = navigateBack,
+ modifier = modifier
+ )
+}
+
+@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun PresenterView(
+ itemsData: PresenterViewModel.ItemsData,
+ createCipherZoomableImageSource: (FileInfo, String) -> CipherZoomableImageSource,
+ createMediaDataSource: (FileInfo) -> MediaDataSource,
+ imagesFitScreen: Boolean,
+ initialFileId: String,
+ navigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val activity = LocalActivity.current
+
+ // TODO: Use `Saveable`. Needs to be "synced" with MediaView controls.
+ val isTopBarVisible = remember { mutableStateOf(true) }
+ var topBarTitle by rememberSaveable { mutableStateOf("") }
+
+ DisposableEffect(isTopBarVisible.value) {
+ val window = activity?.window ?: return@DisposableEffect onDispose {}
+ val insetsController = WindowCompat.getInsetsController(window, window.decorView)
+
+ insetsController.apply {
+ if (!isTopBarVisible.value) {
+ hide(WindowInsetsCompat.Type.statusBars())
+ hide(WindowInsetsCompat.Type.navigationBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ onDispose {
+ insetsController.apply {
+ show(WindowInsetsCompat.Type.statusBars())
+ show(WindowInsetsCompat.Type.navigationBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ AnimatedVisibility(
+ visible = isTopBarVisible.value,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier.zIndex(1f)
+ ) {
+ TopAppBar(
+ windowInsets = WindowInsets.safeDrawingTopAppBar,
+ title = {
+ Text(
+ text = topBarTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = navigateBack) {
+ Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
+ }
+ }
+ )
+ }
+ },
+ contentWindowInsets = if (isTopBarVisible.value) ScaffoldDefaults.contentWindowInsets else WindowInsets(0),
+ modifier = modifier.fillMaxSize() // https://stackoverflow.com/a/76916130
+ ) { _ ->
+ CipherFilePager(
+ itemsData = itemsData,
+ createCipherZoomableImageSource = createCipherZoomableImageSource,
+ createMediaDataSource = createMediaDataSource,
+ initialFileId = initialFileId,
+ imagesFitScreen = imagesFitScreen,
+ isTopBarVisible = isTopBarVisible,
+ switchTopBarVisibility = { isTopBarVisible.value = !isTopBarVisible.value },
+ updateTopBarTitle = { topBarTitle = it }
+ )
+ }
+}
+
+@Composable
+private fun CipherFilePager(
+ itemsData: PresenterViewModel.ItemsData,
+ createCipherZoomableImageSource: (FileInfo, String) -> CipherZoomableImageSource,
+ createMediaDataSource: (FileInfo) -> MediaDataSource,
+ initialFileId: String,
+ imagesFitScreen: Boolean,
+ isTopBarVisible: State,
+ switchTopBarVisibility: () -> Unit,
+ updateTopBarTitle: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (itemsData.cipherFileEntities.isEmpty()) {
+ return
+ }
+
+ val pagerState = rememberPagerState(
+ initialPage = itemsData.cipherFileEntities.indexOfFirst { item -> item.id == initialFileId },
+ pageCount = { itemsData.cipherFileEntities.size }
+ )
+
+ LaunchedEffect(pagerState, updateTopBarTitle) {
+ snapshotFlow { pagerState.currentPage }.collect { index ->
+ val fileFullName = itemsData.cipherFileEntities[index].fullName()
+ updateTopBarTitle("${index + 1}/${pagerState.pageCount} - $fileFullName")
+ }
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ key = { index -> itemsData.cipherFileEntities[index].id },
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) { index ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (itemsData.physicalFiles == null) {
+ CircularProgressIndicator(modifier = Modifier.align(alignment = Alignment.Center))
+ } else {
+ val cipherFileEntity = remember(itemsData, index) { itemsData.cipherFileEntities[index] }
+ val physicalFile = remember(cipherFileEntity) { itemsData.physicalFiles[cipherFileEntity.id] }
+
+ if (physicalFile == null) {
+ FileNotFoundContentView(cipherFileEntity.fullName())
+ } else {
+ CipherFilePresenter(
+ fileName = cipherFileEntity.fullName(),
+ mimeType = cipherFileEntity.mimeType,
+ createCipherZoomableImageSource = {
+ createCipherZoomableImageSource(physicalFile, cipherFileEntity.mimeType)
+ },
+ createMediaDataSource = { createMediaDataSource(physicalFile) },
+ isTopBarVisible = isTopBarVisible,
+ switchTopBarVisibility = switchTopBarVisibility,
+ imagesFitScreen = imagesFitScreen,
+ modifier = Modifier
+ .fillMaxSize()
+ .align(Alignment.Center)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CipherFilePresenter(
+ fileName: String,
+ mimeType: String,
+ createCipherZoomableImageSource: () -> CipherZoomableImageSource,
+ createMediaDataSource: () -> MediaDataSource,
+ isTopBarVisible: State,
+ switchTopBarVisibility: () -> Unit,
+ imagesFitScreen: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ if (mimeType.startsWith("image/")) {
+ val imageSource = remember(fileName, mimeType) {
+ createCipherZoomableImageSource()
+ }
+
+ ZoomableImage(
+ image = imageSource,
+ contentDescription = null,
+ contentScale = if (imagesFitScreen) ContentScale.Fit else ContentScale.Inside,
+ onClick = { switchTopBarVisibility() },
+ modifier = modifier
+ )
+ } else if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) {
+ val mediaDataSource = remember(fileName) { createMediaDataSource() }
+
+ VideoContentView(
+ mediaDataSource = mediaDataSource,
+ isTopBarVisible = isTopBarVisible,
+ switchTopBarVisibility = switchTopBarVisibility,
+ modifier = modifier
+ )
+ } else {
+ NotSupportedContentView(fileName)
+ }
+}
+
+@PagePreviews
+@Composable
+private fun PresenterViewPreview() = PreviewHost {
+ PresenterView(
+ itemsData = PresenterViewModel.ItemsData(
+ cipherFileEntities = PreviewSampleData.cipherFileEntities
+ ),
+ createCipherZoomableImageSource = { _, _ -> error("Not implemented") },
+ createMediaDataSource = { error("Not implemented") },
+ imagesFitScreen = true,
+ initialFileId = "file0",
+ navigateBack = { }
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterViewModel.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterViewModel.kt
new file mode 100644
index 0000000..b74fd89
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/PresenterViewModel.kt
@@ -0,0 +1,100 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.presenter
+
+import android.content.Context
+import android.media.MediaDataSource
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import coil.ImageLoader
+import coil.decode.GifDecoder
+import coil.decode.SvgDecoder
+import coil.request.CachePolicy
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.lukaspieper.truvark.common.data.io.FileInfo
+import de.lukaspieper.truvark.common.domain.entities.CipherFileEntity
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.domain.crypto.decryption.DecryptingFileHandle
+import de.lukaspieper.truvark.domain.crypto.decryption.FileHandleMediaDataSource
+import de.lukaspieper.truvark.domain.crypto.decryption.coil.CipherFileFetcher
+import de.lukaspieper.truvark.domain.crypto.decryption.coil.CipherZoomableImageSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+@HiltViewModel(assistedFactory = PresenterViewModel.Factory::class)
+class PresenterViewModel @AssistedInject constructor(
+ private val preferences: PersistentPreferences,
+ private val vault: Vault,
+ @ApplicationContext private val appContext: Context,
+ @Assisted private val folderId: String,
+) : ViewModel() {
+
+ private val imageLoader by lazy {
+ ImageLoader.Builder(appContext)
+ .components {
+ add(CipherFileFetcher.Factory(appContext, vault))
+
+ add(SvgDecoder.Factory())
+ add(GifDecoder.Factory())
+ }
+ .diskCachePolicy(CachePolicy.DISABLED)
+ .memoryCachePolicy(CachePolicy.ENABLED)
+ .build()
+ }
+
+ val itemsData = MutableStateFlow(ItemsData())
+
+ val imagesFitScreen = preferences.imagesFitScreen
+
+ init {
+ // TODO: Should the flow be collected here? How to update the data without interrupting the user?
+ viewModelScope.launch(Dispatchers.IO) {
+ val cipherFileEntities = vault.findCipherFileEntitiesForFolder(folderId).first()
+ itemsData.update { it.copy(cipherFileEntities = cipherFileEntities) }
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val physicalFilesById = vault.fileSystem.fetchFilesFromCipherDirectory(folderId)
+ .associateBy { it.fullName }
+
+ itemsData.update { it.copy(physicalFiles = physicalFilesById) }
+ }
+ }
+
+ internal fun createCipherZoomableImageSource(fileInfo: FileInfo, mimeType: String): CipherZoomableImageSource {
+ return CipherZoomableImageSource(fileInfo, mimeType, vault, appContext.contentResolver, imageLoader)
+ }
+
+ internal fun createMediaDataSource(fileInfo: FileInfo): MediaDataSource {
+ return FileHandleMediaDataSource(
+ // TODO: Make this FileSystem-agnostic, e.g. by splitting file access and decryption and adding a method
+ // returning a FileHandle
+ DecryptingFileHandle(appContext.contentResolver, vault, fileInfo.uri as Uri)
+ )
+ }
+
+ @Immutable
+ data class ItemsData(
+ val cipherFileEntities: List = emptyList(),
+ val physicalFiles: Map? = null
+ )
+
+ @AssistedFactory
+ interface Factory {
+ fun create(folderId: String): PresenterViewModel
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/FileNotFoundContentView.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/FileNotFoundContentView.kt
new file mode 100644
index 0000000..10a8339
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/FileNotFoundContentView.kt
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.presenter.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ManageSearch
+import androidx.compose.material3.Icon
+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.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.preview.ElementPreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.DarkColorScheme
+
+@Composable
+fun FileNotFoundContentView(
+ fileName: String,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ManageSearch,
+ contentDescription = null,
+ tint = DarkColorScheme.onBackground,
+ modifier = Modifier
+ .requiredSize(100.dp)
+ .padding(bottom = 16.dp)
+ )
+
+ Text(
+ stringResource(R.string.encrypted_file_not_found),
+ textAlign = TextAlign.Center,
+ color = DarkColorScheme.onBackground
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = fileName,
+ fontStyle = FontStyle.Italic,
+ color = DarkColorScheme.onBackground
+ )
+ }
+}
+
+@ElementPreviews
+@Composable
+private fun FileNotFoundContentViewPreview() = PreviewHost(backgroundColor = Color.Black) {
+ FileNotFoundContentView("example.txt")
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/NotSupportedContentView.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/NotSupportedContentView.kt
new file mode 100644
index 0000000..1ef4e2a
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/NotSupportedContentView.kt
@@ -0,0 +1,70 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.presenter.views
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ImageNotSupported
+import androidx.compose.material3.Icon
+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.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.preview.ElementPreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.DarkColorScheme
+
+@Composable
+fun NotSupportedContentView(
+ fileName: String,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Default.ImageNotSupported,
+ contentDescription = null,
+ tint = DarkColorScheme.onBackground,
+ modifier = Modifier
+ .requiredSize(100.dp)
+ .padding(bottom = 16.dp)
+ )
+
+ Text(
+ stringResource(R.string.file_not_supported_info),
+ textAlign = TextAlign.Center,
+ color = DarkColorScheme.onBackground,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = fileName,
+ fontStyle = FontStyle.Italic,
+ color = DarkColorScheme.onBackground,
+ )
+ }
+}
+
+@ElementPreviews
+@Composable
+private fun NotSupportedContentViewPreview() = PreviewHost(backgroundColor = Color.Black) {
+ NotSupportedContentView("example.jpg")
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/VideoContentView.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/VideoContentView.kt
new file mode 100644
index 0000000..224abf3
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/presenter/views/VideoContentView.kt
@@ -0,0 +1,80 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.presenter.views
+
+import android.media.MediaDataSource
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import de.lukaspieper.truvark.ui.controls.mediaplayer.MediaPlayerState
+import de.lukaspieper.truvark.ui.controls.mediaplayer.MediaView
+
+@Composable
+fun VideoContentView(
+ mediaDataSource: MediaDataSource,
+ isTopBarVisible: State,
+ switchTopBarVisibility: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val mediaPlayerState by remember(mediaDataSource) {
+ mutableStateOf(
+ MediaPlayerState(
+ mediaDataSource = mediaDataSource,
+ coroutineScope = coroutineScope,
+ areControlsVisible = isTopBarVisible
+ )
+ )
+ }
+
+ var centerX by remember { mutableFloatStateOf(0F) }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .onSizeChanged { centerX = it.width / 2F }
+ .simpleTapGesture(switchTopBarVisibility, mediaPlayerState, centerX)
+ ) {
+ MediaView(
+ state = mediaPlayerState,
+ modifier = modifier
+ )
+ }
+}
+
+private fun Modifier.simpleTapGesture(
+ switchTopBarVisibility: () -> Unit,
+ playerState: MediaPlayerState? = null,
+ centerX: Float? = null
+): Modifier {
+ return this.pointerInput(centerX) {
+ detectTapGestures(
+ onDoubleTap = {
+ if (playerState != null && centerX != null) {
+ if (it.x > centerX) {
+ playerState.forward()
+ } else {
+ playerState.rewind()
+ }
+ }
+ },
+ onTap = {
+ switchTopBarVisibility()
+ }
+ )
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsHomePage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsHomePage.kt
new file mode 100644
index 0000000..befa59f
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsHomePage.kt
@@ -0,0 +1,238 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.AppSettingsAlt
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.Copyright
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.PrivacyTip
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.ui.controls.SafeDrawingListDetailPaneScaffold
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.settings.app.AppSettingsPage
+import de.lukaspieper.truvark.ui.views.settings.licensing.OpenSourceLicensePage
+import de.lukaspieper.truvark.ui.views.settings.vault.VaultSettingsPage
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun SettingsHomePage(
+ navigateBack: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val settingsMenuItems = mapOf(
+ SettingsMenuItem.Key.VAULT to SettingsMenuItem.Internal(
+ Icons.Default.Lock, R.string.vault, R.string.settings_description_vault
+ ),
+ SettingsMenuItem.Key.APP to SettingsMenuItem.Internal(
+ Icons.Default.AppSettingsAlt, R.string.app, R.string.settings_description_app
+ ),
+ SettingsMenuItem.Key.OSS_LICENSES to SettingsMenuItem.Internal(
+ Icons.Default.Copyright, R.string.settings_licensing
+ ),
+ SettingsMenuItem.Key.SOURCE_CODE to SettingsMenuItem.External(
+ Icons.Default.Code, R.string.source_code, null, Uri.parse("https://github.com/lukaspieper/Truvark")
+ ),
+ SettingsMenuItem.Key.PRIVACY_POLICY to SettingsMenuItem.External(
+ Icons.Default.PrivacyTip,
+ R.string.privacy_policy,
+ null,
+ Uri.parse("https://truvark.lukaspieper.de/en/privacy.html")
+ )
+ )
+
+ val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator()
+ val selectedMenuItem by remember(scaffoldNavigator.currentDestination) {
+ derivedStateOf { settingsMenuItems[scaffoldNavigator.currentDestination?.contentKey] }
+ }
+
+ LaunchedEffect(scaffoldNavigator) {
+ // Do initial navigation when both panes are visible. Note that primary maps to the detail pane.
+ val detailPaneState = scaffoldNavigator.scaffoldState.currentState.primary
+ if (scaffoldNavigator.currentDestination?.contentKey == null && detailPaneState == PaneAdaptedValue.Expanded) {
+ scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, SettingsMenuItem.Key.VAULT)
+ }
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+ SafeDrawingListDetailPaneScaffold(
+ scaffoldNavigator = scaffoldNavigator,
+ modifier = modifier,
+ listPaneTopAppBarTitle = stringResource(R.string.settings),
+ listPaneTopAppBarNavigationIcon = {
+ IconButton(
+ onClick = navigateBack,
+ content = { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
+ )
+ },
+ listPaneContent = { contentPadding ->
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ items(
+ items = settingsMenuItems.keys.toList(),
+ key = { key -> key }
+ ) { key ->
+ val settingsMenuItem by remember(key) { derivedStateOf { settingsMenuItems.getValue(key) } }
+ val isSelected by remember(settingsMenuItem, selectedMenuItem) {
+ derivedStateOf { settingsMenuItem == selectedMenuItem }
+ }
+ val onClick: () -> Unit = {
+ when (settingsMenuItem) {
+ is SettingsMenuItem.Internal -> {
+ coroutineScope.launch {
+ scaffoldNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail, key)
+ }
+ }
+
+ is SettingsMenuItem.External -> {
+ try {
+ val browserIntent = Intent(
+ Intent.ACTION_VIEW,
+ (settingsMenuItem as SettingsMenuItem.External).uri
+ )
+ context.startActivity(browserIntent, Bundle.EMPTY)
+ } catch (e: Exception) {
+ logcat("SettingsHomePage", LogPriority.WARN) { e.asLog() }
+ }
+ }
+ }
+ }
+
+ SettingsButton(
+ icon = settingsMenuItem.icon,
+ title = stringResource(settingsMenuItem.title),
+ description = settingsMenuItem.description?.let { stringResource(it) },
+ isExternal = settingsMenuItem is SettingsMenuItem.External,
+ isSelected = isSelected,
+ onClick = onClick
+ )
+ }
+ }
+ },
+ detailPaneContent = { contentPadding ->
+ scaffoldNavigator.currentDestination?.contentKey?.let { settingsMenuItem ->
+ when (settingsMenuItem) {
+ SettingsMenuItem.Key.VAULT -> VaultSettingsPage(modifier = Modifier.padding(contentPadding))
+ SettingsMenuItem.Key.APP -> AppSettingsPage(modifier = Modifier.padding(contentPadding))
+ SettingsMenuItem.Key.OSS_LICENSES -> OpenSourceLicensePage(
+ modifier = Modifier.padding(contentPadding)
+ )
+
+ else -> {
+ // Show nothing.
+ }
+ }
+ }
+ },
+ detailPaneTopAppBarTitle = selectedMenuItem?.let { stringResource(it.title) } ?: "",
+ detailPaneTopAppBarNavigationIcon = {
+ IconButton(
+ onClick = { coroutineScope.launch { scaffoldNavigator.navigateBack() } },
+ content = { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
+ )
+ }
+ )
+}
+
+@Composable
+fun SettingsButton(
+ icon: ImageVector,
+ title: String,
+ description: String?,
+ isExternal: Boolean,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = onClick,
+ enabled = !isSelected,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ ),
+ shape = CardDefaults.shape,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Icon(icon, null)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = MaterialTheme.paddings.large)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ if (description != null) {
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ if (isExternal) {
+ Icon(Icons.AutoMirrored.Filled.OpenInNew, null)
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun SettingsViewPreview() = PreviewHost {
+ SettingsHomePage(navigateBack = {})
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsMenuItem.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsMenuItem.kt
new file mode 100644
index 0000000..b1b6802
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/SettingsMenuItem.kt
@@ -0,0 +1,38 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings
+
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.compose.ui.graphics.vector.ImageVector
+
+sealed interface SettingsMenuItem {
+ val icon: ImageVector
+ val title: Int
+ val description: Int?
+
+ enum class Key {
+ VAULT,
+ APP,
+ OSS_LICENSES,
+ PRIVACY_POLICY,
+ SOURCE_CODE,
+ }
+
+ data class Internal(
+ override val icon: ImageVector,
+ @StringRes override val title: Int,
+ @StringRes override val description: Int? = null
+ ) : SettingsMenuItem
+
+ data class External(
+ override val icon: ImageVector,
+ @StringRes override val title: Int,
+ @StringRes override val description: Int? = null,
+ val uri: Uri
+ ) : SettingsMenuItem
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsPage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsPage.kt
new file mode 100644
index 0000000..db6a55f
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsPage.kt
@@ -0,0 +1,75 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.app
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.controls.LabeledSwitch
+import de.lukaspieper.truvark.ui.preview.DetailPanePreviewHost
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.theme.paddings
+
+@Composable
+fun AppSettingsPage(
+ modifier: Modifier = Modifier,
+ viewModel: AppSettingsViewModel = hiltViewModel()
+) {
+ val imagesFitScreen = viewModel.imagesFitScreen.collectAsStateWithLifecycle(false)
+ val isLoggingEnabled = viewModel.isLoggingEnabled.collectAsStateWithLifecycle(false)
+
+ AppSettingsView(
+ imagesFitScreen = imagesFitScreen.value,
+ updateImagesFitScreen = viewModel::applyImagesFitScreen,
+ isLoggingEnabled = isLoggingEnabled.value,
+ updateIsLoggingEnabled = viewModel::applyLogging,
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun AppSettingsView(
+ imagesFitScreen: Boolean,
+ updateImagesFitScreen: (Boolean) -> Unit,
+ isLoggingEnabled: Boolean,
+ updateIsLoggingEnabled: (Boolean) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier = modifier) {
+ LabeledSwitch(
+ text = stringResource(R.string.fit_images_to_match_screen),
+ checked = imagesFitScreen,
+ onCheckedChange = updateImagesFitScreen,
+ modifier = Modifier.padding(horizontal = MaterialTheme.paddings.small)
+ )
+
+ LabeledSwitch(
+ text = stringResource(R.string.logging),
+ checked = isLoggingEnabled,
+ onCheckedChange = updateIsLoggingEnabled,
+ modifier = Modifier.padding(horizontal = MaterialTheme.paddings.small)
+ )
+ }
+}
+
+@PagePreviews
+@Composable
+private fun AppSettingsViewPreview() = DetailPanePreviewHost { contentPadding ->
+ AppSettingsView(
+ imagesFitScreen = false,
+ updateImagesFitScreen = {},
+ isLoggingEnabled = false,
+ updateIsLoggingEnabled = {},
+ modifier = Modifier.padding(contentPadding)
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsViewModel.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsViewModel.kt
new file mode 100644
index 0000000..2b0bf2c
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/app/AppSettingsViewModel.kt
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.app
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.lukaspieper.truvark.common.logging.LogcatLogger
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.logging.AndroidLogcatLogger
+import kotlinx.coroutines.runBlocking
+import javax.inject.Inject
+
+@HiltViewModel
+class AppSettingsViewModel @Inject constructor(
+ private val preferences: PersistentPreferences
+) : ViewModel() {
+ val isLoggingEnabled = preferences.loggingAllowed
+ val imagesFitScreen = preferences.imagesFitScreen
+
+ fun applyLogging(enabled: Boolean) = runBlocking {
+ if (enabled) {
+ AndroidLogcatLogger.installWithDefaultPriority()
+ } else {
+ LogcatLogger.uninstall()
+ }
+
+ preferences.saveLoggingAllowed(enabled)
+ }
+
+ fun applyImagesFitScreen(enabled: Boolean) = runBlocking {
+ preferences.saveImagesFitScreen(enabled)
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/License.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/License.kt
new file mode 100644
index 0000000..1b10c9d
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/License.kt
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.licensing
+
+import de.lukaspieper.truvark.R
+
+sealed class License(
+ val name: String,
+ val textResId: Int?
+) {
+ companion object {
+ // All uris listed in the generated file `third_party_licenses` should be a key in this map
+ private val licenseByUri = mapOf(
+ "http://www.apache.org/licenses/LICENSE-2.0.txt" to ApacheLicenseV20,
+ "https://www.apache.org/licenses/LICENSE-2.0.txt" to ApacheLicenseV20,
+ "https://www.apache.org/licenses/LICENSE-2.0" to ApacheLicenseV20,
+ "https://github.com/lambdapioneer/argon2kt/blob/master/LICENSE" to MitLicense
+ )
+
+ fun getByUri(uri: String): License {
+ return licenseByUri[uri] ?: UnknownLicense
+ }
+ }
+
+ data object UnknownLicense : License(
+ name = "Unknown license",
+ textResId = null
+ )
+
+ data object ApacheLicenseV20 : License(
+ name = "Apache License Version 2.0",
+ textResId = R.raw.apache_2_0
+ )
+
+ data object MitLicense : License(
+ name = "MIT License",
+ textResId = R.raw.mit
+ )
+
+ data object GeneralPublicLicenseV30OrLater : License(
+ name = "GNU General Public License",
+ textResId = R.raw.gpl_3_0_or_later
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/LicensedItem.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/LicensedItem.kt
new file mode 100644
index 0000000..554449d
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/LicensedItem.kt
@@ -0,0 +1,9 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.licensing
+
+class LicensedItem(val id: String, val license: License)
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/OpenSourceLicensePage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/OpenSourceLicensePage.kt
new file mode 100644
index 0000000..4ea00ff
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/licensing/OpenSourceLicensePage.kt
@@ -0,0 +1,206 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.licensing
+
+import android.content.res.Resources
+import androidx.annotation.RawRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.layout.sizeIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+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.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.preview.DetailPanePreviewHost
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.settings.licensing.License.GeneralPublicLicenseV30OrLater
+
+@Composable
+fun OpenSourceLicensePage(
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val licensedItems by produceState(initialValue = emptyList()) {
+ value = fetchLicenseItems(context.resources)
+ }
+
+ OpenSourceLicenseView(
+ licensedItems = licensedItems,
+ modifier = modifier
+ )
+}
+
+private fun fetchLicenseItems(resources: Resources): List {
+ val metadata = resources.readRawStringResource(R.raw.third_party_license_metadata)
+ val licenseUris = resources.readRawStringResource(R.raw.third_party_licenses)
+
+ val undetectedLicensedItems = listOf(
+ LicensedItem("logcat", License.ApacheLicenseV20)
+ )
+
+ val metadataRegex = Regex("^(\\d+):(\\d+)\\s(.+)\$")
+ return metadata.lineSequence()
+ .mapNotNull { line -> metadataRegex.matchEntire(line) }
+ .map { matchResult ->
+ val (uriStartIndex, uriLength, itemId) = matchResult.destructured
+ val licenseUri = licenseUris.substring(uriStartIndex.toInt(), uriStartIndex.toInt() + uriLength.toInt())
+ LicensedItem(itemId, License.getByUri(licenseUri))
+ }
+ .plus(undetectedLicensedItems)
+ .distinctBy { it.id.lowercase() }
+ .sortedBy { it.id.lowercase() }
+ .toList()
+}
+
+private fun Resources.readRawStringResource(@RawRes id: Int): String {
+ openRawResource(id).use { inputStream ->
+ return inputStream.bufferedReader().readText()
+ }
+}
+
+@Composable
+fun OpenSourceLicenseView(
+ licensedItems: List,
+ modifier: Modifier = Modifier
+) {
+ var selectedLicense by remember { mutableStateOf(License.UnknownLicense) }
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.paddings.small),
+ modifier = modifier.fillMaxSize()
+ ) {
+ item {
+ Card(
+ onClick = { selectedLicense = GeneralPublicLicenseV30OrLater },
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(Modifier.padding(all = MaterialTheme.paddings.small)) {
+ Text(
+ text = stringResource(R.string.app_name),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = GeneralPublicLicenseV30OrLater.name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.size(MaterialTheme.paddings.extraLarge))
+ }
+
+ items(licensedItems) { licenseItem ->
+ Card(
+ enabled = licenseItem.license != License.UnknownLicense,
+ onClick = { selectedLicense = licenseItem.license },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(Modifier.padding(all = MaterialTheme.paddings.small)) {
+ Text(
+ text = licenseItem.id,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = licenseItem.license.name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ item {
+ Spacer(Modifier.size(8.dp))
+ }
+ }
+
+ if (selectedLicense != License.UnknownLicense) {
+ val context = LocalContext.current
+ val licenseText by produceState(initialValue = "") {
+ selectedLicense.textResId?.let { textResId ->
+ value = context.resources.readRawStringResource(textResId)
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { selectedLicense = License.UnknownLicense },
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Card(
+ shape = MaterialTheme.shapes.extraLarge,
+ modifier = Modifier
+ .padding(MaterialTheme.paddings.small)
+ .sizeIn(maxWidth = 600.dp)
+ ) {
+ Box(Modifier.padding(horizontal = MaterialTheme.paddings.small)) {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ Text(
+ text = licenseText,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = MaterialTheme.paddings.medium, bottom = 72.dp)
+ )
+ }
+
+ FloatingActionButton(
+ onClick = { selectedLicense = License.UnknownLicense },
+ modifier = Modifier
+ .align(alignment = Alignment.BottomEnd)
+ .padding(bottom = MaterialTheme.paddings.small)
+ ) {
+ Icon(Icons.Default.Close, contentDescription = null)
+ }
+ }
+ }
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun OpenSourceLicenseViewPreview() = DetailPanePreviewHost { contentPadding ->
+ val licensedItems = List(10) {
+ LicensedItem("Item $it", License.UnknownLicense)
+ }
+
+ OpenSourceLicenseView(
+ licensedItems = licensedItems,
+ modifier = Modifier.padding(contentPadding)
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/BiometricsView.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/BiometricsView.kt
new file mode 100644
index 0000000..ed36225
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/BiometricsView.kt
@@ -0,0 +1,175 @@
+/*
+ * SPDX-FileCopyrightText: 2024 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.vault
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.biometric.auth.authenticateWithClass3Biometrics
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+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.foundation.layout.requiredSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.ui.controls.PasswordField
+import de.lukaspieper.truvark.ui.preview.ElementPreviews
+import de.lukaspieper.truvark.ui.preview.PreviewHost
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.settings.vault.VaultSettingsViewModel.BiometricSetupResult
+import kotlinx.coroutines.launch
+import javax.crypto.Cipher
+
+@Composable
+fun BiometricsView(
+ biometricsStatus: Int,
+ isVaultUsingBiometricUnlocking: Boolean,
+ setupBiometricUnlocking: suspend (ByteArray) -> BiometricSetupResult,
+ modifier: Modifier = Modifier
+) {
+ if (biometricsStatus == BiometricManager.BIOMETRIC_SUCCESS) {
+ Column(
+ verticalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = modifier
+ ) {
+ Text(
+ text = stringResource(R.string.biometric_unlocking),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ if (isVaultUsingBiometricUnlocking) {
+ ActiveBiometricsIndicator()
+ }
+
+ Text(
+ text = stringResource(R.string.setup_biometrics_description),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Justify
+ )
+
+ SetupBiometricUnlockingView(setupBiometricUnlocking)
+ }
+ }
+}
+
+@Composable
+private fun SetupBiometricUnlockingView(
+ setupBiometricUnlocking: suspend (ByteArray) -> BiometricSetupResult,
+ modifier: Modifier = Modifier
+) {
+ val coroutineScope = rememberCoroutineScope()
+ var password by remember { mutableStateOf("") }
+
+ var setupResult by remember { mutableStateOf(null) }
+ val isPasswordIncorrect by remember(setupResult) {
+ derivedStateOf { setupResult == BiometricSetupResult.INVALID_PASSWORD }
+ }
+
+ LaunchedEffect(setupResult) {
+ if (setupResult == BiometricSetupResult.SUCCESS) {
+ password = ""
+ }
+ }
+
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = modifier.fillMaxWidth()
+ ) {
+ PasswordField(
+ value = password,
+ onValueChange = { password = it },
+ label = R.string.confirm_vaults_password,
+ onKeyboardDone = {
+ coroutineScope.launch {
+ setupResult = setupBiometricUnlocking(password.toByteArray())
+ }
+ },
+ passwordIsIncorrect = isPasswordIncorrect,
+ modifier = Modifier.weight(1f)
+ )
+
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ setupResult = setupBiometricUnlocking(password.toByteArray())
+ }
+ },
+ modifier = Modifier.height(TextFieldDefaults.MinHeight)
+ ) {
+ Icon(Icons.Default.LockOpen, null)
+ }
+ }
+}
+
+@Composable
+private fun ActiveBiometricsIndicator() {
+ Card(Modifier.fillMaxWidth()) {
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(MaterialTheme.paddings.medium)
+ ) {
+ Icon(
+ Icons.Outlined.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.requiredSize(32.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.vault_using_biometric_unlocking),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
+
+suspend fun authenticateCryptoObject(cryptoObject: BiometricPrompt.CryptoObject, activity: FragmentActivity): Cipher? {
+ val result = activity.authenticateWithClass3Biometrics(
+ crypto = cryptoObject,
+ title = activity.getString(R.string.setup_biometrics),
+ negativeButtonText = activity.getString(R.string.cancel_setup),
+ description = activity.getString(R.string.biometric_prompt_setup_description)
+ )
+
+ return result.cryptoObject?.cipher
+}
+
+@ElementPreviews
+@Composable
+private fun BiometricsViewPreview() = PreviewHost(Modifier) {
+ BiometricsView(
+ biometricsStatus = BiometricManager.BIOMETRIC_SUCCESS,
+ isVaultUsingBiometricUnlocking = true,
+ setupBiometricUnlocking = { BiometricSetupResult.SUCCESS }
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsPage.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsPage.kt
new file mode 100644
index 0000000..d450f4b
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsPage.kt
@@ -0,0 +1,183 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.vault
+
+import androidx.activity.compose.LocalActivity
+import androidx.biometric.BiometricManager
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+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.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.constants.FixedValues.MAX_VAULT_NAME_LENGTH
+import de.lukaspieper.truvark.ui.preview.DetailPanePreviewHost
+import de.lukaspieper.truvark.ui.preview.PagePreviews
+import de.lukaspieper.truvark.ui.theme.paddings
+import de.lukaspieper.truvark.ui.views.settings.vault.VaultSettingsViewModel.BiometricSetupResult
+
+@Composable
+fun VaultSettingsPage(
+ modifier: Modifier = Modifier,
+ viewModel: VaultSettingsViewModel = hiltViewModel()
+) {
+ val activity = LocalActivity.current as FragmentActivity
+ val biometricsStatus = remember { viewModel.checkBiometricSupport() }
+ val isVaultUsingBiometricUnlocking by viewModel.isVaultUsingBiometricUnlocking
+ .collectAsStateWithLifecycle(false)
+
+ VaultSettingsSections(
+ vaultName = viewModel.vaultName,
+ updateVaultName = viewModel::updateVaultName,
+ biometricsStatus = biometricsStatus,
+ isVaultUsingBiometricUnlocking = isVaultUsingBiometricUnlocking,
+ setupBiometricUnlocking = { password ->
+ viewModel.setupBiometricUnlocking(
+ password = password,
+ authenticateCryptoObject = { cryptoObject ->
+ authenticateCryptoObject(cryptoObject, activity)
+ }
+ )
+ },
+ modifier = modifier
+ )
+}
+
+@Composable
+fun VaultSettingsSections(
+ vaultName: String,
+ updateVaultName: (String) -> Boolean,
+ biometricsStatus: Int,
+ isVaultUsingBiometricUnlocking: Boolean,
+ setupBiometricUnlocking: suspend (ByteArray) -> BiometricSetupResult,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ verticalArrangement = spacedBy(48.dp),
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ VaultName(
+ vaultName = vaultName,
+ updateVaultName = updateVaultName
+ )
+
+ BiometricsView(
+ biometricsStatus = biometricsStatus,
+ isVaultUsingBiometricUnlocking = isVaultUsingBiometricUnlocking,
+ setupBiometricUnlocking = setupBiometricUnlocking
+ )
+ }
+}
+
+@Composable
+fun VaultName(
+ vaultName: String,
+ updateVaultName: (String) -> Boolean,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ verticalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = modifier
+ ) {
+ Text(
+ text = stringResource(R.string.vault_name),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ Text(
+ text = stringResource(R.string.vault_name_change_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Justify
+ )
+
+ Row(
+ horizontalArrangement = spacedBy(MaterialTheme.paddings.medium),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ var editableVaultName by rememberSaveable(vaultName) { mutableStateOf(vaultName) }
+ var isInputValid by rememberSaveable { mutableStateOf(true) }
+ val isVaultNameEdited by remember(vaultName, editableVaultName) {
+ derivedStateOf { editableVaultName != vaultName }
+ }
+
+ TextField(
+ value = editableVaultName,
+ onValueChange = {
+ if (it.length <= MAX_VAULT_NAME_LENGTH) {
+ editableVaultName = it
+ }
+ },
+ label = { Text(stringResource(R.string.vault_name)) },
+ singleLine = true,
+ isError = isInputValid.not(),
+ supportingText = {
+ Text(
+ text = "${editableVaultName.length}/${MAX_VAULT_NAME_LENGTH}",
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.End,
+ )
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { isInputValid = updateVaultName(editableVaultName) }),
+ modifier = Modifier.weight(1f)
+ )
+
+ Button(
+ onClick = { isInputValid = updateVaultName(editableVaultName) },
+ enabled = isVaultNameEdited,
+ modifier = Modifier.height(TextFieldDefaults.MinHeight)
+ ) {
+ Icon(if (isVaultNameEdited) Icons.Default.Save else Icons.Default.Done, null)
+ }
+ }
+ }
+}
+
+@PagePreviews
+@Composable
+private fun VaultSettingsSectionsPreview() = DetailPanePreviewHost { contentPadding ->
+ VaultSettingsSections(
+ vaultName = "Preview vault",
+ updateVaultName = { true },
+ biometricsStatus = BiometricManager.BIOMETRIC_SUCCESS,
+ isVaultUsingBiometricUnlocking = false,
+ setupBiometricUnlocking = { BiometricSetupResult.SUCCESS },
+ modifier = Modifier.padding(contentPadding)
+ )
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsViewModel.kt b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsViewModel.kt
new file mode 100644
index 0000000..4ee5045
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/ui/views/settings/vault/VaultSettingsViewModel.kt
@@ -0,0 +1,86 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.ui.views.settings.vault
+
+import androidx.biometric.BiometricPrompt
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.common.domain.vault.VaultFactory
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.data.preferences.PersistentPreferences
+import de.lukaspieper.truvark.domain.crypto.BiometricConfig
+import de.lukaspieper.truvark.domain.crypto.BiometricCryptoProvider
+import kotlinx.coroutines.flow.map
+import javax.crypto.Cipher
+import javax.inject.Inject
+
+@HiltViewModel
+class VaultSettingsViewModel @Inject constructor(
+ private val vaultFactory: VaultFactory,
+ private val vault: Vault,
+ private val biometricCryptoProvider: BiometricCryptoProvider,
+ private val preferences: PersistentPreferences
+) : ViewModel() {
+ val isVaultUsingBiometricUnlocking = preferences.biometricConfig.map { it?.vaultId == vault.id }
+
+ var vaultName by mutableStateOf(vault.displayName)
+ private set
+
+ fun updateVaultName(name: String): Boolean {
+ try {
+ vault.updateDisplayName(name)
+ vaultName = vault.displayName
+ return true
+ } catch (_: Exception) {
+ // Not logging here because serious errors are already logged at this time.
+ return false
+ }
+ }
+
+ fun checkBiometricSupport(): Int {
+ return biometricCryptoProvider.checkBiometricSupport()
+ }
+
+ suspend fun setupBiometricUnlocking(
+ password: ByteArray,
+ authenticateCryptoObject: suspend (BiometricPrompt.CryptoObject) -> Cipher?
+ ): BiometricSetupResult {
+ try {
+ if (!vaultFactory.validatePassword(vault, password)) {
+ return BiometricSetupResult.INVALID_PASSWORD
+ }
+
+ val cryptoObject = biometricCryptoProvider.createEncryptingPromptObject()
+ val cipher = authenticateCryptoObject(cryptoObject)
+ checkNotNull(cipher) { "Authentication failed" }
+
+ val config = BiometricConfig(
+ vaultId = vault.id,
+ iv = cipher.iv,
+ accessKey = cipher.doFinal(password)
+ )
+ preferences.saveBiometricConfig(config)
+
+ return BiometricSetupResult.SUCCESS
+ } catch (e: Exception) {
+ logcat(LogPriority.WARN) { e.asLog() }
+ return BiometricSetupResult.UNKNOWN_ERROR
+ }
+ }
+
+ enum class BiometricSetupResult {
+ SUCCESS,
+ INVALID_PASSWORD,
+ UNKNOWN_ERROR
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/work/DatabaseSyncingWorker.kt b/android/src/main/java/de/lukaspieper/truvark/work/DatabaseSyncingWorker.kt
new file mode 100644
index 0000000..16b42a7
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/work/DatabaseSyncingWorker.kt
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.work
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.lukaspieper.truvark.common.constants.FileNames
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.common.logging.LogPriority.INFO
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.common.work.WorkBundle
+import de.lukaspieper.truvark.data.database.DatabaseFileSynchronization
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/**
+ * Because there is no database implementation with SAF, the database in the vault directory is copied to the internal
+ * storage for direct access. As a consequence both database files must be synchronized before usage and after
+ * modifications.
+ *
+ * This worker handles the synchronization after modifications while [DatabaseFileSynchronization] is responsible for
+ * the synchronization before usage.
+ *
+ * NOTE: Make sure that this worker always runs sequentially (e. g. by using unique work).
+ */
+@HiltWorker
+class DatabaseSyncingWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ workScheduler: WorkScheduler,
+ private val vault: Vault
+) : UniversalWorker(appContext, workerParams, workScheduler) {
+
+ /**
+ * Safely creates an encrypted copy of the internal database in the internal cache directory while the database is
+ * in use. Then the database in the vault directory will be overwritten with the new database copy.
+ *
+ * This workaround is required because Realm does not support SAF/OutputStream
+ * ([issue #289](https://github.com/realm/realm-kotlin/issues/289)).
+ */
+ override suspend fun tryDoWork() {
+ // Force updating the notification as it might not run as expedited work
+ workScheduler.buildUpdatedNotification(notificationId, contentTitleResId)
+
+ val cacheDatabaseFile = applicationContext.cacheDir.resolve(FileNames.INDEX_REALM)
+
+ vault.writeEncryptedDatabaseCopyTo(cacheDatabaseFile)
+ cacheDatabaseFile.inputStream().use { inputStream ->
+ vault.fileSystem.openOutputStream(vault.fileSystem.databaseFile).use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+
+ logcat(INFO) { "Database synchronization finished." }
+ }
+
+ override fun onStopped() {
+ workScheduler.finishNotification(notificationId)
+ }
+
+ /**
+ * An empty [WorkBundle] that can be used to trigger [DatabaseSyncingWorker] by sending it to
+ * [WorkScheduler.schedule]. This will cause one unnecessary job to run, however, the job will return immediately.
+ */
+ class EmptyWorkBundle : WorkBundle(size = 1) {
+ override val progress = MutableStateFlow(0)
+
+ override suspend fun processUnit() {
+ // Not incrementing the progress because the notification progress bar would switch from indeterminate and
+ // the user should not be confused by this internal implementation detail.
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/work/UniversalWorker.kt b/android/src/main/java/de/lukaspieper/truvark/work/UniversalWorker.kt
new file mode 100644
index 0000000..4993a0b
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/work/UniversalWorker.kt
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.work
+
+import android.content.Context
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+import android.os.Build
+import androidx.annotation.StringRes
+import androidx.hilt.work.HiltWorker
+import androidx.work.Data
+import androidx.work.ForegroundInfo
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.lukaspieper.truvark.common.logging.LogPriority.DEBUG
+import de.lukaspieper.truvark.common.logging.LogPriority.ERROR
+import de.lukaspieper.truvark.common.logging.LogPriority.INFO
+import de.lukaspieper.truvark.common.logging.asLog
+import de.lukaspieper.truvark.common.logging.logcat
+import kotlinx.coroutines.runBlocking
+import kotlin.system.measureTimeMillis
+
+@HiltWorker
+open class UniversalWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParameters: WorkerParameters,
+ protected val workScheduler: WorkScheduler
+) : Worker(appContext, workerParameters) {
+
+ protected val notificationId = inputData.keyValueMap[DATA_KEY_NOTIFICATION_ID] as Int
+
+ @StringRes
+ protected val contentTitleResId = inputData.keyValueMap[DATA_KEY_CONTENT_TITLE_RES_ID] as Int
+
+ final override fun doWork(): Result {
+ val elapsedMilliseconds = measureTimeMillis {
+ try {
+ runBlocking { tryDoWork() }
+ } catch (e: Exception) {
+ logcat(ERROR) { e.asLog() }
+ } finally {
+ // TODO: Stick with onStopped() or define own method?
+ onStopped()
+ }
+ }
+ logcat(INFO) { "Worker took $elapsedMilliseconds milliseconds." }
+
+ // Always returning success because dependent work requests need to run in any case
+ return Result.success()
+ }
+
+ open suspend fun tryDoWork() {
+ workScheduler.processWork(notificationId, contentTitleResId)
+ }
+
+ override fun getForegroundInfo(): ForegroundInfo {
+ logcat(DEBUG) { "Worker requesting ForegroundInfo." }
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ForegroundInfo(
+ notificationId,
+ workScheduler.buildUpdatedNotification(notificationId, contentTitleResId)!!,
+ FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ } else {
+ ForegroundInfo(
+ notificationId,
+ workScheduler.buildUpdatedNotification(notificationId, contentTitleResId)!!
+ )
+ }
+ }
+
+ companion object {
+ private const val DATA_KEY_NOTIFICATION_ID = "NOTIFICATION_ID"
+ private const val DATA_KEY_CONTENT_TITLE_RES_ID = "CONTENT_TITLE_RES_ID"
+
+ fun createInputData(notificationId: Int, @StringRes contentTitleResId: Int): Data {
+ return workDataOf(
+ DATA_KEY_NOTIFICATION_ID to notificationId,
+ DATA_KEY_CONTENT_TITLE_RES_ID to contentTitleResId
+ )
+ }
+ }
+}
diff --git a/android/src/main/java/de/lukaspieper/truvark/work/WorkScheduler.kt b/android/src/main/java/de/lukaspieper/truvark/work/WorkScheduler.kt
new file mode 100644
index 0000000..b92430c
--- /dev/null
+++ b/android/src/main/java/de/lukaspieper/truvark/work/WorkScheduler.kt
@@ -0,0 +1,188 @@
+/*
+ * SPDX-FileCopyrightText: 2022 Lukas Pieper
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package de.lukaspieper.truvark.work
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.StringRes
+import androidx.core.app.NotificationCompat
+import androidx.work.Data
+import androidx.work.ExistingWorkPolicy
+import androidx.work.ListenableWorker
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.WorkQuery
+import androidx.work.Worker
+import de.lukaspieper.truvark.R
+import de.lukaspieper.truvark.common.NotificationChannel
+import de.lukaspieper.truvark.common.domain.vault.Vault
+import de.lukaspieper.truvark.common.logging.LogPriority
+import de.lukaspieper.truvark.common.logging.logcat
+import de.lukaspieper.truvark.common.work.Scheduler
+import de.lukaspieper.truvark.common.work.WorkBundle
+import de.lukaspieper.truvark.di.VaultModule
+
+/**
+ * A [WorkManager] that schedules [WorkBundle]s and automatically enqueues [DatabaseSyncingWorker] to ensure database
+ * consistency. For running work, notifications are shown.
+ *
+ * Note that most workers (including [DatabaseSyncingWorker]) require [VaultModule.initializeVaultModule]
+ * to be executed before.
+ */
+class WorkScheduler(private val appContext: Context) : Scheduler() {
+
+ companion object {
+ const val CHANNEL_ID = "de.lukaspieper.truvark"
+
+ /**
+ * Just any constant name that is used for enqueuing all [Worker]s to prevent running multiple operations in
+ * the same folder or even on the same files.
+ */
+ const val UNIQUE_WORK_NAME = "UNIQUE_WORK_NAME"
+ }
+
+ private val workManager = WorkManager.getInstance(appContext)
+ private val notificationChannel = NotificationChannel(appContext, CHANNEL_ID, R.string.foreground_service)
+
+ private val scheduledBundles = mutableMapOf()
+
+ init {
+ val enqueuedWorkQuery = WorkQuery.fromStates(WorkInfo.State.ENQUEUED)
+ val enqueuedWorkInfo = workManager.getWorkInfos(enqueuedWorkQuery).get()
+ logcat(LogPriority.INFO) {
+ "Number of enqueued work that did not run before being canceled: ${enqueuedWorkInfo.size}"
+ }
+
+ // The user might have switched the vault. To avoid any side effects all work will be canceled.
+ workManager.cancelAllWork()
+
+ // Delete data from eligible finished work because failed work cannot be retried (SAF permissions, etc).
+ workManager.pruneWork()
+ }
+
+ override fun onVaultChanged(vault: Vault) {
+ // TODO: Pass down the vault ID, to check if the worker got the correct vault injected
+ schedule(DatabaseSyncingWorker.EmptyWorkBundle(), AndroidSchedulerMetadata(R.string.sync_database))
+ }
+
+ override fun schedule(workBundle: WorkBundle, metadata: SchedulerMetadata) {
+ require(metadata is AndroidSchedulerMetadata)
+
+ var notificationId: Int
+ do {
+ notificationId = notificationChannel.generateNotificationId()
+ } while (scheduledBundles.containsKey(notificationId))
+
+ val notificationBuilder = notificationChannel.provideNotificationBuilder().apply {
+ setSmallIcon(R.drawable.ic_truvark)
+ setContentTitle(appContext.getString(metadata.notificationTitle))
+ setCategory(Notification.CATEGORY_SERVICE)
+ setOnlyAlertOnce(true)
+ setOngoing(true)
+ setVisibility(NotificationCompat.VISIBILITY_SECRET)
+ setProgress(0, 0, true)
+ }
+ notificationChannel.notify(notificationId, notificationBuilder.build())
+
+ val scheduledBundle = ScheduledBundle(notificationId, metadata, workBundle, notificationBuilder)
+ scheduledBundles[scheduledBundle.notificationId] = scheduledBundle
+
+ val workRequests = MutableList(workBundle.size) { buildOneTimeWorkRequest(scheduledBundle) }
+ val dbSyncRequest = buildOneTimeWorkRequest(
+ scheduledBundle.copy(
+ metadata = scheduledBundle.metadata.copy(notificationTitle = R.string.sync_database)
+ )
+ )
+
+ workManager.beginUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequests)
+ .then(dbSyncRequest)
+ .enqueue()
+ }
+
+ private inline fun buildOneTimeWorkRequest(
+ scheduledBundle: ScheduledBundle
+ ): OneTimeWorkRequest {
+ return OneTimeWorkRequestBuilder()
+ .setInputData(scheduledBundle.toInputData())
+ .addTag(scheduledBundle.notificationId.toString())
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+ }
+
+ suspend fun processWork(notificationId: Int, @StringRes contentTitleResId: Int) {
+ val scheduledBundle = scheduledBundles[notificationId] ?: return
+
+ val notification = buildUpdatedNotification(notificationId, contentTitleResId)!!
+ notificationChannel.notify(notificationId, notification)
+
+ scheduledBundle.workBundle.processUnit()
+ }
+
+ fun buildUpdatedNotification(notificationId: Int, @StringRes contentTitleResId: Int): Notification? {
+ val scheduledBundle = scheduledBundles[notificationId] ?: return null
+ var contentTitle = appContext.getString(contentTitleResId)
+
+ // TODO: This implementation could allow race conditions?
+ val (size, progress) = Pair(scheduledBundle.workBundle.size, scheduledBundle.workBundle.progress.value)
+ if (size > 0 && progress > 0) {
+ contentTitle = "$contentTitle ($progress/$size)"
+ scheduledBundle.notificationBuilder.setProgress(size, progress, false)
+ }
+
+ scheduledBundle.notificationBuilder.setContentTitle(contentTitle)
+ return scheduledBundle.notificationBuilder.build()
+ }
+
+ fun finishNotification(notificationId: Int) {
+ val scheduledBundle = scheduledBundles[notificationId]
+
+ if (scheduledBundle?.metadata?.notificationFinishTitle == null) {
+ notificationChannel.cancel(notificationId)
+ return
+ }
+
+ val notification = scheduledBundle.notificationBuilder.apply {
+ setContentTitle(appContext.getString(scheduledBundle.metadata.notificationFinishTitle))
+ setProgress(0, 0, false)
+ setOngoing(false)
+ setAutoCancel(true)
+
+ scheduledBundle.metadata.notificationAction?.let { intent ->
+ val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ setContentIntent(pendingIntent)
+
+ scheduledBundle.metadata.notificationActionText?.let { actionText ->
+ addAction(R.drawable.ic_truvark, appContext.getString(actionText), pendingIntent)
+ }
+ }
+ }.build()
+ notificationChannel.notify(notificationId, notification)
+ }
+
+ data class AndroidSchedulerMetadata(
+ @StringRes val notificationTitle: Int,
+ @StringRes val notificationFinishTitle: Int? = null,
+ val notificationAction: Intent? = null,
+ @StringRes val notificationActionText: Int? = null,
+ ) : SchedulerMetadata
+
+ private data class ScheduledBundle(
+ val notificationId: Int,
+ val metadata: AndroidSchedulerMetadata,
+ val workBundle: WorkBundle,
+ val notificationBuilder: NotificationCompat.Builder,
+ ) {
+ fun toInputData(): Data {
+ return UniversalWorker.createInputData(notificationId, metadata.notificationTitle)
+ }
+ }
+}
diff --git a/android/src/main/res/drawable/ic_launcher_background.xml b/android/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..e26d5a8
--- /dev/null
+++ b/android/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_launcher_foreground.xml b/android/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..0c3860a
--- /dev/null
+++ b/android/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_locker.xml b/android/src/main/res/drawable/ic_locker.xml
new file mode 100644
index 0000000..f400404
--- /dev/null
+++ b/android/src/main/res/drawable/ic_locker.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_truvark.xml b/android/src/main/res/drawable/ic_truvark.xml
new file mode 100644
index 0000000..5401d5b
--- /dev/null
+++ b/android/src/main/res/drawable/ic_truvark.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/drawable/ic_vault_filesystem.xml b/android/src/main/res/drawable/ic_vault_filesystem.xml
new file mode 100644
index 0000000..68ea1f3
--- /dev/null
+++ b/android/src/main/res/drawable/ic_vault_filesystem.xml
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/res/mipmap-anydpi/ic_launcher.xml b/android/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..5f865f2
--- /dev/null
+++ b/android/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/android/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..5f865f2
--- /dev/null
+++ b/android/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher.png b/android/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..d43b10f70153005f316df284e1101e8374674ef1
GIT binary patch
literal 2947
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
literal 0
HcmV?d00001
diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher.png b/android/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b3a7d17ad8984a7e532aa867c6a1a39f609950f
GIT binary patch
literal 1767
zcmV`g&
z?UWF4*LBBiH}{n7dM9lsd){)gi}ZViaFyc0k&0t3UJ~r8;0>L
zI1BD<1x(BP07~^{zY)xzsBUDqR-Z2if?LM0Kkf(~@jE){-0_X9)GXQ=0M)^F#JSo^
zM@L5oEXTtu-~S^3%eg!_I5^}7pzC^u`v1?eQnP4h0G91uNu^S@9{?4FM-P9Km6}C6
z1E7uVDs8&s@(Sc`b^th;j&Bbh=2XZ5&MHjfq{X99{|Z3T9$8RrDjntfYOsQ<$F%u8z8^u
z!@WzLg?w(hVJ{HpKE|9Min*fPdz^@aKmhc1w(Z5a5b--+-MQqfOJKYxh;ky8c>$aQM+w@@rbyurD7Ru2
zL;(AMPwxv57iS}NOg%^i{3pE=%vLhLvKx!z^Z?bt*HEf=oQ?~B1wN}1N8WFIXj4cx
z4g@F4{|m%*kaMnZ7j>X%`g43h(`ghH0D#UDC*Nhz>{rt#kwW@(sjrpn1);aMmulI$
zgh91lDdR$}fxTxB1cJayl%ALgjj08}#W1YJo}M0C=IjfPs3)Dy97CjbldGV1A#|E>
z>)aQo7UbD~1sv7c7a}4`(?)R-gPW-ZH2~a0$JzE$W8Fw_$YiV^h?~wiMt^4Hk*ygS
z8F_#rzRnCRGnJwO5K!OP1k|&T|24vLuT$qa2yXR0Y%$?M+?GtH-w>&^oVHpo00DF)
zIj!V$;yy*{90#XbsiTT`RnyX%eu@VX_&yp3aRLC)k+vMIoQXsL+!yH$mTXqxc_52B%T!t9FgJ#l(JY5)*ED?5(k%aiO%~vf&ps?9<9V_KZweo
zg_hl&Y5_Jy!Adiq#*09k9kA6NgRMCLfQ&7x&q4+SHQHVzCH=_!3Y^q|&d^
zndFibh(PF1sw$u(kAV%?8cn8OC)*S7!F|Op*x15*FqSpd-Q7JkI+{F|*31ir>HH2N
z5dnoD^e`r^n-^dMwqO&sm+bQ`5OEIgB_s*?zP`2lZ%?FNrmk^D*R2J^v=&X%{>iYM
zUjcSa_wWol(8U-Ui!TeX(aUXy|8UVpS;aRNIL0@75U~jbn$Z#+y5{=(`|kp9jeB^;
zb)btemA9%DCawx{wF`O6b62bm{{O?R9Jt24Zs_pY2Jxoz{6CZZfv{(4)w%!x002ov
JPDHLkV1jk~S#|&b
literal 0
HcmV?d00001
diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000000000000000000000000000000000..4de3081b38044ae5f6f08b0a5ba6c37acd4b550f
GIT binary patch
literal 1952
zcmV;R2VeM!P)Sofs1*oBsN$bi{e~h$f&Ned9qt*=*pv63m&8sgu5{!V&z*bEow;{r
z-Z)iN7B%WMrhq`6VQoOg8CyBB1LrGlN6`-#PUl!o~{c
z_e&`*;+AD~7*_Od!?F(%$X!J0
zbl8ksp-;9Dh(4!bqphu{(aT3Ah3C0^HjL;4hH3xkAazYQ?0-uEbm#XaTVzxCk+V^;
z`p6b0IoV7O=cHqhp^^MskaCT3A>`>h2OnO(7b^<+$R2X`Pu96^@#jhawqzjO2l?{i
zuuxISAILhRQ8QdAP!U`cwljYl_8~7WrAl@+Ao9mxP|07X?`l_U#$s?Lgn|L05ZxK(&$Pf2-}nCw$XPM4nK&T
z7Oc~GVrw^>JTk*(Pwiln@6WLKpgVwpr?&ETpnuO6S33q_o{b^54Yja!T^~?6dWo9%
ze5ssQR0@_W06=mGhpq1wj_w^hmpb)F(?ZQS|HXWQAt;c1=@Dr7r=
zXJ`B5;ThhSw;$cck`wD(egwdQr&2Iz?pC>~!Xwf&twHXvh1Pk@z}SH_OMf(55sbZ+
z&N~Atm#|@-U$bV-YLzn=8BsWMw(-n+6<9SATe?~2NKZuoTYM$*@TDL#3%Yr)${C_8
zo7QP4=gWPGXez<_AK$`;o*AwPU<)>#epKXY!ThQ!H~O4A-yniPD=JTw#by8_hQgCM
zUM^q@Hl2P{NKPT$zT)?XlOlkX*=0$msS(Pag{vagpJb!2P4Gg@hpFRA5!17?T?K5x
zrqhoHaN=d5Q0O6WmfZOagmwE}C>0xhz_g>xPR3ZIJDwLv3mE{Oo$V@M3pSm8oRHkw
zL&gVH^N;vd)pYLoMpY^on;#y`27qU0`(hxuQ5}3ooO4TQYinzhZrXU|`+o$Wn-`m#
zn_IF01cSkLTK|7mmCD6Z2A~_(CBNTqWCNh0u&MDkRjFJoWdO9XU8YSpuFgR8dIey2
z#n`&rQ>o5gH2|6cxs>uFM3_ORG!YhF2#suqq
zaDb&h&TVoaf6IOS%t{nJ3uU&1?fH`~l@k%z_$#1YTq|?&@WBBIeRB2Bm=i
zq<4b8Oy*~HV|kozqB{5*O2v-TrNAqIkypl9Vz|4cbB_~}6XpL|;@T`YXSmBc(6rzq
zqC?qfBm|fMn9K6K0nf@#`#*wUl8i+>#3H_^XOFQm9iA5
zGcfq{Fzb7Ch}QyY06;ozx~Gptdg7kIBsgy4OiH=k!-@uSolka&4kiCu2
zt*f;1+yQQdF1EOcAZ}`F3%n##X)()cu>d$I_v2f-c)RnJQuvgqvk#o|rH)hNtC|+j
zg2zMZI!T_LCkOqHo=p)W)bi%zG7
zK>Em#Z~u}wSb~dp-WL#bSVgdDCXQ!=$owp{=+!A7U{e;XGV&3@p`_ID5?<5y1SEX^zzcLHIj;mF;rdC{3FxSOU<0;V+X63=?N0a*
zzDh3Gl0~e+m{xynZS98E*0#L?Eqpc{2hC+O`z{0000CcAt0?tSkj+ca(3G-;YX(g%H|38kovJRHFpdCVXK4&aOmB0BgO
zp(BVebyT2Gq&%vO{-ZD|e+lR)I>HPc-XIDHmJ!E4!1nt6&YqK;+n!16N${qtP~-02y;2+O#zV(_smL}GI#s-?el?1j?{8xo|F%aGAnrm
zt#w|1e}5A`y*~<3Rkb;tPRDKAHf=kbH8PnV0N-&9zafqHK1t*(DcL{)zaK|kP^WzLZDbLCmWiIpq{$uJPD-DU2_k%0MZ%{7
zGYEZti0{(%6Q~>NDC!#&pBhGF;tj)Sqkwo5DfzxoShx!h3H8RQ09W-s>WVrC>Cy-?
zhY$6bkgq+DlzK@h7@%+{AT`A4bh({<-GNB=WPQpOg43NpFO=g=UkE7Mo5w_6
zoxb!IAGWgZR_19<)1opLbrVq_Z~0H6LWNvT|Aiaev7a6Y#HMSv`qCG|&=s3~c?b+%
zw#k>i5Vm}In=cQ6zVils=>>$YrsX_^1}Jk<4-q4Pc~psC$`#6onPvb$=xkWdW5k7U
zLp2{AjgNt;@WWC?d~Qd^Of-Ncxg*1LjzlIVX*HcEFbkV@`g#;x?E1|NH2?tJ5u9Dk
zux^w&s=A11Z&~RPQGsGdJ2Th-;~c;Ux~`ukb5%7F0fsLxyp95=^7}GovH<{cL2PY{
zKHo^CQku+JH3Ytmfm;6qte_Ie2L>+6Z-C+Jwz*yX-9-Zi^X4J2@$n+xuCG3@BHFpfDG&_uu+j_eh1uf4$BgDBip~V(>U@`^r**+)64*jBg3>$B$H-0
z=dyHH#Qhfn@2$BE%WM5Wc4Lp*_sM~VLf2rAPmf|@5?8rlsc{G#mK+Nv0A6bcEnaH|+N)7OVi5rOpj&6Qd5%L1Drr*z|+;F5D%l(4moSS+@bTK{v%
ztP=MFG-3=`cW!C(051e*nqfb`Y}qolY%RDgD3b3#TgtGcalaR*qXDS(GtS5mZZfG|
z08WdE1!DF#*R-7@EQ1nfy+LS11N2pB06@%+Q-<+ygu8W)yu!Ka@Nn&)<+qHT-`g
z8er{tl^b9@80+@m6-BW)$N6J{IDAA?>2suvDsn%-X>5RTH>4=3i@%jfo
z!8541^#i>J*16kn-c=Rsxogx%mcZ^Gjr!7;i`D!3e0r1#J{r>R0=JywKoJqTiH4tl
zz|GY%KsB-9iY-3k1xj6xt_K8%nqfUd+={1n{6-L?A62!pmYrs&muC%7IVP4StoLKF
z)LL*HI4Khb&@}U7-kZaO#x;QB#^cFPgX4U|QRe>*%lVRYW5ofu+GsQbSkk_6GNs=H
zjyczpM2t`8o2K()+*s-Cw^nFC1E4Xp#W_{g?~mk;u=wT!A^DeGuM7DW=h!qk9*-x;058iog$<2u0QnCcDEt2LKw`0GJDObabo(*MSCz
z$F*Kc_;2~9u%VyJ6g2W9u3@yU|ncfxd<}Yq@$Bs?TtC|KFABJk?YcvnYaK0z4
z2O%f`QPXl7cOq7T0qjgC+bG)izXwhv5LM#Mjk#536d
ztM;yScO4q>-Ct`1q%{2r;utew(cmW;09|6KZdi9C@k}&8*TBkxN78F%0KOQfsHvmG
zG2Tzami}b}i2dMZO*3xbH)0vJv6iFr7{JMPxf?#W*&Tf-*xgJ5#eyx@ZDX;)n@>3~
zbNj-l$hEtQqT;}AbKr&yzqm+M_4B2hLpKsC)c|YG?k~8HEaQw@_qZ#!_xkdv1QSD0
z$L;7yTmp^*H|F^+%>o*V_M2AbJ^AJ^p;!ZCH?MNH-@Mb8CJ1}(9&^{8GvHc|zr0Ez
zIMj6eL|a?iFgOnE0g;?f!VfXBRzq&e<4GJm{l4*kaVZWFNg0GH5En8)G1z(gUbkmwb^Uxk@BmEy-*(&EV^@G%%d!@E
zd6xx~gT+giE?sF^Y4$j3*m^;^1{k?-%w2cEplhdV=We_h_y3ytW^;2hmZ29%ig;QE
zy&$gZR#}gv)*7W6VD#a$+@Y(suoX^kUbTkF59cP6>Z8OdUVE0`{YZi#QfpjME|fA0
zjTfrK6S$#eB!iSmsP+fSF@ONpfx6bO^yOJ?2(D6^`3?~OO~l?_?PM?R|lLQ_){CSKTlDarSHWll#&2P~M1$Cb-r
zSys?Ax}u^Dz=79X4qY|ujuo*uH}=Q@chl9wZe~^K@j-B+852!{Fn~bkx^8#)hTs?X6=Q%2zi(s4dn>r%90l%pRVwICSWZRv
zT9HuIqudDCaH!idMKui|fMtqZckC_609@yjSnH1~$}w=lxq-W`n(OfjJ3AD~ZLO_N
zs`_Kt?MDErml0}V000xRb)On^NAAzr3OGz8uBg{^ttmNJR_Wa??+pr3P56TcrM><}0Ex)^<0gnlx@laf2C
zX-r73%=+H{DS+R+>B~jX5(U?d&2*Jy>i4AkAUNP$_zzpvP}m6y9&l_zUs6pyK3##i
z9uPXg=O+{uZU@KAZ7Jy1jeH>}aKVx#OR&}E5Y{9T>O&Y2u!>I8JfRj4y5vSXEa2jm
z0}j_6%QbZ;UkEb-0bLM1VRc+lZ=vZp{$-Vj3k};FP6}_6F&H1JDdte}(2&m}K>E9ii*AxSf0=jIeysqD2;c
zb}wGMcni6tN3l4C-eBc|*R-u66$@TR)@!NN!{z*(bi%GyH`fjSvLsaG3qgf23|m6v
z>-5=4=#54@u1==3=MlWPl5C_@k0EQZ;6qC`!olCDE7kcp)%{xN0A09FOSq1qBVQIw
zM6d&d0EJb-l}$}eL$SE>eKlpgCESvQBikl>!|b%h2-XmCEuNoXb6nK1Bd*+uI-~Af
z2j~KwYCg%TmI#;BLM;cA6!eDGG-}uzkE?g9n(+^`GVYq#0VZ_)dh`mE!OD{266%6F
z#pB97&CSjGP*>EM*B$#Ip>#P@iEv41IWh8|2wqu2FBxJ_ES9(}scO&VV#Ii_0GKP_
zZmJ?zC0vW-UdDSHjE`X@>VTsoRHqTt4Rz#o#V7}L553EoN>F*s65&4+q#Wi9FvM!=
z=7X)#=vDDV@@J~5zXn3!21L)0z=nfx7ihf#g4{{ha@WTL>12aAjC55sUPYNGo9cik
z*U{oZJn9B_gSysrW?K(||3p_7hOi8O&!yo}h
z$qSJl_aYCR&q1EZ8)cv@l!>xY2VNIJeE#$MGem?TuxyL*G)@kPUa<
zEA@`Ja*&97P*GC9C3o=*8RkVI^v#rJy{+r!y8yoDt{E>P4bsv*N8|D2!PeIHE0GuS
zT{sYje;BN1Zmj45KrJcx=5wPk2
O0000_kHJaXJfH>nU@k|0bwD>qG=$~AlGWnPL1M(d)H
zk&!06dcPE+s%mpKn@!rbZQ6Esw~@>B19*?m@Ey{4^GPDtF+W3Me1yh4Wc2sO|dWL$UVFCYjVR!{~A>CwGL1
zIPE1zCYb*I$oqW-_()bz!1t%o7PP5YdmCAVon@lua#=D5-=|KWkqIKa7b4-)fhmMu
z-@|+9^D}50+9=u^>)^0Ps#RAUsEr>GMrr49)*D3Us<1LTnWP2VlhLF`!#DIUl@5i(dh8>
z>tV-nR?8gJU4-^6%W9byeLKER&`1ZmpsIU^dKN}SQkO^XYTlD&BCX>-*&cYDbHU=8~_p*vA
zARm~m3IO;fqiY!UgRzNQ8P#vzO~m`z3#AH6w)-+>vjQy630J};GI!Mob0}E#(GvVw
zR3N5XC79(3C~%{j_VcQ$cE}u7D#*ORG_zlmE2tDb+kzEv;K6gX
zw=ciI_HXaJI-N6!ZNb+TRV#@A7w{9Ui5As@^v^;
zICYqXx!!h{1HqAD+Go;fvzK#OzA57N3y$~3e1X-CexP@2@C=9yLQnu;5?A@K)HntX
z%k~8mj@PnGEB6*Fw93&B%ozo6u5`nGn|whJxGZ;VkhlGImL0ByK|bL0cDVz4*F+jl
zcbBi-6@smhGrI9jaLG9>N!VUSB9T~0qyK3X7KZtmYY?1ihW*T{Rjb&xwcvJ2k!=5Y
zQh^o4{a(za89<|-aYl-8(`oHua9WBl5UaO^rtO?y1ys1|4MNm?0K(xge1L#h9cK*V
zffzUI97Tmq5%RqXnV-L-kg)7OFxM1NaK^g*4@FTd&T(;HATA%#Qu^akL7|u*U=Avv
zV1^VWb1AqDxF$bIpdFZ>TX_MYjecORDS(4VL;4-ymUA2^!b3Mv`1wa<4qE{@blG|H
zcvaZ_gMB{Y1a{xH*O$I5+%)cXomJX?QmGIeYKHYBaVwtQ@hd?@KdNeXTXvS+UJhFU
zI121s+glY7sd$MO=-WBuOJ6R~MsK^T!Nk^t^-dy@Sp$v(7iB^Lnr5Ew%^W3!u^U=X
zOenx{lgaca!Ev#0l-Ym7a=s#E49x+Dg+^0=C7m0mGy3)5m~%ZzM0~o~G@TzKV^mka
z4X6MNX0|z}s`@>#{1q18dO$efCAjB;O!Eo|57-^WYeq94jZHY>N!t?a43qEw`~`{U
z6%ZZ@`$D?)6yF!}ZO*Y}ax$4rkpf2zqyL1z_J0Dajtmz=QO)#}7uXWt{H1N~
zz7yy8^4CfMVkNBU##6+x!nvL_1K2q*VHFYo2oldW1vouj?uIKiyW@8r@a3zQ0y3KZ
zC~=IHuo&=@6o4tQQa7wSk$AQ!05>s)&mUn2E|biS2M6vy*WLb&oo;Tp$5%$R5Swv|
znmI`vnIjhom7)NgY>j?yi@Wch06Q-M=4k!po7`-F_{0lL
zgrJVw(UH0w90wlE^HZ876pD_RR_<*%bCfWZ0(!R(x@#|7&(4jfGBO|yO|XO0r4Qa~|aHtf1(uRC;TUFe7IC4e1ArqU`-!`e8<
z!NR{>iAzLM0g-aVWhsEdN<4!MJKwpk*H>1p!5uK!f7@+uPka{KT9&oU%eyRC94ueC
za%G=oW!dAXQJV#&Dq#P~L++L@Z_mf|j=!9GpkV(s^R?#YW^6+*kCpJW3}!)6*R84^
zNv#!1RRA3E&^a?=atbi{<=k{yeTX>4&z==GKawCsYE3H2B~oFLF+-&)0N8i;Iqs%w
zwz^##`s(I%1%&z04cl#NZ@&_pPHF!off;IEx^!t)H?om?K%*Q52#nuB@&09-*mi`s
zyjnqQKd2e=^pYh@@UR|kLriOanDAd9PpYc+Emm0GmPKL~Or?MW4-^kU@!ifF$K9dx
zM%ea*x7;wnNB@Udd?ga8oAX>w`K#_eff-swvVjVT)cL?v3Rr)|W_Q=Ed#7B4VOwI`
zksa)L;98j_!Bs{x-=u(M8@P;>X#a(%wW(ULB1)UiE#=mIv?*NB{DP#X)0*ztoRHKF2(WuOVQWPKnMGPLu
zpL}`KPErK<2mH)I-`G$XYX&y@J35rxz#ZqX?9nd-z93Fse84d5XHdaRuM5gifPmfE
z=?-5ol7G14ls})Wh*E#KD+q
zEDh{i!?vdj>xT*zAh<}Y#!HJAFWv;M2o*&>#bn{?)vMvnjrR8TPicn3f4H>%rYI~0
z2;iqGM=ssy?)lOFik^~`V*dAuqtAdN&efz_4`E0Xg;hy<&Eafwru+d28o&KOAgbAN
zM&8lSZ!N2g3A=Ad{;T;T#2?cvUNyPS{Sx^y*2e>PfBy95~c%n}YptD}Hn}09MwY
zz8tWf(-Ol_-scao*Pm3BQ{aSi19Kgk>G2A?I~2`2+QR132JZ_%B=4P}mI$?r?0v+O(Q^WTqT*Js|o7o1aot
zm>pa*w}zUj8cigXThxs4x-d%?u56p^7iMS7N3emAAMyMKo8zL59ZBUjv>9#ZeL!E(r~8k%HMUNKwPyWKz9T)r@~)lyTFKxV#P{bfj^9`->_|?f&
z`sb>uzYIcP2E@#ez`{Y83yfX?LGGl_@}CzR>12gCjC55sUP7Iyo7#XU*D>NjJlY0x
zgSOUnWm^w{{lrujim+mN)6`ES4Ab8`;IJujIzg-IKjBsZk;Rt%P)LAMibAAEUX+3R
zIVcllqYl)CI#D;;z}q5-FMfZ2mWWUUwrvqlv6$2m!CvPn+Er(iLX?7%)qeR?&
zijw&qnTsb$G0zd9uVpmr4P7_i0`NZnneiObAT8xNnM|gSwzjrkg|bj4Cmp|5g1S&A
z>P8#TmRV1{SA>sX&m<5MaC*3tO7HP7Gxpa()({c5hA;wiftZ}^^&XH)ILvVfpV4<)
zk%p(mb^&ZU%0O8;l+7J0>^tg2-3@Q!s}*z}BM&wgguo17Yga++&CLTwD-;8hh0zN_
z0bF_&z~A@|X^_@IULL>Wpaf;2Y}A3eQ0JT`zL>}g!Cnha;XoYzeJpMo;Gg5bUV~H_
z_zd464bmbH@}i7+!mALFCX@oeWB?#lhWK2h6Zs-7{|80J*^F^HZZiM?002ovPDHLk
FV1hXD+R*?2
literal 0
HcmV?d00001
diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..13f5c1d1684b25728193dc8c72cde80d4d98f01d
GIT binary patch
literal 6429
zcmV+&8RF)NP)xRL-)2sS1T
zMT~I_Hm4~t#*Rq{DULW0V~7)1Y*GYBIgo(u!~~xhwnYZ0LyL0sPT<@>?
zYNg%ocl`f1|MA}Io>WST6e&`qNRiUPvV>qM!+DEeBq;>x9Y2|SA2-zKM9^9IO}-Dm&ngq0-s@)
z{q8Kg2G>4B?BWNV7EX8ih9yBY%h({96Q12&8Bh(8v9*
zTfB+(O~!z+gvKVx)CR)CQ%Mk7h~bVB+(10SKf8g)2pbrfwTVXo9Jg?eVHO~NdwC)7
z>-c>_JEF%Dah8sSj0t07;jbh@n3Ix#M7#|Q;1mjnh>>n226`lHKyGAC5=Mluk>dg<
zSgFs*HDrt!tK$?7hCTq&D>Yf>C>IGW$xE_}8!z1>-f7EY$*}qJ+Y~#lKe_
z%#(jeq{Jk1h+`KDyNS|2K@9M`XpBX|NZSBoWMmS5|2*b|xe4=@L~2mxgsOag^7nvpcvLf-Swnw*|KFh|F`26uHlNpwm~OU
zR7oLRxj|R+>9CiiRH8c08jmRD>s&d#kmuBkkZ!&X-7$Uk)B*9cGaOw$~9gJTskWW|SaNOOzd)^$B2>b&EHe;Qnf
zSNtBE^o#Q-LZUHHof5C~eao^6qV79Z_ylabxX$$okBbwPXbKpK@{gOQw?VW)#|d|#
zggjFuT>Y;&!D_JO@*V!ZcN|z0yMAY1zwmV(%Xu@K4@#(W1EU))SF?>%sKNO_8=M
zVd~(<$o=);_-9T>+MW+}d`L>3s1w5(EhBB)9wVjtcf|?S0!g4U>L3unJW>98s;csX
z{%v34(}gTrsT;sok}Bv2U*5D0jY
zRA#!vcl0ap|K%kOMY{z@bFpSlr
z?OG|NyvJonGUD18l>{my4gvu@l$o}Bf2vHL*5yNNZDEP)7T<*dHk$W{kOV3tE&>6;
zVZFR>n$CM+yH>6b(WIFKt{uVv8_Ih`?ES;)2z2ygC;iEt>l()9%XUQCu7sIaZ;jkv
z52_{5?zirZ=%)d&e8?jtG7rFp1c{S~yS=4kFyE#FiqZQ3Si%o0vRAR^_YXw$(+Emc
zne9GEY{--8Vn^5nA7bCIGo8oymHeIsK-+9$O34)o6w?y<4GhQZM
z$X68O6l|Pok{6=hhvYtgg-#tO-|%*YM0)_Xq`8pgd?nQ+FBIfHCe3|#$4&J>5{ZK6
zLcXdR2Vv_}%wC99UWHrGk@P;@2W++m6`2)#MMl?sOw%-)x$YCj`lB=cme+5WAeg#f
zLqtDuqr5KUJd@34i?Der4id2y%B3WEzKV5}i1(>>FRT}UL^`aWCgEIgX2^UeY#zs%
zA+V6fLlVB|dwV2-l1EYm;$h4%?e7qq_Y3x~TWIcM+xb00M~NiQl0ao7MIcv5rZr7F
z2HU3^Nv4?#JCB5~_o3mWybmA=R7QdX0%#c1H0x&AzL79S-4{uyh8#C|zAK63nL`q&
zj3f!fZEP6M57O!MIOjo#8&wGnUxwrGmFGku2~q+
z0;Pf+Xux(0_n;9;peT|!A=nP!m6)bmIBdU?abr=zMJsK$_>Z&^8Nwpy83Hle>xT0X
z;lwcG#)73h-+4_tZt%4Pl0Z@P8i5SMeuQuWuL`Eeo98elBq-IEWwTk;@d`f?8|ijP
z0!2|GkYztUGBV??t8V0~Jj)uA1nO}DS+@Ja`1trva3i${fxxi7
zY}S||H~t@MND`>W354y8v9a84a3d9vsG=|e>6=_hpo*9}lq`X~RjVSuO9Fxida+sp
z!9Dfma=8uU#$U3AB!OB)Ae=RnFLz9
zciKOA!;v}twVFVr3VU*Ja1`HuxF~_>NyLGHajTaOlL4(JP;q+F-}Sru{S%)*6X~zj
z1j3=RzP{l#;6`eZDvT0{V1nHIZ(<|ej+POqu*%?b3inf8aBJC#xp@e_5udfelK!d4Z5ph8R
zK~U%8Vud77UqK_ikVwZtAiSEmnzFnSPTYBh<~xP;Ya(qY4WR?YLik#64T#H9
zyw;BV%vhHDXJ||kXfDD8sx!@wOZCq82
zH?Rg}4kUmRsNS+%#F1N$)4s#wX#=nXI(sF!z&KGAfp{W4GCDfC$#F~H1>ep6`3b*5x^sz-?_9&NIWq!+XJnR22y{7hHs7DNSJ5Zk~Q0
zyd+Q=M?P{Q^1Et*s%-b$_WQ*RlaVpB5$G0%s+oAjoz1vVRRYZg$r8#oRX6Qx(WoR)
z<2Z2rVSjQ@`{X$bfG!~Ba>^})14=3&P*n*u7sNVLVwp@vIW4@QwQF;sW)kSghfnw$
zF4`RFvmL?Le+s*ng_=R2p}*e8yp;*v7P*X
zqoZ#0LU9m?WB(r=@>g$P7wMy&!ELJP=1&I(26n*asUU@^D|~UGVQgq<2d;I*`)^Chf17g>6^o5#P^|P=wu{j7-clhGMk9Q!E(n&Ns
zMmp-sWIA>NF&z8kNq^I&+kLNvM>7^fY3;|fUmO}8eI;z23brrms=mSk7lu=cSdq*d
zhJ8;rdZA_!h~t7AkNWG5wCBq?;eD|4p37-CJfCN_=5MEOSoq!;l4)~vbmA43Q+SDW
z-ZfsR*#zR)_pXEV_(!<^7J}MPrz_8CtUTkb^bIG^qM#8l5!?AVSX9(?>#lC|LbVWR
z+pq2P4}YkEgJXA`p)XCyPbU5~P7ws&2m4;?-@>-c#{BSDEChlek-}>|;DzjTdh9q!
zxPHL~(G^~(76Q@7dJ08<>*eP+baeIT$4~g1F4^jP`IhBJ)OMO~{c>n%ShyD&Bqg3feil{TxUHMKP|XBl_Z6lm>G6z44$mL@z=i(Q!Q}HJUU_A*3M#K6
zu`R+EHqm2Ap2H%pOxw$suiR!??qh5q9ltJAFM&83ethTi7tyczT(x;^r0pgl*fiCw
z|5>(d056Y%_rcD4!=-yA;Uzu15SUs}6y<7U?-f_6yiN{faT16lj;A#az5hZQ9>Z18l}@
z$6wo$i1*=AN~lwT`ztJ4wrrY2S1LG1T+Sd>jM=9XHlEGC(X?C|#yTw@N{T=nwUqsh5{TP^e86YZV`IM#TM?VV`(VSg>4|eC
z;h_whZWDcdeOLycBJS~e97=M7RnCrCrB0ea6ji=As1X7=!A|?jggpL4R{3+-h}jD3
zc{P2VcQZr`WjH^Afvs0m{SL(1P6}l$ArQl2k4QI2pmQWD;EQv=0NXGd!B#B`U18y&
z45!x+Q}HOsw4xdJVv`ON2v4dVQ4@ihwC%8Z+x%sJy#xx9X4lUt+I_?(c+WFzgRLmo
zs%4=oEMPF-=D=cN05h_5sg^85#}!5-Lx*#x#}
zdEQe=cs_&`%?wM_)(#B~9V8_BVY`!Pa`$?F=T-ZvLXro<@DhgUIT;x7724Xm5TLE@2Yk(?AnzGSQ8hOzuzdB!J`QC5h?)bcPS0W?1JYCbA!ZPN7?-WA1r6mONZU
z5+M#FnezzN5o{P48GnW}z?+
z;W-g~>luN)V#NwfcpB>zilTlEXH;lNYvWSC)!@28$Wj#T?@6+}A37wu#1;iV)6A=_
zj`loZ;hqQ+!&(G3@+>lO#Ow9Qi^em#_Zo)vJo82}!JZ46C}SB!H_YdWu5N(NpgZh^v8CG~#fO;I3=U~p7Za$aED7Pcm5xfrewfOVvJX!83@^BIJBDi1d5@YQ`^hPto
z!^5x0WR))v7yd$tNZl9OXsE)!F-OC+UdU$Ezb13Pcpk3<9YI&nSy$&f)e84U^n1!u
zK_!7t*lMD*4dmwrC^4NLdtElC{*7+fzu=+GExYt&2M
z8<-A|>p&;a4Ri!u^`zISBw=rav$}}yC`~7s``!qpB8en!orE^?QLJXOCu0G=j?}hf
za)RTfrS&c>qGAgIb0Ks36kc$^h=aLe&IngH!vkGFC(`RIB4TcY^$b)OaM@5IR#}k)
zP>+Po*@=nlAFHbNfM!^tR|0tTf}brT6#8akD1}_^Z7^nx9gh}cEsT2{Hb=}AbH?1E
z1L&e>bDdg+dn1&jaMef@aS#fR54i-BLTH;I*pg1CFU@3ff1#?z!=!pU3o;3w2`Cw{
zS)}4*afQChf6KEFWu3(sF;+6>*J13K1Lnf!1doF`Vy>7o<_;b3@?4Uq2B}0O?vaoq
z;nPapJwCw%TZv(QWg?sVprYvCBo*8fSW|-W=z+GfhXS~lqG1283iR6G8~sZ6&LtBF
zgJjsjb68K1vD}9-k)-%?GRAFetTZgMIUvX39)}YLrMV0qRJc-}oZ;T+@3APINi)5kwus*SNgE3$%7?VgE9;Pq{%taC>VS%#4UP&YqR%%iH
zVy_6Fco-2!JW=6LQi&b{VB``#mx$8eLf6j-WI@~L1AU=S^o=oKEFx*b-bNCq#Zrk(
z2n|?x!^$m^Q=FASqKb#>;1Q6x!q~_~5L#e-cnlB$K*$03|M53mgKKdQ?nN7D3vHrp
z^nt$6C;G-1BxyQGL^2^Xz-R~)NKUa3hcy*=0_0DeSnv=CEigV3T6hhRK?Ht>zu_8O
zi+eax&=%T6+vo#*p-=QJh9^my4iu44BB?mB;2B_WcnKIEfY+E0{O`CH_uyVm6p<{F
rFkLR(xM6aM02xGt|1OfGXUX#a01rAiMCm^?00000NkvXXu0mjf;ldka
literal 0
HcmV?d00001
diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000000000000000000000000000000000..994d535a82d3f5900207d21bcd06754fc93c2e83
GIT binary patch
literal 6645
zcmV2GdS2aqZ{PRr?wOsP-FfZolig+ZJu|y3FQbSd5R;UaR)Hj`GD0em
zpa>{IO92B4Do7I0Krk_;e1vEa4G9%6Wz-bWM1ce&Pthg7aC5$M&*?dHd%CBew_oS4
z`f7G(`ks5v|9|~I&N=toNJNShDN>|Jkj32`l@^g{E
zV_0OrJHo%CxAivaoC
z>xIBS$FCFG5j~cOQ?xInPv{#9e_0X2oRknG$J@XFb~=5C80ki0pvQv-@C{EX4w(qJ+Y~#lNBN
z7z4&4jFW#!q*O_k5ywiW_Y$SwK@9McXpB|DNW%bqWMmS5{}RT8u?gdrM5<7hg{o9)
zbx3W{=|xe^FHOO5vc%LBsB{-;pts
z-0pO)2c{F59~BajIF2*wuuzp>$t{TBmxJnn=wc-uNm&xn>#QN)zIPMly^vL66NjmC
z5g79e&UE}PBYP{jqmjcthKUN@C_L=0=9?%GmjW@gs&f7@>QDy}Fj
z8#F^j6&8Y(8+5gj4%8&C
zzYPw=&3umy`jr(FAyFBqPKj6gzG<3iQTI(Nd;pe}+2XkAr^JCu)C7z~sizFXoe*u%
zbi!RIA
zi98Lt>_OLKTsLzyI?m>Kf`cgwTj!>{qqi=2$8I~G7x??=-mN*;mVz^`JebpN5PL75
z&AF}=j2)QBX)}nUw=B@l1+Og^Z0DwDn(pTJN~(|
zyc74Hofr7~=GpO_YfHhw8;<6*8^r9@hjOkf1zV0x=Cm2a@q5nlwj7bgE6+OD3G&3hcSC8HJ#qmn??0S{${
zN!6|lV%dQb_pG|iw^2~>S>n-MScTs&?J!=}u(
zH5cSQYYBrti4N2