From b2753bd3636f218dab5847444492964fd1039cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:17:23 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Drive:=20Men=C3=BC=20mit=20Ordnerbaum,=20Tr?= =?UTF-8?q?esore=20nur=20im=20Men=C3=BC,=20Tresor-Sperre,=20Drive-Einstell?= =?UTF-8?q?ungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menü: Kopf mit Name und Platz, „Persönlich“/„Gemeinsam“ mit aufklappbarem, lazy geladenem Ordnerbaum (gemerkte Aufklapp-Zustände), „Tresore“, Icon-Leiste unten (Papierkorb, Speicher, Ordner sichern, Fristen, Importieren, Einstellungen) - Tresor-Ordner nicht mehr in Listen, Suche, Fotos und Ordnerwahl (Drive 1.11 `vaultsApart`, sonst filtert die App selbst) - Neuer Tresor nur im Plus-Menü mit „Wer darf ihn öffnen?“, immer im Wurzelordner des gewählten Bereichs - Tresor-Sperre: Gerätebestätigung vor dem Öffnen, Sperre nach Inaktivität und im Hintergrund, Keystore-Schlüssel mit Nutzer-Authentifizierung (Migration ohne neue Freigabe), Hinweis ohne Displaysperre - Drive-Einstellungen als eigener Bildschirm (KI pro Bereich, Ansicht, Zwischenspeicher, Hochladen nur im WLAN, Benachrichtigungen) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/drive/DriveApi.kt | 27 +- .../gatecontrol/android/drive/DriveCache.kt | 41 ++ .../gatecontrol/android/drive/DriveJson.kt | 14 + .../gatecontrol/android/drive/DriveLogic.kt | 29 + .../gatecontrol/android/drive/DriveModels.kt | 5 + .../android/drive/DrivePreviews.kt | 3 + .../gatecontrol/android/drive/DrivePush.kt | 4 + .../android/drive/DriveSettings.kt | 90 +++ .../android/drive/DriveTransfers.kt | 17 + .../gatecontrol/android/drive/DriveTree.kt | 86 +++ .../android/drive/DriveUploadNetwork.kt | 53 ++ .../android/drive/backup/DriveBackupWork.kt | 11 +- .../drive/backup/DriveFolderBackupWork.kt | 11 +- .../drive/vault/DriveVaultDeviceKey.kt | 171 +++++- .../android/drive/vault/DriveVaultKeystore.kt | 77 +++ .../android/drive/vault/DriveVaultLock.kt | 130 ++++ .../android/drive/vault/DriveVaultNames.kt | 80 +++ .../android/drive/vault/DriveVaultSession.kt | 77 ++- .../android/navigation/AppNavigation.kt | 16 +- .../gatecontrol/android/navigation/Screen.kt | 2 + .../gatecontrol/android/push/PushNotifier.kt | 3 + .../android/ui/drive/DriveComponents.kt | 9 +- .../android/ui/drive/DriveFolderScreen.kt | 19 +- .../android/ui/drive/DriveHomeScreen.kt | 435 +++++++++----- .../android/ui/drive/DriveSettingsScreen.kt | 553 ++++++++++++++++++ .../android/ui/drive/DriveVaultScreen.kt | 235 +++++++- .../android/ui/drive/DriveViewModels.kt | 162 +++-- .../android/ui/settings/SettingsScreen.kt | 60 +- app/src/main/res/values-de/strings_drive3.xml | 3 - app/src/main/res/values-de/strings_drive4.xml | 2 - app/src/main/res/values-de/strings_drive6.xml | 84 +++ app/src/main/res/values/strings_drive3.xml | 3 - app/src/main/res/values/strings_drive4.xml | 2 - app/src/main/res/values/strings_drive6.xml | 84 +++ .../android/drive/DriveSettingsLogicTest.kt | 67 +++ .../android/drive/DriveTreeTest.kt | 136 +++++ .../drive/vault/DriveVaultDeviceKeyTest.kt | 107 +++- .../drive/vault/DriveVaultNamesTest.kt | 41 ++ 38 files changed, 2629 insertions(+), 320 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveCache.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveSettings.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveTree.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/DriveUploadNetwork.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultKeystore.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultLock.kt create mode 100644 app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultNames.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt create mode 100644 app/src/main/res/values-de/strings_drive6.xml create mode 100644 app/src/main/res/values/strings_drive6.xml create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveSettingsLogicTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/DriveTreeTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultNamesTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt index e6456b61..3834c41d 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -140,7 +140,22 @@ class DriveApi @Inject constructor( val head = call(request = { pluginGet(DRIVE_PLUGIN, "nodes/$id", lang()) }, parse = { it }) if (head !is DResult.Ok) return head.notOk() val q = mapOf("sort" to sort, "order" to order, "offset" to offset.toString(), "limit" to limit.toString()) - return get("nodes/$id/children", q) { DriveJson.folder(head.value, it) } + // Drive 1.11: a vault folder is opened from the menu only, never from a list (an older Drive still lists them) + return get("nodes/$id/children", q) { DriveJson.folder(head.value, it)?.let(DriveLogic::withoutVaults) } + } + + /** + * The subfolders of [id] for the menu's tree, by name, never a vault folder. With [apart] (Drive 1.11 + * `vaultsApart`) the server sends folders only, each with `hasDirs`; else the folders of the usual list. + */ + suspend fun dirs(id: Long, apart: Boolean, limit: Int = 500): DResult> { + val q = buildMap { + put("sort", "name") + put("order", "asc") + put("limit", limit.toString()) + if (apart) put("dirs", "1") + } + return get("nodes/$id/children", q, DriveJson::treeDirs) } /** A new folder; a taken name gets a free one ([rename]) or `409 exists`. */ @@ -188,7 +203,7 @@ class DriveApi @Inject constructor( put("q", q) if (area != null) put("area", DriveJson.areaId(area) ?: return bad()) } - return get("search", query, DriveJson::items) + return get("search", query) { o -> DriveJson.items(o)?.let(DriveLogic::withoutVaults) } } suspend fun changes(since: Long, limit: Int = 500): DResult = @@ -229,14 +244,16 @@ class DriveApi @Inject constructor( * [camera]: only the camera uploads (Drive 1.10, feature `photosFolder`). */ suspend fun photos(area: String? = null, before: String? = null, limit: Int = 120, camera: Boolean = false): DResult = - get("photos", DriveJson.photosQuery(area, before, limit, camera) ?: return bad(), DriveJson::photos) + get("photos", DriveJson.photosQuery(area, before, limit, camera) ?: return bad()) { o -> DriveJson.photos(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } suspend fun photoMonths(area: String? = null, camera: Boolean = false): DResult> = get("photos/months", DriveJson.photosQuery(area, camera = camera) ?: return bad(), DriveJson::months) /** Photos of this calendar day ([date] `YYYY-MM-DD`, else the server's today) in earlier years. */ suspend fun memories(date: String? = null, camera: Boolean = false): DResult> = - get("photos/memories", DriveJson.photosQuery(camera = camera, date = date) ?: return bad(), DriveJson::memories) + get("photos/memories", DriveJson.photosQuery(camera = camera, date = date) ?: return bad()) { o -> + DriveJson.memories(o)?.map { it.copy(items = DriveLogic.withoutVaults(it.items)) } + } /** * The camera uploads' folder ("Meine Dateien › Bilder › Kamera-Uploads"), made when it is missing (`POST photos/camera`, @@ -255,7 +272,7 @@ class DriveApi @Inject constructor( if (before != null) put("before", before) put("limit", limit.coerceIn(1, 200).toString()) } - return get("albums/$album", q, DriveJson::albumPage) + return get("albums/$album", q) { o -> DriveJson.albumPage(o)?.let { it.copy(items = DriveLogic.withoutVaults(it.items)) } } } suspend fun updateAlbum(id: String, name: String? = null, coverId: Long? = null, frame: Boolean? = null): DResult { diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveCache.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveCache.kt new file mode 100644 index 00000000..49af7e1d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveCache.kt @@ -0,0 +1,41 @@ +package com.gatecontrol.android.drive + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Zwischenspeicher" of the Drive settings (Drive 1.11): the preview pictures and the files opened from Drive on this + * phone. Never the vault's keys, its decrypted copies (they go when the vault locks) or uploads still waiting. + */ +@Singleton +class DriveCache @Inject constructor( + @ApplicationContext private val context: Context, + private val previews: DrivePreviews, +) { + private fun dirs(): List = CLEARABLE.map { File(context.cacheDir, it) } + + /** Bytes in the cache. */ + suspend fun size(): Long = withContext(Dispatchers.IO) { dirs().sumOf(::bytes) } + + /** Empties the cache. */ + suspend fun clear() = withContext(Dispatchers.IO) { + previews.clearMemory() + dirs().forEach { d -> d.listFiles()?.forEach { runCatching { it.deleteRecursively() } } } + } + + private fun bytes(f: File): Long = when { + f.isFile -> f.length() + f.isDirectory -> f.listFiles()?.sumOf(::bytes) ?: 0L + else -> 0L + } + + internal companion object { + /** Preview pictures ([DrivePreviews]) and opened files ([DriveTransfers.fetch]); uploads (`drive-up`) stay. */ + val CLEARABLE = listOf("drive-previews", "drive-open") + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt index fffaef75..26182f3c 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -242,6 +242,20 @@ internal object DriveJson { fun items(o: JsonObject): List? = o.arr("items")?.mapNotNull { (it as? JsonObject)?.let(::node) } + /** `GET nodes//children[?dirs=1]` → the folders for the menu's tree (`hasDirs` from Drive 1.11 on). */ + fun treeDirs(o: JsonObject): List? { + val arr = o.arr("items") ?: return null + val nodes = ArrayList() + val hasDirs = HashMap() + for (e in arr) { + val item = e as? JsonObject ?: continue + val n = node(item) ?: continue + nodes += n + item.bool("hasDirs")?.let { hasDirs[n.id] = it } + } + return DriveTree.dirsOf(nodes, hasDirs) + } + /** * `GET nodes//content` → the media id to download (the call also tells a child that a parent looked), or * [EMPTY_CONTENT] for an empty file: Drive 1.5 keeps those without media (`empty: true`, `size: 0`). diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt index e2b7bd8f..92b6b69d 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt @@ -376,4 +376,33 @@ internal object DriveLogic { /** A vault's nodes with their names: folders first, then by name. */ fun sortVault(items: List>): List> = items.sortedWith(compareBy>({ !it.first.isDir }, { it.second.lowercase(Locale.ROOT) })) + + // ─── Drive 1.11: vaults only in the menu ─────────────────────── + + /** + * A folder's content without its vault folders (Drive 1.11: vaults are opened from the menu only). Inside a vault + * nothing is left out. A Drive with `vaultsApart` leaves them out itself; an older one lists them. + */ + fun withoutVaults(folder: DFolder): DFolder { + if (folder.node.encrypted) return folder + val items = folder.items.filter { !it.encrypted } + if (items.size == folder.items.size) return folder + return folder.copy(items = items, total = (folder.total - (folder.items.size - items.size)).coerceAtLeast(items.size)) + } + + /** Search, photos, albums: nothing of a vault. */ + fun withoutVaults(items: List): List = items.filter { !it.encrypted } + + /** + * The areas a new vault can go to ("Wer darf ihn öffnen?"): Meine Dateien ("Nur ich"), the family (with a family + * seat) and, with a business seat, each team folder with the write right. The vault lies in the area's top folder. + */ + fun vaultAreas(areas: List, seat: String?): List = areas.filter { a -> + a.writable && when (a.kind) { + "mine" -> true + "family" -> seat == "family" + "team" -> seat == "business" + else -> false + } + }.sortedBy { listOf("mine", "family", "team").indexOf(it.kind) } } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt index 49ce1424..657422a8 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -82,6 +82,11 @@ data class DMe( const val FEATURE_OFFICE = "office" /** Drive 1.10: the camera uploads' folder (`photos/camera`) and the Photos tab's filter `camera=1`. */ const val FEATURE_PHOTOS_FOLDER = "photosFolder" + /** + * Drive 1.11: vault folders stay out of the lists (`children`), `children?dirs=1` gives the menu's tree with + * `hasDirs`, a new vault always lies in its area's top folder. + */ + const val FEATURE_VAULTS_APART = "vaultsApart" } } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePreviews.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePreviews.kt index 7809b657..44ffae3f 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DrivePreviews.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePreviews.kt @@ -43,6 +43,9 @@ class DrivePreviews @Inject constructor( private val inFlight = ConcurrentHashMap>() private val dir: File get() = File(context.cacheDir, "drive-previews").apply { mkdirs() } + /** "Zwischenspeicher leeren": the pictures in memory go too (the disk is emptied by [DriveCache]). */ + fun clearMemory() = memory.evictAll() + /** Already in memory (no I/O): for the first frame. */ fun peek(node: DNode, large: Boolean = false): Bitmap? = key(node, large)?.let { memory.get(it) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/DrivePush.kt b/app/src/main/java/com/gatecontrol/android/drive/DrivePush.kt index 6e7bd675..f72014a4 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DrivePush.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DrivePush.kt @@ -14,6 +14,10 @@ object DrivePush { fun isDriveMessage(message: PushMessage): Boolean = message.topic.startsWith(TOPIC_PREFIX) + /** Whether [message] is shown as a notification: Drive's deadlines and vault requests can be off ([DriveSettingsValues]). */ + fun shown(message: PushMessage, settings: DriveSettingsValues): Boolean = + !isDriveMessage(message) || DriveSettingsLogic.showPush(message.topic.removePrefix(TOPIC_PREFIX), settings) + /** `file:` | `vault::`; null for other messages. */ fun destination(message: PushMessage): String? { if (!isDriveMessage(message)) return null diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveSettings.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveSettings.kt new file mode 100644 index 00000000..018bbf52 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveSettings.kt @@ -0,0 +1,90 @@ +package com.gatecontrol.android.drive + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** The Drive settings kept on this phone (Drive 1.11, screen "Drive-Einstellungen"); the AI per area is the server's. */ +data class DriveSettingsValues( + /** A vault opens only after the fingerprint / face or the phone's PIN (the device key needs it too). */ + val vaultBiometric: Boolean = true, + /** An open vault locks after this many minutes without use ([DriveSettingsLogic.AUTO_LOCK_CHOICES]). */ + val autoLockMinutes: Int = DriveSettingsLogic.DEFAULT_AUTO_LOCK, + /** An open vault locks as soon as the app goes to the background. */ + val lockOnLeave: Boolean = true, + /** Every upload waits for WLAN (the photo and folder backup keep their own setting on top). */ + val wifiOnly: Boolean = false, + /** Drive pushes shown on this phone: reminders of deadlines, requests to approve a device for a vault. */ + val notifyDeadlines: Boolean = true, + val notifyVaultRequests: Boolean = true, +) + +/** Pure rules of the settings (unit-tested). */ +internal object DriveSettingsLogic { + const val DEFAULT_AUTO_LOCK = 3 + val AUTO_LOCK_CHOICES = listOf(1, 3, 5, 15) + + /** A stored value that is not one of the choices becomes the default. */ + fun autoLock(raw: Int): Int = raw.takeIf { it in AUTO_LOCK_CHOICES } ?: DEFAULT_AUTO_LOCK + + /** Whether a Drive push of [topic] (`deadline`, `vault`, …; without the plugin prefix) is shown. */ + fun showPush(topic: String, s: DriveSettingsValues): Boolean = when (topic) { + "deadline" -> s.notifyDeadlines + "vault" -> s.notifyVaultRequests + else -> true + } + + /** May an upload go now: always, or with "Hochladen nur im WLAN" only on an unmetered network. */ + fun uploadAllowed(wifiOnly: Boolean, connected: Boolean, unmetered: Boolean): Boolean = connected && (!wifiOnly || unmetered) +} + +@Singleton +class DriveSettings @Inject constructor(@ApplicationContext context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + private val _values = MutableStateFlow(read()) + val values: StateFlow = _values.asStateFlow() + + val current: DriveSettingsValues get() = _values.value + + private fun read() = DriveSettingsValues( + vaultBiometric = prefs.getBoolean(KEY_BIOMETRIC, true), + autoLockMinutes = DriveSettingsLogic.autoLock(prefs.getInt(KEY_AUTO_LOCK, DriveSettingsLogic.DEFAULT_AUTO_LOCK)), + lockOnLeave = prefs.getBoolean(KEY_LEAVE, true), + wifiOnly = prefs.getBoolean(KEY_WIFI, false), + notifyDeadlines = prefs.getBoolean(KEY_NOTIFY_DEADLINES, true), + notifyVaultRequests = prefs.getBoolean(KEY_NOTIFY_VAULT, true), + ) + + fun update(change: (DriveSettingsValues) -> DriveSettingsValues) { + synchronized(this) { + val next = change(_values.value).let { it.copy(autoLockMinutes = DriveSettingsLogic.autoLock(it.autoLockMinutes)) } + prefs.edit() + .putBoolean(KEY_BIOMETRIC, next.vaultBiometric) + .putInt(KEY_AUTO_LOCK, next.autoLockMinutes) + .putBoolean(KEY_LEAVE, next.lockOnLeave) + .putBoolean(KEY_WIFI, next.wifiOnly) + .putBoolean(KEY_NOTIFY_DEADLINES, next.notifyDeadlines) + .putBoolean(KEY_NOTIFY_VAULT, next.notifyVaultRequests) + .apply() + _values.value = next + } + } + + companion object { + private const val PREFS = "drive_settings" + private const val KEY_BIOMETRIC = "vault_biometric" + private const val KEY_AUTO_LOCK = "vault_auto_lock" + private const val KEY_LEAVE = "vault_lock_on_leave" + private const val KEY_WIFI = "upload_wifi_only" + private const val KEY_NOTIFY_DEADLINES = "notify_deadlines" + private const val KEY_NOTIFY_VAULT = "notify_vault" + + /** "Hochladen nur im WLAN", for the backups' scheduling (outside of injection). */ + fun wifiOnly(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_WIFI, false) + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt index 8205eed9..dfa9a3e5 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt @@ -44,6 +44,7 @@ class DriveTransfers @Inject constructor( private val local: DriveLocal, private val live: DriveLive, private val dates: DriveTakenAt, + private val network: DriveUploadNetwork, ) { enum class State { QUEUED, RUNNING, CONFLICT, DONE, SAME, SKIPPED, FAILED } @@ -98,6 +99,12 @@ class DriveTransfers @Inject constructor( val paused: StateFlow = _paused.asStateFlow() init { + // "Hochladen nur im WLAN" (Drive 1.11): the queue goes on once WLAN is there (or the setting is off) + scope.launch { + network.allowed.collect { ok -> + if (ok && mutex.withLock { queue.isNotEmpty() }) pump() + } + } scope.launch { live.locked.collect { locked -> if (!locked && _paused.value) { @@ -112,6 +119,11 @@ class DriveTransfers @Inject constructor( private val _changed = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) val changed: SharedFlow = _changed.asSharedFlow() + private companion object { + /** An upload waits for WLAN ("Hochladen nur im WLAN"). */ + const val WAIT_WIFI = "wifi_wait" + } + private fun set(id: Long, f: (Upload) -> Upload) = _uploads.update { list -> list.map { if (it.id == id) f(it) else it } } /** Queues [uris] for the folder [folderId]. */ @@ -187,6 +199,11 @@ class DriveTransfers @Inject constructor( try { while (true) { if (_paused.value) break + if (network.waitsForWifi()) { + // every waiting upload says why it waits; the network's change starts the queue again + _uploads.update { list -> list.map { if (it.state == State.QUEUED) it.copy(code = WAIT_WIFI) else it } } + break + } val job = mutex.withLock { queue.removeFirstOrNull() } ?: break runCatching { run(job) }.onFailure { e -> if (e is CancellationException) throw e diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveTree.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveTree.kt new file mode 100644 index 00000000..4db19134 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveTree.kt @@ -0,0 +1,86 @@ +package com.gatecontrol.android.drive + +/** + * The folder tree of the Drive menu (Drive 1.11, pure and unit-tested): each area with its folders, opened one level at + * a time. A folder's subfolders are loaded only when it is opened ([DTreeDir.hasDirs] tells whether there are any; null + * while unknown — an older server without `vaultsApart` — so the arrow shows until its folders are loaded). The opened + * folders are kept on the device ([encode] / [decode]). + */ +internal object DriveTree { + + /** One row of the menu's tree. [depth] 0 is the area itself (its top folder [id]). */ + data class Row( + val id: Long, + val name: String, + val depth: Int, + /** The area the folder lies in. */ + val areaId: String, + /** Set on the area's own row (depth 0). */ + val area: DArea?, + /** The arrow: it has (or may have) subfolders. */ + val expandable: Boolean, + val expanded: Boolean, + /** Opened, its folders on the way. */ + val loading: Boolean, + ) + + /** + * The rows of [roots] (the areas, in their order): an opened folder is followed by its loaded subfolders, one level + * deeper. A folder opened but not loaded yet shows as [Row.loading] (its id is in [needs]). + */ + fun flatten(roots: List, expanded: Set, children: Map>): List { + val out = ArrayList() + fun walk(areaId: String, dirs: List, depth: Int, seen: Set) { + for (d in dirs) { + if (d.id in seen) continue // never in a loop, whatever the server answers + val kids = children[d.id] + val expandable = when { + kids != null -> kids.isNotEmpty() + else -> d.hasDirs != false + } + val open = expandable && d.id in expanded + out += Row(d.id, d.name, depth, areaId, null, expandable, open, loading = open && kids == null) + if (open && kids != null) walk(areaId, kids, depth + 1, seen + d.id) + } + } + for (a in roots) { + val kids = children[a.rootId] + val expandable = kids == null || kids.isNotEmpty() + val open = expandable && a.rootId in expanded + out += Row(a.rootId, a.label, 0, a.id, a, expandable, open, loading = open && kids == null) + if (open && kids != null) walk(a.id, kids, 1, setOf(a.rootId)) + } + return out + } + + /** The opened folders whose subfolders are not loaded yet (shown as loading). */ + fun needs(rows: List): List = rows.filter { it.loading }.map { it.id } + + /** [id] opened or closed. */ + fun toggle(expanded: Set, id: Long): Set = if (id in expanded) expanded - id else expanded + id + + /** The persisted form of the opened folders (a string set of ids). */ + fun encode(expanded: Set): Set = expanded.take(MAX_KEPT).map { it.toString() }.toSet() + + fun decode(raw: Set?): Set = raw.orEmpty().mapNotNull { it.toLongOrNull()?.takeIf { id -> id > 0 } }.toSet() + + /** + * The folders of a `children` answer for the tree: folders only, never a vault folder (Drive 1.11 leaves them out + * itself with `vaultsApart`; an older Drive lists them), sorted by name. + */ + fun dirsOf(items: List, hasDirs: Map = emptyMap()): List = + items.filter { it.isDir && !it.encrypted } + .map { DTreeDir(it.id, it.name, hasDirs[it.id]) } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + + /** The "Persönlich" group: Meine Dateien. */ + fun personal(areas: List): List = areas.filter { it.kind == "mine" } + + /** The "Gemeinsam" group: the family, the children's folders, the team folders. */ + fun shared(areas: List): List = areas.filter { it.kind != "mine" } + + private const val MAX_KEPT = 500 +} + +/** A folder of the menu's tree: [hasDirs] whether it has subfolders (null: not known before they are loaded). */ +data class DTreeDir(val id: Long, val name: String, val hasDirs: Boolean?) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveUploadNetwork.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveUploadNetwork.kt new file mode 100644 index 00000000..bd3bc8e0 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveUploadNetwork.kt @@ -0,0 +1,53 @@ +package com.gatecontrol.android.drive + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * "Hochladen nur im WLAN" (Drive 1.11): whether an upload may go now — any network, or with the setting only an + * unmetered one (WLAN, Ethernet). [allowed] changes with the network and the setting; the uploads wait while it is false. + */ +@Singleton +class DriveUploadNetwork @Inject constructor( + @ApplicationContext context: Context, + private val settings: DriveSettings, +) { + private val cm = context.getSystemService(ConnectivityManager::class.java) + private val _unmetered = MutableStateFlow(true) + private val _allowed = MutableStateFlow(true) + val allowed: StateFlow = _allowed.asStateFlow() + + init { + runCatching { + cm?.activeNetwork?.let { cm.getNetworkCapabilities(it) }?.let(::take) + cm?.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = take(caps) + }) + }.onFailure { Timber.w("Drive: network not watched (%s)", it.javaClass.simpleName) } + update() + } + + private fun take(caps: NetworkCapabilities) { + _unmetered.value = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) || + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) + update() + } + + /** After a change of the setting (the screen calls it) or the network. */ + fun update() { + // offline the uploads go on as before (they fail and say so); only "metered with WLAN only" makes them wait + _allowed.value = DriveSettingsLogic.uploadAllowed(settings.current.wifiOnly, connected = true, unmetered = _unmetered.value) + } + + /** Uploads must wait for WLAN now. */ + fun waitsForWifi(): Boolean = !_allowed.value +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt index f14a3cc4..b771d9e9 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveBackupWork.kt @@ -82,8 +82,9 @@ object DriveBackupWork { private const val CHANNEL = "drive_backup" private const val NOTIFICATION_ID = 0x6E000001 - private fun constraints(cfg: DriveBackupConfig) = Constraints.Builder() - .setRequiredNetworkType(if (cfg.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + // its own "Nur im WLAN" and Drive's "Hochladen nur im WLAN" (Drive-Einstellungen, for every upload) + private fun constraints(context: Context, cfg: DriveBackupConfig) = Constraints.Builder() + .setRequiredNetworkType(if (cfg.wifiOnly || com.gatecontrol.android.drive.DriveSettings.wifiOnly(context)) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresCharging(cfg.chargingOnly) .setRequiresBatteryNotLow(true) .build() @@ -99,7 +100,7 @@ object DriveBackupWork { return } val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) - .setConstraints(constraints(cfg)) + .setConstraints(constraints(context, cfg)) .build() wm.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, periodic) watch(context, again = false) @@ -118,7 +119,7 @@ object DriveBackupWork { private fun enqueueRun(context: Context, cfg: DriveBackupConfig, policy: ExistingWorkPolicy) { val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints(cfg)) + .setConstraints(constraints(context, cfg)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .build() WorkManager.getInstance(context).enqueueUniqueWork(RUN, policy, request) @@ -150,7 +151,7 @@ object DriveBackupWork { val cfg = backup(context).config.value if (!cfg.enabled) return runCatching { - val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(cfg)).build() + val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(context, cfg)).build() WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, periodic) watch(context, again = false) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveFolderBackupWork.kt b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveFolderBackupWork.kt index 0b1aa887..f2ceb6a3 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/backup/DriveFolderBackupWork.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/backup/DriveFolderBackupWork.kt @@ -81,8 +81,9 @@ object DriveFolderBackupWork { private const val CHANNEL = "drive_backup" private const val NOTIFICATION_ID = 0x6E000002 - private fun constraints(s: DriveFolderSettings) = Constraints.Builder() - .setRequiredNetworkType(if (s.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + // its own "Nur im WLAN" and Drive's "Hochladen nur im WLAN" (Drive-Einstellungen, for every upload) + private fun constraints(context: Context, s: DriveFolderSettings) = Constraints.Builder() + .setRequiredNetworkType(if (s.wifiOnly || com.gatecontrol.android.drive.DriveSettings.wifiOnly(context)) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresCharging(s.chargingOnly) .setRequiresBatteryNotLow(true) .build() @@ -97,7 +98,7 @@ object DriveFolderBackupWork { wm.cancelUniqueWork(TRIGGER) return } - val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(settings)).build() + val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(context, settings)).build() wm.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, periodic) watch(context, again = false) // A pass waiting under the old conditions (e.g. for WLAN) gets the new ones. @@ -115,7 +116,7 @@ object DriveFolderBackupWork { private fun enqueueRun(context: Context, settings: DriveFolderSettings, policy: ExistingWorkPolicy) { val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints(settings)) + .setConstraints(constraints(context, settings)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .build() WorkManager.getInstance(context).enqueueUniqueWork(RUN, policy, request) @@ -143,7 +144,7 @@ object DriveFolderBackupWork { val b = folders(context) if (!b.active()) return runCatching { - val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(b.settings.value)).build() + val periodic = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS).setConstraints(constraints(context, b.settings.value)).build() WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, periodic) watch(context, again = false) } diff --git a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKey.kt b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKey.kt index 665bce6f..ccc695e2 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKey.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKey.kt @@ -1,6 +1,7 @@ package com.gatecontrol.android.drive.vault import android.content.Context +import com.gatecontrol.android.drive.DriveSettings import com.gatecontrol.android.familie.emergency.AesGcmCipher import com.gatecontrol.android.familie.emergency.CacheCipher import dagger.hilt.android.qualifiers.ApplicationContext @@ -11,53 +12,169 @@ import javax.inject.Singleton /** * This phone's X25519 key for Drive's vault folders. The private key lies on the disk only encrypted with a - * non-exportable AES-256-GCM key of the Android Keystore (`MAGIC ‖ iv ‖ ciphertext`, in the no-backup files: it never - * leaves the phone, a restored phone is a new device). A key the Keystore can no longer open (the Keystore was reset) - * is replaced: the phone then asks the vaults again like a new device. + * non-exportable AES-256-GCM key of the Android Keystore, in the no-backup files: it never leaves the phone, a restored + * phone is a new device. + * + * Since Drive 1.11 that Keystore key asks for the person ([auth]: fingerprint, face or the phone's PIN, valid for + * [DriveVaultKeystore.AUTH_TIMEOUT_S] after the confirmation the vault shows): without it the private key cannot be + * read ([AuthRequired]). The public key is stored next to it, so the device id and the safety number never need it. + * Format v2: `MAGIC2 ‖ protection (1 = auth, 0 = plain) ‖ publicKey(32) ‖ iv ‖ ciphertext`; v1 (`MAGIC1 ‖ iv ‖ + * ciphertext`, the [plain] key) is read as it is and moved over with [protect] after the next confirmation — the same + * key pair, so the device stays approved. A key the Keystore can no longer open (reset, the screen lock removed) is + * replaced: the phone then asks the vaults again like a new device. */ @Singleton -class DriveVaultDeviceKey internal constructor(private val file: File, private val cipher: CacheCipher) { +class DriveVaultDeviceKey internal constructor( + private val file: File, + /** The Keystore key without confirmation (v1, and the setting "Mit Fingerabdruck öffnen" off). */ + private val plain: CacheCipher, + /** The Keystore key that asks for the person. */ + private val auth: CacheCipher, + /** A new key pair is stored with [auth] (the setting). */ + private val wantAuth: () -> Boolean = { true }, + /** The [plain] Keystore key is no longer needed (after the move to [auth]). */ + private val dropPlain: () -> Unit = {}, +) { - @Inject constructor(@ApplicationContext context: Context) : this( + @Inject constructor(@ApplicationContext context: Context, settings: DriveSettings) : this( File(File(context.noBackupFilesDir, "drive-vault"), "device.key"), AesGcmCipher { AesGcmCipher.keystoreKey(KEYSTORE_ALIAS) }, + DriveVaultKeystore.authCipher(AUTH_ALIAS), + { settings.current.vaultBiometric }, + { DriveVaultKeystore.delete(KEYSTORE_ALIAS) }, ) + /** The private key needs the person's confirmation first (fingerprint, face or PIN). */ + class AuthRequired : Exception("device confirmation required") + + /** The phone has no screen lock: a key that asks for the person cannot be made. */ + class NoScreenLock : Exception("no screen lock") + + /** The Keystore failed for another reason (the key itself may be fine): nothing is replaced, the vault says so. */ + class Unavailable(cause: Throwable) : Exception("keystore unavailable", cause) + + private class Stored(val auth: Boolean, val publicKey: ByteArray, val blob: ByteArray) + @Volatile private var publicKey: ByteArray? = null - /** The key pair (made on first use); the caller wipes the private key after use. */ + /** The key pair (made on first use); the caller wipes the private key after use. Throws [AuthRequired]. */ @Synchronized fun keyPair(): DriveVaultCrypto.KeyPair { - read()?.let { return it } - val pair = DriveVaultCrypto.newKeyPair() - file.parentFile?.mkdirs() - val tmp = File(file.parentFile, file.name + ".tmp") - tmp.writeBytes(MAGIC + cipher.encrypt(pair.privateKey)) - if (!tmp.renameTo(file)) { - file.delete() - check(tmp.renameTo(file)) { "device key not stored" } + load()?.let { s -> + val priv = try { + (if (s.auth) auth else plain).decrypt(s.blob) + } catch (e: AuthRequired) { + throw e + } catch (e: Unavailable) { + throw e + } catch (e: Exception) { + unreadable(e) + null + } + if (priv != null) { + try { + if (priv.size == 32) { + val pair = DriveVaultCrypto.keyFromSeed(priv) + if (pair.publicKey.contentEquals(s.publicKey)) return pair.also { publicKey = it.publicKey } + pair.wipe() + } + } finally { + priv.fill(0) + } + unreadable(IllegalStateException("bad key")) + } } - publicKey = pair.publicKey - return pair + return create() + } + + /** Whether the stored key asks for the person (null: there is none yet). */ + @Synchronized + fun protectedByAuth(): Boolean? = load()?.auth + + /** + * Stores the private key under the Keystore key the setting wants ([withAuth]): the move of a v1 key (Drive 1.11) + * and the switch "Mit Fingerabdruck öffnen". Needs the person's confirmation just before when either side asks for + * it ([AuthRequired], [NoScreenLock] otherwise; the key stays as it was). The key pair stays the same. + */ + @Synchronized + fun protect(withAuth: Boolean) { + val s = load() ?: return + if (s.auth == withAuth) return + val from = if (s.auth) auth else plain + val to = if (withAuth) auth else plain + val priv = from.decrypt(s.blob) + try { + val blob = to.encrypt(priv) + check(to.decrypt(blob).let { back -> back.contentEquals(priv).also { back.fill(0) } }) { "not readable back" } + write(Stored(withAuth, s.publicKey, blob)) + } finally { + priv.fill(0) + } + if (withAuth) runCatching { dropPlain() } + Timber.i("Drive vault: device key now %s", if (withAuth) "behind the device confirmation" else "without confirmation") } - private fun read(): DriveVaultCrypto.KeyPair? { + /** The stored key, its public part readable without the person; a v1 file gets the public part now. */ + private fun load(): Stored? { if (!file.isFile) return null return try { val blob = file.readBytes() - require(blob.size > MAGIC.size && blob.copyOf(MAGIC.size).contentEquals(MAGIC)) { "unknown format" } - val priv = cipher.decrypt(blob.copyOfRange(MAGIC.size, blob.size)) - require(priv.size == 32) { "bad key" } - DriveVaultCrypto.keyFromSeed(priv).also { priv.fill(0); publicKey = it.publicKey } + when { + blob.size > MAGIC2.size + 1 + 32 && blob.copyOf(MAGIC2.size).contentEquals(MAGIC2) -> { + val flag = blob[MAGIC2.size].toInt() + require(flag == 0 || flag == 1) { "unknown protection" } + val pub = blob.copyOfRange(MAGIC2.size + 1, MAGIC2.size + 33) + Stored(flag == 1, pub, blob.copyOfRange(MAGIC2.size + 33, blob.size)).also { publicKey = pub } + } + blob.size > MAGIC1.size && blob.copyOf(MAGIC1.size).contentEquals(MAGIC1) -> { + // v1 (before Drive 1.11): the plain Keystore key; the public part is written next to it at once + val cipherBlob = blob.copyOfRange(MAGIC1.size, blob.size) + val priv = plain.decrypt(cipherBlob) + val pub = try { + require(priv.size == 32) { "bad key" } + DriveVaultCrypto.keyFromSeed(priv).let { p -> p.publicKey.also { p.wipe() } } + } finally { + priv.fill(0) + } + Stored(false, pub, cipherBlob).also { write(it); publicKey = pub } + } + else -> error("unknown format") + } } catch (e: Exception) { - Timber.w("Drive vault: the device key is unreadable (%s), a new one is made", e.javaClass.simpleName) - file.delete() + unreadable(e) null } } - /** The public key (32 raw bytes). */ - fun publicKey(): ByteArray = publicKey ?: keyPair().let { p -> p.publicKey.also { p.wipe() } } + private fun unreadable(e: Exception) { + Timber.w("Drive vault: the device key is unreadable (%s), a new one is made", e.javaClass.simpleName) + file.delete() + publicKey = null + } + + private fun create(): DriveVaultCrypto.KeyPair { + val pair = DriveVaultCrypto.newKeyPair() + // behind the confirmation when the setting wants it and the person just confirmed; else plain for now — + // the next confirmation moves it ([protect]) + val stored = (if (wantAuth()) runCatching { Stored(true, pair.publicKey, auth.encrypt(pair.privateKey)) }.getOrNull() else null) + ?: Stored(false, pair.publicKey, plain.encrypt(pair.privateKey)) + write(stored) + publicKey = pair.publicKey + return pair + } + + private fun write(s: Stored) { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeBytes(MAGIC2 + byteArrayOf(if (s.auth) 1 else 0) + s.publicKey + s.blob) + if (!tmp.renameTo(file)) { + file.delete() + check(tmp.renameTo(file)) { "device key not stored" } + } + } + + /** The public key (32 raw bytes): stored in the clear, never needs the person (only a new key pair is made). */ + fun publicKey(): ByteArray = publicKey ?: synchronized(this) { load()?.publicKey } ?: keyPair().let { p -> p.publicKey.also { p.wipe() } } fun deviceId(): String = DriveVaultCrypto.deviceId(publicKey()) @@ -65,6 +182,8 @@ class DriveVaultDeviceKey internal constructor(private val file: File, private v private companion object { const val KEYSTORE_ALIAS = "gatecontrol_drive_vault_device" - val MAGIC = byteArrayOf(0x47, 0x44, 0x56, 0x4b, 0x01) // "GDVK" v1 + const val AUTH_ALIAS = "gatecontrol_drive_vault_device_auth" + val MAGIC1 = byteArrayOf(0x47, 0x44, 0x56, 0x4b, 0x01) // "GDVK" v1 + val MAGIC2 = byteArrayOf(0x47, 0x44, 0x56, 0x4b, 0x02) // "GDVK" v2 } } diff --git a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultKeystore.kt b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultKeystore.kt new file mode 100644 index 00000000..795fc86a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultKeystore.kt @@ -0,0 +1,77 @@ +package com.gatecontrol.android.drive.vault + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.security.keystore.UserNotAuthenticatedException +import com.gatecontrol.android.familie.emergency.AesGcmCipher +import com.gatecontrol.android.familie.emergency.CacheCipher +import timber.log.Timber +import java.security.KeyStore +import javax.crypto.AEADBadTagException +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * The Keystore key behind the vault's device key (Drive 1.11): AES-256-GCM that works only for + * [AUTH_TIMEOUT_S] seconds after the person confirmed with a strong biometric or the phone's PIN / pattern / password + * (the vault's [com.gatecontrol.android.ui.components.GcDeviceAuth] prompt). It needs a screen lock; removing that, the + * system wipes the key (the phone then asks the vaults again like a new device). + */ +internal object DriveVaultKeystore { + /** Long enough for `GET keys` and unwrapping after the prompt, short enough to need it for each opening. */ + const val AUTH_TIMEOUT_S = 60 + + private const val PROVIDER = "AndroidKeyStore" + + fun authCipher(alias: String): CacheCipher = object : CacheCipher { + private val inner = AesGcmCipher { authKey(alias) } + + override fun encrypt(plain: ByteArray): ByteArray = guard(alias) { inner.encrypt(plain) } + + override fun decrypt(blob: ByteArray): ByteArray = guard(alias) { inner.decrypt(blob) } + } + + private inline fun guard(alias: String, block: () -> T): T = try { + block() + } catch (e: UserNotAuthenticatedException) { + throw DriveVaultDeviceKey.AuthRequired() + } catch (e: KeyPermanentlyInvalidatedException) { + // the screen lock was removed (or reset): the key is gone for good, a new one can be made + delete(alias) + throw e + } catch (e: AEADBadTagException) { + throw e + } catch (e: DriveVaultDeviceKey.NoScreenLock) { + throw e + } catch (e: Exception) { + Timber.w("Drive vault: keystore failed (%s)", e.javaClass.simpleName) + throw DriveVaultDeviceKey.Unavailable(e) + } + + private fun authKey(alias: String): SecretKey { + val ks = KeyStore.getInstance(PROVIDER).apply { load(null) } + (ks.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(true) + .setUserAuthenticationParameters(AUTH_TIMEOUT_S, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) + // a new fingerprint does not lock the person out (the PIN opens it as well) + .setInvalidatedByBiometricEnrollment(false) + .build() + return try { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).apply { init(spec) }.generateKey() + } catch (e: java.security.InvalidAlgorithmParameterException) { + // "Secure lock screen must be enabled" + throw DriveVaultDeviceKey.NoScreenLock() + } catch (e: IllegalStateException) { + throw DriveVaultDeviceKey.NoScreenLock() + } + } + + fun delete(alias: String) { + runCatching { KeyStore.getInstance(PROVIDER).apply { load(null) }.deleteEntry(alias) } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultLock.kt b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultLock.kt new file mode 100644 index 00000000..b849e4cf --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultLock.kt @@ -0,0 +1,130 @@ +package com.gatecontrol.android.drive.vault + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.gatecontrol.android.drive.DriveSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import javax.inject.Singleton + +/** When an open vault locks (pure, unit-tested). */ +internal object DriveVaultLockTimer { + /** Milliseconds until the vaults lock for want of use (≤ 0: now). */ + fun remaining(now: Long, lastUse: Long, minutes: Int): Long = lastUse + minutes * 60_000L - now + + /** + * The app went to the background: lock now? Not when the setting is off, nor when the vault itself handed the person + * to another app a moment ago ([away]: the file picker for an upload, an app showing a decrypted file). + */ + fun lockOnStop(lockOnLeave: Boolean, away: Boolean): Boolean = lockOnLeave && !away + + /** The timer is due, but an upload or download of the vault still runs: it waits this long and looks again. */ + const val BUSY_RECHECK_MS = 5_000L +} + +/** + * The vault lock of Drive 1.11 (like OneDrive's Personal Vault): an open vault locks after the time set without use + * (standard 3 minutes, [touch] counts as use) and — unless switched off — as soon as the app goes to the background. + * Locking is [DriveVaultSession.lock]; it waits for a running upload or download ([busy]). + */ +@Singleton +class DriveVaultLock @Inject constructor( + private val session: DriveVaultSession, + private val settings: DriveSettings, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Volatile private var lastUse = 0L + @Volatile private var away = false + @Volatile private var pending = false + private val running = AtomicInteger(0) + private var timer: Job? = null + + private val observer = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + away = false + } + + override fun onStop(owner: LifecycleOwner) { + val wasAway = away + away = false + if (!session.anyUnlocked()) return + if (DriveVaultLockTimer.lockOnStop(settings.current.lockOnLeave, wasAway)) { + Timber.i("Drive vault: locked (app in the background)") + lockNow() + } + } + } + + init { + // the process lifecycle wants the main thread + Handler(Looper.getMainLooper()).post { runCatching { ProcessLifecycleOwner.get().lifecycle.addObserver(observer) } } + } + + /** The person used the vault (opened it, a folder, a file …): the time without use starts again. */ + fun touch() { + lastUse = SystemClock.elapsedRealtime() + if (session.anyUnlocked()) start() + } + + /** The vault hands the person to another app now (picker, viewer): leaving the app this once does not lock. */ + fun away() { + away = true + touch() + } + + /** Runs [block] (an upload or a download of the vault) while the lock waits. */ + suspend fun busy(block: suspend () -> T): T { + running.incrementAndGet() + touch() + try { + return block() + } finally { + running.decrementAndGet() + touch() + if (pending && running.get() == 0) lockNow() + } + } + + /** Locks every vault now (after a running transfer). */ + fun lockNow() { + if (running.get() > 0) { + pending = true + return + } + pending = false + synchronized(this) { timer?.cancel() } + session.lock() + } + + @Synchronized + private fun start() { + if (timer?.isActive == true) return + timer = scope.launch { + while (session.anyUnlocked()) { + val wait = DriveVaultLockTimer.remaining(SystemClock.elapsedRealtime(), lastUse, settings.current.autoLockMinutes) + if (wait > 0) { + delay(wait) + continue + } + if (running.get() > 0) { + delay(DriveVaultLockTimer.BUSY_RECHECK_MS) + continue + } + Timber.i("Drive vault: locked (not used for a while)") + session.lock() + break + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultNames.kt b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultNames.kt new file mode 100644 index 00000000..ca445d81 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultNames.kt @@ -0,0 +1,80 @@ +package com.gatecontrol.android.drive.vault + +import android.content.Context +import com.gatecontrol.android.familie.emergency.AesGcmCipher +import com.gatecontrol.android.familie.emergency.CacheCipher +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The names of the vaults themselves (not of anything inside), so the menu lists a locked vault by its name ("Steuer · + * Privat · gesperrt"). Kept on this phone only, encrypted with a Keystore key of its own (`MAGIC ‖ iv ‖ ciphertext` of + * `{ "": "" }` in the no-backup files). Learnt whenever a vault is open; anything unreadable is dropped. + */ +@Singleton +class DriveVaultNames internal constructor(private val file: File, private val cipher: CacheCipher) { + + @Inject constructor(@ApplicationContext context: Context) : this( + File(File(context.noBackupFilesDir, "drive-vault"), "names.bin"), + AesGcmCipher { AesGcmCipher.keystoreKey(KEYSTORE_ALIAS) }, + ) + + private var cache: MutableMap? = null + + @Synchronized + fun get(vaultId: String): String? = all()[vaultId] + + @Synchronized + fun put(vaultId: String, name: String) { + val m = all() + if (m[vaultId] == name) return + m[vaultId] = name.take(255) + write(m) + } + + /** Only these vaults are still there (the others go). */ + @Synchronized + fun keep(vaultIds: Set) { + val m = all() + if (m.keys.all { it in vaultIds }) return + m.keys.retainAll(vaultIds) + write(m) + } + + private fun all(): MutableMap { + cache?.let { return it } + val m = HashMap() + if (file.isFile) { + runCatching { + val blob = file.readBytes() + require(blob.size > MAGIC.size && blob.copyOf(MAGIC.size).contentEquals(MAGIC)) + val o = JsonParser.parseString(String(cipher.decrypt(blob.copyOfRange(MAGIC.size, blob.size)), Charsets.UTF_8)).asJsonObject + o.entrySet().forEach { (k, v) -> if (v.isJsonPrimitive) m[k] = v.asString } + }.onFailure { file.delete() } + } + cache = m + return m + } + + private fun write(m: Map) { + runCatching { + val o = JsonObject().apply { m.forEach { (k, v) -> addProperty(k, v) } } + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeBytes(MAGIC + cipher.encrypt(o.toString().toByteArray(Charsets.UTF_8))) + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + } + } + + private companion object { + const val KEYSTORE_ALIAS = "gatecontrol_drive_vault_names" + val MAGIC = byteArrayOf(0x47, 0x44, 0x56, 0x4e, 0x01) // "GDVN" v1 + } +} diff --git a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultSession.kt b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultSession.kt index 8d279d95..0badefc6 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultSession.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/vault/DriveVaultSession.kt @@ -18,6 +18,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import timber.log.Timber import java.io.File import java.io.IOException @@ -36,6 +40,7 @@ class DriveVaultSession @Inject constructor( @ApplicationContext private val context: Context, private val api: DriveApi, private val deviceKey: DriveVaultDeviceKey, + private val vaultNames: DriveVaultNames, ) { /** vault id → key version → TK. Never written anywhere. */ private val keys = ConcurrentHashMap>() @@ -66,11 +71,46 @@ class DriveVaultSession @Inject constructor( private fun tk(vaultId: String, version: Int): ByteArray? = keys[vaultId]?.get(version) - /** Forgets the keys of [vaultId] (or of all vaults). */ - fun lock(vaultId: String? = null) { - val gone = if (vaultId == null) keys.values.toList().also { keys.clear() } else listOfNotNull(keys.remove(vaultId)) + private val _locks = MutableSharedFlow(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** The vaults were locked ([lock]): an open vault page closes its content and asks again. */ + val locks: SharedFlow = _locks.asSharedFlow() + + /** Some vault's keys are in memory. */ + fun anyUnlocked(): Boolean = keys.values.any { it.isNotEmpty() } + + /** + * Locks every vault (Drive 1.11: after a while without use, when the app goes to the background, or by hand): the + * keys in memory are overwritten and forgotten, the decrypted names forgotten, the decrypted copies deleted. + */ + fun lock() { + val gone = keys.values.toList().also { keys.clear() } gone.forEach { m -> m.values.forEach { it.fill(0) } } synchronized(names) { names.clear() } + wipeOpened() + _locks.tryEmit(Unit) + } + + /** Whether this device is approved for [vault] (the vault's own list; opening still asks for the confirmation). */ + fun approved(vault: DVault): Boolean { + val me = runCatching { deviceId() }.getOrNull() ?: return false + return vault.myDevices.any { it.deviceId == me && it.state == "approved" } + } + + /** The vault's name for the menu: decrypted while it is open, else the one this phone remembers. */ + fun label(vault: DVault): String? = nameOf(vault) ?: vaultNames.get(vault.id) + + /** Only these vaults are still there (the remembered names of others go). */ + fun keepNames(vaultIds: Set) = vaultNames.keep(vaultIds) + + /** + * The device key under the protection the setting wants (Drive 1.11: behind the confirmation; the switch + * "Mit Fingerabdruck öffnen"). Called right after the person confirmed; failure leaves the key as it was. + */ + suspend fun protectDeviceKey(withAuth: Boolean): Boolean = withContext(Dispatchers.IO) { + runCatching { deviceKey.protect(withAuth) } + .onFailure { Timber.w("Drive vault: device key not moved (%s)", it.javaClass.simpleName) } + .isSuccess } private fun keep(vaultId: String, version: Int, tk: ByteArray) { @@ -104,7 +144,16 @@ class DriveVaultSession @Inject constructor( if (k.state != "approved") { return@withContext DResult.Ok(if (k.state == "pending") Access.Pending(safetyNumber()) else Access.Unknown(revoked = k.state == "revoked")) } - unwrapAll(vault.id, k.keys) + try { + unwrapAll(vault.id, k.keys) + } catch (e: DriveVaultDeviceKey.AuthRequired) { + // the device key waits for the person's confirmation (Drive 1.11) + return@withContext DResult.Failed(CODE_AUTH, 0) + } catch (e: DriveVaultDeviceKey.NoScreenLock) { + return@withContext DResult.Failed(CODE_NO_LOCK, 0) + } catch (e: DriveVaultDeviceKey.Unavailable) { + return@withContext DResult.Failed("decrypt", 0) + } if (unlocked(vault.id)) DResult.Ok(Access.Open) else DResult.Failed("decrypt", 0) } is DResult.Failed -> if (r.code == "not_found") DResult.Ok(Access.Unknown(revoked = false)) else r @@ -322,6 +371,7 @@ class DriveVaultSession @Inject constructor( is DResult.Ok -> { keep(id, 1, tk) current[id] = r.value.vault.keyVersion + vaultNames.put(r.value.vault.id, name.trim()) DResult.Ok(Created(r.value.vault, r.value.node, DriveVaultCrypto.formatRecoveryKey(recovery))) } else -> { @@ -350,13 +400,16 @@ class DriveVaultSession @Inject constructor( } } - /** The name of the vault itself (its folder's nameCipher). */ + /** The name of the vault itself (its folder's nameCipher); remembered for the menu ([label]). */ fun nameOf(vault: DVault): String? { val cipher = vault.nameCipher ?: return null synchronized(names) { names[cipher] }?.let { return it } if (!unlocked(vault.id)) return null return runCatching { DriveVaultCrypto.decryptName({ tk(vault.id, it) }, vault.id, cipher) }.getOrNull() - ?.also { n -> synchronized(names) { names[cipher] = n } } + ?.also { n -> + synchronized(names) { names[cipher] = n } + vaultNames.put(vault.id, n) + } } private fun encryptName(vaultId: String, name: String): Pair? { @@ -502,10 +555,14 @@ class DriveVaultSession @Inject constructor( runCatching { openDir().deleteRecursively() } } - private companion object { - const val PLATFORM = "android" - const val ROTATE_TRIES = 3 + companion object { + /** `open`: the device key needs the person's confirmation first. */ + const val CODE_AUTH = "auth_required" + /** `open`: the phone has no screen lock, the vault cannot be opened. */ + const val CODE_NO_LOCK = "no_screen_lock" + private const val PLATFORM = "android" + private const val ROTATE_TRIES = 3 /** The opaque name of a vault node (32 hex characters, plugin `vault.js` OPAQUE_RE). */ - val OPAQUE = Regex("^[0-9a-f]{32}$") + private val OPAQUE = Regex("^[0-9a-f]{32}$") } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index d17e69b6..6ca62271 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -420,8 +420,7 @@ fun AppNavigation( onNavigateToProfile = { paneKey = SettingsPane.Profile.key }, onNavigateToConnection = { paneKey = SettingsPane.Connection.key }, onNavigateToShared = if (plugins.gatedrop) { { paneKey = SettingsPane.Shared.key } } else null, - onNavigateToDriveBackup = if (plugins.drive) { { navController.navigate(Screen.DriveBackup.route) { launchSingleTop = true } } } else null, - onNavigateToDriveFolders = if (plugins.drive) { { navController.navigate(Screen.DriveFolders.route) { launchSingleTop = true } } } else null, + onNavigateToDrive = if (plugins.drive) { { navController.navigate(Screen.DriveSettings.route) { launchSingleTop = true } } } else null, selectedPane = pane, viewModel = settingsViewModel, ) @@ -451,8 +450,7 @@ fun AppNavigation( onNavigateToProfile = openProfileFromSettings, onNavigateToConnection = { navController.navigate(Screen.ConnectionDetails.route) { launchSingleTop = true } }, onNavigateToShared = if (plugins.gatedrop) { { navController.navigate(Screen.Shared.create()) { launchSingleTop = true } } } else null, - onNavigateToDriveBackup = if (plugins.drive) { { navController.navigate(Screen.DriveBackup.route) { launchSingleTop = true } } } else null, - onNavigateToDriveFolders = if (plugins.drive) { { navController.navigate(Screen.DriveFolders.route) { launchSingleTop = true } } } else null, + onNavigateToDrive = if (plugins.drive) { { navController.navigate(Screen.DriveSettings.route) { launchSingleTop = true } } } else null, ) } } @@ -1509,6 +1507,7 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga file = file, deadlines = { go(Screen.DriveDeadlines.route) }, vault = vault, + settings = { go(Screen.DriveSettings.route) }, ), ) } @@ -1538,6 +1537,15 @@ private fun NavGraphBuilder.driveRoutes(navController: NavHostController, naviga composable(Screen.DriveSearch.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveSearchScreen(onBack = back, onFolder = folder) } } + composable(Screen.DriveSettings.route) { + GcContentWidth { + com.gatecontrol.android.ui.drive.DriveSettingsScreen( + onBack = back, + onBackup = { go(Screen.DriveBackup.route) }, + onFolders = { go(Screen.DriveFolders.route) }, + ) + } + } composable(Screen.DriveBackup.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveBackupScreen(onBack = back) } } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index 2e372df2..c0edb570 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -141,6 +141,8 @@ sealed class Screen(val route: String) { data object DriveTrash : Screen("drive/trash") data object DriveSearch : Screen("drive/search") data object DriveBackup : Screen("drive/backup") + /** "Drive-Einstellungen" (Drive 1.11): the gear of the Drive menu, the app settings' row "Drive". */ + data object DriveSettings : Screen("drive/settings") /** "Ordner sichern": phone folders backed up into Drive. */ data object DriveFolders : Screen("drive/folders") /** The storage overview (Drive 1.7). */ diff --git a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt index cb7d2639..bc43061d 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt @@ -62,6 +62,7 @@ class PushNotifier @Inject constructor( private val chats: dagger.Lazy, private val gatedrop: dagger.Lazy, private val vault: dagger.Lazy, + private val driveSettings: dagger.Lazy, ) : PushPresenter { companion object { @@ -141,6 +142,8 @@ class PushNotifier @Inject constructor( override suspend fun show(message: PushMessage, notificationId: Int, channel: ChannelSpec, quiet: Boolean) { // GateDrop deliveries: its own notification with Öffnen / Speichern / Kopieren. if (runCatching { gatedrop.get().show(message, notificationId, quiet) }.getOrDefault(false)) return + // Drive-Einstellungen › Benachrichtigungen: deadlines and vault requests can stay off this phone (still in the inbox) + if (!com.gatecontrol.android.drive.DrivePush.shown(message, driveSettings.get().current)) return val ctx = localized() if (nm.getNotificationChannel(channel.id) == null) createChannels(ctx, listOf(channel)) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt index 8acf9524..c0fab24b 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveComponents.kt @@ -313,6 +313,9 @@ internal fun driveText(context: Context, code: String): String = when (code) { "recovery_key" -> context.getString(R.string.drive_code_recovery_key) "done_rotated" -> context.getString(R.string.drive_done_rotated) "old_keys" -> context.getString(R.string.drive_code_old_keys) + "wifi_wait" -> context.getString(R.string.drive_upload_wifi_wait) + "auth_required" -> context.getString(R.string.drive_vault_gate_title) + "no_screen_lock" -> context.getString(R.string.drive_vault_no_lock) else -> context.getString(R.string.drive_error_generic, code) } @@ -426,7 +429,11 @@ internal fun UploadsSheet( uploads.takeLast(50).forEach { u -> val line = when (u.state) { DriveTransfers.State.QUEUED -> - if (u.code == "device_locked") stringResource(R.string.drive_upload_paused_locked) else stringResource(R.string.drive_upload_waiting) + when (u.code) { + "device_locked" -> stringResource(R.string.drive_upload_paused_locked) + "wifi_wait" -> stringResource(R.string.drive_upload_wifi_wait) + else -> stringResource(R.string.drive_upload_waiting) + } DriveTransfers.State.RUNNING -> if (u.total > 0) "${DriveLogic.size(u.sent)} / ${DriveLogic.size(u.total)}" else DriveLogic.size(u.sent) DriveTransfers.State.CONFLICT -> stringResource(R.string.drive_upload_conflict) DriveTransfers.State.DONE -> stringResource(R.string.drive_upload_done) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt index 79fc8819..f3eaf1f2 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveFolderScreen.kt @@ -74,6 +74,7 @@ import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcChevron import com.gatecontrol.android.ui.components.GcFilterChip import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIconSquare import com.gatecontrol.android.ui.components.GcIcons import com.gatecontrol.android.ui.components.GcListRow import com.gatecontrol.android.ui.components.GcOutlineButton @@ -125,6 +126,8 @@ internal fun DriveFolderContent( onVault: (vaultId: String, folderId: Long) -> Unit = { _, _ -> }, /** List or tiles (shared with the Drive tab's own bar, [FolderViewActions]). */ gridState: androidx.compose.runtime.MutableState = rememberDriveGrid(), + /** A tap on the path above (the Drive tab: the folder shows in the tab itself); else [onFolder]. */ + onCrumb: ((Long) -> Unit)? = null, ) { val state by viewModel.state.collectAsStateWithLifecycle() val area by viewModel.area.collectAsStateWithLifecycle() @@ -190,7 +193,7 @@ internal fun DriveFolderContent( Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { topBar(title) { FolderViewActions(viewModel, gridState) } - if (folder != null && folder.crumbs.isNotEmpty()) Crumbs(folder, area?.label, onFolder) + if (folder != null && folder.crumbs.isNotEmpty()) Crumbs(folder, area?.label, onCrumb ?: onFolder) Column( // bottom: room for the round button over the last item Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 88.dp), @@ -266,13 +269,19 @@ internal fun DriveFolderContent( onClick = { addOpen = false; startScan() }, leading = { Icon(GcIcons.Scan, contentDescription = null, tint = GateControlTheme.extraColors.muted) }, ) - // Drive 1.9 (feature `vault`): an end-to-end encrypted folder (not inside another vault) + // Drive 1.9 (feature `vault`): an end-to-end encrypted folder. Drive 1.11: only here, in the top folder of + // the area chosen in the dialog, and it shows in the menu under "Tresore" (never in the lists) if (me?.has(DMe.FEATURE_VAULT) == true && folder?.node?.encrypted == false) { + GcRowDivider(Modifier.padding(horizontal = 20.dp, vertical = 6.dp)) GcListRow( title = stringResource(R.string.drive_vault_new), - description = stringResource(R.string.drive_vault_new_desc), + description = stringResource(R.string.drive_vault_new_menu_hint), onClick = { addOpen = false; viewModel.startVault() }, - leading = { Icon(GcIcons.Vault, contentDescription = null, tint = GateControlTheme.extraColors.muted) }, + leading = { + GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.warnBg) { + Icon(GcIcons.Lock, contentDescription = null, tint = GateControlTheme.extraColors.warn, modifier = Modifier.size(20.dp)) + } + }, ) } } @@ -457,7 +466,7 @@ internal fun DriveFolderContent( newVault?.let { v -> val key = v.recoveryKey if (key == null) { - NewVaultDialog(busy = v.busy, onCreate = viewModel::createVault, onDismiss = viewModel::closeVault) + NewVaultDialog(v, onCreate = viewModel::createVault, onDismiss = viewModel::closeVault) } else { RecoveryKeyDialog(key, onDone = { viewModel.closeVault() diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt index 96b379ba..7cd55d8f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt @@ -69,6 +69,22 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import com.gatecontrol.android.ui.theme.GateControlTheme import kotlinx.coroutines.launch +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import com.gatecontrol.android.drive.DriveTree /** Where the Drive tab leads. */ data class DriveNav( @@ -87,6 +103,8 @@ data class DriveNav( val deadlines: () -> Unit = {}, /** A vault folder (Drive 1.9). */ val vault: (String, Long) -> Unit = { _, _ -> }, + /** "Drive-Einstellungen" (Drive 1.11, the gear of the menu). */ + val settings: () -> Unit = {}, ) /** @@ -120,12 +138,25 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel viewModel.clearMessage() } } - BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } - val home = (state as? DriveLoad.Ready)?.value val usable = home != null && home.me.licenceValid && home.me.seat != null && home.areas.isNotEmpty() val current = if (usable) home?.let { h -> h.areas.firstOrNull { it.id == selected } ?: viewModel.current(h.areas) } else null - LaunchedEffect(current?.rootId) { current?.let { folderViewModel.open(it.rootId) } } + val folderState by folderViewModel.state.collectAsStateWithLifecycle() + /** The folder shown on the Files page (the area's top or, from the menu's tree, one below). */ + val shown = (folderState as? DriveLoad.Ready)?.value?.node + /** A folder picked in the menu's tree of another area: opened once that area is the current one. */ + var pendingFolder by remember { mutableStateOf(null) } + LaunchedEffect(current?.rootId) { + val c = current ?: return@LaunchedEffect + val target = pendingFolder + pendingFolder = null + when { + target != null -> folderViewModel.open(target) + // back on the tab: a folder of this area stays open + (folderViewModel.state.value as? DriveLoad.Ready)?.value?.node?.areaId == c.id -> Unit + else -> folderViewModel.open(c.rootId) + } + } val openMenu: () -> Unit = { scope.launch { drawer.open() } } // Drive 1.7 (feature `photos`): the tab has two pages, the files and the photos; a swipe goes from one to the other. val hasPhotos = me?.has(DMe.FEATURE_PHOTOS) == true @@ -148,8 +179,20 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel onPauseOrDispose { } } val vaults by viewModel.vaults.collectAsStateWithLifecycle() - LaunchedEffect(drawer.isOpen, me?.features) { if (drawer.isOpen) viewModel.loadVaults() } + val expanded by viewModel.expanded.collectAsStateWithLifecycle() + val children by viewModel.children.collectAsStateWithLifecycle() + LaunchedEffect(drawer.isOpen, me?.features) { + if (drawer.isOpen) { + viewModel.loadVaults() + viewModel.refreshTree() + } + } val grid = rememberDriveGrid() + // a folder below the area's top (opened from the menu): back goes up a level + BackHandler(enabled = !drawer.isOpen && !onPhotosPage && shown != null && shown.parentId != null && shown.areaId == current?.id) { + shown?.parentId?.let(folderViewModel::open) + } + BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } ModalNavigationDrawer( drawerState = drawer, @@ -157,31 +200,54 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel drawerContent = { ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.background) { val close: () -> Unit = { scope.launch { drawer.close() } } + val areas = home?.areas.orEmpty().takeIf { usable }.orEmpty() + val personal = DriveTree.flatten(DriveTree.personal(areas), expanded, children) + val shared = DriveTree.flatten(DriveTree.shared(areas), expanded, children) + // opened folders of an earlier run: their subfolders load when the menu shows them + val needs = DriveTree.needs(personal + shared) + LaunchedEffect(needs) { needs.forEach { viewModel.loadDirs(it) } } DriveDrawer( - areas = home?.areas.orEmpty().takeIf { usable }.orEmpty(), - current = current, - onArea = { a -> viewModel.select(a); if (onPhotosPage) showPage(false); close() }, - onTrash = { close(); nav.trash() }, - onFolders = { close(); nav.folders() }, - onStorage = if (me?.has(DMe.FEATURE_STORAGE) == true) ({ close(); nav.storage() }) else null, - onDeadlines = if (me?.has(DMe.FEATURE_DOC_AI) == true) ({ close(); nav.deadlines() }) else null, - onImport = if (me?.has(DMe.FEATURE_CLOUD_IMPORT) == true) ({ - scope.launch { - drawer.close() - viewModel.importUrl()?.let { openPortal(context, it) } ?: Toast.makeText(context, R.string.drive_portal_unavailable, Toast.LENGTH_LONG).show() + me = me ?: home?.me, + areas = areas, + personal = personal, + shared = shared, + selected = shown?.id?.takeIf { !onPhotosPage }, + onToggle = viewModel::toggle, + onOpen = { row -> + val area = areas.firstOrNull { it.id == row.areaId } + if (area != null) { + if (area.id != current?.id) { + pendingFolder = row.id.takeIf { row.area == null } + viewModel.select(area) + } else { + folderViewModel.open(row.id) + } } - }) else null, - // Drive 1.8 (`docAi`): the AI per area, with the write right - docAi = current?.takeIf { me?.has(DMe.FEATURE_DOC_AI) == true && it.writable && it.docAi != null }, - onDocAi = viewModel::setDocAi, - // Drive 1.9 (`vault`): every vault, opened from here + if (onPhotosPage) showPage(false) + close() + }, + // Drive 1.9 (`vault`): the vaults are opened from here only (Drive 1.11) vaults = if (me?.has(DMe.FEATURE_VAULT) == true) vaults else null, onVault = { e -> close(); nav.vault(e.vault.id, e.vault.nodeId) }, - onNewVault = if (me?.has(DMe.FEATURE_VAULT) == true && current?.writable == true) ({ - close() - showPage(false) - folderViewModel.startVault() - }) else null, + icons = buildList { + add(DrawerIcon(GcIcons.Trash, R.string.drive_trash) { close(); nav.trash() }) + if (me?.has(DMe.FEATURE_STORAGE) == true) add(DrawerIcon(GcIcons.Activity, R.string.drive_storage_title) { close(); nav.storage() }) + add(DrawerIcon(GcIcons.Upload, R.string.drive_folders_title) { close(); nav.folders() }) + if (me?.has(DMe.FEATURE_DOC_AI) == true) add(DrawerIcon(GcIcons.Calendar, R.string.drive_deadlines) { close(); nav.deadlines() }) + // Drive 1.8 (`cloudImport`): the import runs in the portal only + if (me?.has(DMe.FEATURE_CLOUD_IMPORT) == true) { + add( + DrawerIcon(GcIcons.Download, R.string.drive_import) { + scope.launch { + drawer.close() + viewModel.importUrl()?.let { openPortal(context, it) } + ?: Toast.makeText(context, R.string.drive_portal_unavailable, Toast.LENGTH_LONG).show() + } + }, + ) + } + add(DrawerIcon(GcIcons.Settings, R.string.drive_settings_title) { close(); nav.settings() }) + }, ) } }, @@ -189,7 +255,11 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel if (home != null && current != null) { Column(Modifier.fillMaxSize()) { TabBar( - if (onPhotosPage) stringResource(R.string.drive_mode_photos) else current.label, + when { + onPhotosPage -> stringResource(R.string.drive_mode_photos) + shown != null && shown.parentId != null && shown.areaId == current.id -> shown.name + else -> current.label + }, onMenu = openMenu, onSearch = nav.search, actions = { if (!onPhotosPage) FolderViewActions(folderViewModel, grid) }, @@ -209,6 +279,7 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel folderViewModel = folderViewModel, nav = nav, grid = grid, + onCrumb = folderViewModel::open, header = { // Drive 1.6 (feature `guard`): unusual changes stopped — undo, release or look in the portal. val portal = DriveLogic.portalDriveUrl(me) @@ -277,6 +348,7 @@ private fun DriveFilesPage( nav: DriveNav, grid: androidx.compose.runtime.MutableState, header: @Composable () -> Unit, + onCrumb: (Long) -> Unit, ) { DriveFolderContent( viewModel = folderViewModel, @@ -286,6 +358,7 @@ private fun DriveFilesPage( gridState = grid, topBar = { _, _ -> }, header = header, + onCrumb = onCrumb, ) } @@ -356,139 +429,217 @@ private fun TabBar(title: String, onMenu: (() -> Unit)?, onSearch: (() -> Unit)? } } -/** The menu: the areas (with their storage), the trash, "Ordner sichern" and (Drive 1.7) the storage overview. */ +/** An icon of the menu's bottom bar: [label] is its description (read out, shown on a long press). */ +private data class DrawerIcon(val icon: androidx.compose.ui.graphics.vector.ImageVector, val label: Int, val onClick: () -> Unit) + +/** + * The menu (Drive 1.11): who and which seat, "Persönlich" and "Gemeinsam" with each area's folders as a tree (the arrow + * opens, the name shows the folder), the vaults (only here), and a bar of icons below — trash, storage, "Ordner sichern", + * deadlines, import (where Drive has them) and the Drive settings. + */ @Composable private fun DriveDrawer( + me: DMe?, areas: List, - current: DArea?, - onArea: (DArea) -> Unit, - onTrash: () -> Unit, - onFolders: () -> Unit, - onStorage: (() -> Unit)?, - onDeadlines: (() -> Unit)? = null, - onImport: (() -> Unit)? = null, - /** The area whose "KI für Dokumente" can be switched here (null: none). */ - docAi: DArea? = null, - onDocAi: (DArea, Boolean) -> Unit = { _, _ -> }, + personal: List, + shared: List, + selected: Long?, + onToggle: (Long) -> Unit, + onOpen: (DriveTree.Row) -> Unit, /** The vaults (null: Drive has none). */ - vaults: List? = null, - onVault: (DriveHomeViewModel.VaultEntry) -> Unit = {}, - onNewVault: (() -> Unit)? = null, + vaults: List?, + onVault: (DriveHomeViewModel.VaultEntry) -> Unit, + icons: List, ) { - Column(Modifier.fillMaxHeight().verticalScroll(rememberScrollState()).padding(vertical = 16.dp)) { - GcScreenTitle(stringResource(R.string.drive_title), modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) - if (areas.isNotEmpty()) { - GcSectionLabel(stringResource(R.string.drive_move_areas), modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 4.dp)) - areas.forEach { a -> - GcListRow( - title = a.label, - description = areaLine(a), - onClick = { onArea(a) }, - leading = { AreaIcon(a) }, - selected = a.id == current?.id, - ) + Column(Modifier.fillMaxHeight()) { + LazyColumn(Modifier.weight(1f), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 8.dp)) { + item(key = "head") { DrawerHead(me) } + if (personal.isNotEmpty()) { + item(key = "l-personal") { DrawerLabel(stringResource(R.string.drive_menu_personal)) } + items(personal, key = { "p" + it.id }) { r -> TreeRow(r, selected = r.id == selected, onToggle = onToggle, onOpen = onOpen) } } - if (docAi != null) { - GcSwitchRow( - label = stringResource(R.string.drive_area_doc_ai), - description = stringResource(R.string.drive_area_doc_ai_desc, docAi.label), - checked = docAi.docAi == true, - onCheckedChange = { onDocAi(docAi, it) }, - icon = GcIcons.Receipt, - ) + if (shared.isNotEmpty()) { + item(key = "l-shared") { DrawerLabel(stringResource(R.string.drive_menu_shared)) } + items(shared, key = { "s" + it.id }) { r -> TreeRow(r, selected = r.id == selected, onToggle = onToggle, onOpen = onOpen) } } - GcRowDivider(modifier = Modifier.padding(vertical = 8.dp)) + if (!vaults.isNullOrEmpty()) { + item(key = "l-vaults") { DrawerLabel(stringResource(R.string.drive_vaults)) } + items(vaults, key = { "v" + it.vault.id }) { e -> VaultRow(e, areas.firstOrNull { it.id == e.vault.areaId }, onClick = { onVault(e) }) } + } + } + GcRowDivider() + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + icons.forEach { DrawerIconButton(it) } } - // Drive 1.9 (`vault`): the vaults are opened from here (their folders also stay in the lists) - if (vaults != null && (vaults.isNotEmpty() || onNewVault != null)) { - GcSectionLabel(stringResource(R.string.drive_vaults), modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp)) - vaults.forEach { e -> - val area = areas.firstOrNull { it.id == e.vault.areaId }?.label - val status = when (e.state) { - DriveHomeViewModel.VaultState.OPEN -> if (e.vault.pending > 0) pluralStringResource(R.plurals.drive_vault_requests_banner, e.vault.pending, e.vault.pending) else null - DriveHomeViewModel.VaultState.PENDING -> stringResource(R.string.drive_vault_menu_pending) - DriveHomeViewModel.VaultState.LOCKED -> stringResource(R.string.drive_vault_menu_locked) + } +} + +@Composable +private fun DrawerHead(me: DMe?) { + val x = GateControlTheme.extraColors + Column(Modifier.padding(start = 20.dp, end = 20.dp, top = 22.dp, bottom = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + GcScreenTitle(stringResource(R.string.drive_title)) + val name = me?.name?.takeIf { it.isNotBlank() } + if (name != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(40.dp).clip(CircleShape).background(x.accentBg), contentAlignment = Alignment.Center) { + Text(name.trim().take(1).uppercase(), style = MaterialTheme.typography.titleMedium, color = x.accentText, fontWeight = FontWeight.Bold) + } + Column { + Text(name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + when (me.seat) { + "family" -> stringResource(R.string.drive_seat_family) + "business" -> stringResource(R.string.drive_seat_business) + else -> null + }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = x.muted) } } - val x = GateControlTheme.extraColors - GcListRow( - title = e.name ?: stringResource(R.string.drive_vault_menu_locked_title), - description = listOfNotNull(area, status).joinToString(" · ").ifEmpty { null }, - onClick = { onVault(e) }, - leading = { - GcIconSquare(size = 40.dp, background = x.warnBg) { - Icon(if (e.state == DriveHomeViewModel.VaultState.OPEN) GcIcons.Vault else GcIcons.Lock, contentDescription = null, tint = x.warn, modifier = Modifier.size(20.dp)) - } - }, - ) } - if (onNewVault != null) { - GcListRow( - title = stringResource(R.string.drive_vault_new), - description = stringResource(R.string.drive_vault_new_desc), - onClick = onNewVault, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Plus, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, + } + } +} + +@Composable +private fun DrawerLabel(text: String) { + GcSectionLabel(text, modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 18.dp, bottom = 6.dp)) +} + +/** + * A row of the tree: the arrow (opens or closes, ≥ 44 dp) and the name (shows the folder on the Files page). An area + * has its icon and its storage line; the folder shown now is highlighted. + */ +@Composable +private fun TreeRow(r: DriveTree.Row, selected: Boolean, onToggle: (Long) -> Unit, onOpen: (DriveTree.Row) -> Unit) { + val x = GateControlTheme.extraColors + val shape = RoundedCornerShape(12.dp) + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(shape) + .then(if (selected) Modifier.background(x.accentBg) else Modifier) + .heightIn(min = if (r.area != null) 56.dp else 44.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (r.depth > 0) Spacer(Modifier.width((20 * r.depth).dp)) + if (r.expandable) { + val label = stringResource(if (r.expanded) R.string.drive_tree_collapse else R.string.drive_tree_expand, r.name) + Box( + Modifier + .size(width = 40.dp, height = 44.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClickLabel = label) { onToggle(r.id) } + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (r.loading) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = x.muted) + } else { + Icon(if (r.expanded) GcIcons.ChevronDown else GcIcons.ChevronRight, contentDescription = null, tint = x.muted, modifier = Modifier.size(18.dp)) + } + } + } else { + Spacer(Modifier.width(40.dp)) + } + Row( + Modifier + .weight(1f) + .heightIn(min = 44.dp) + .clickable(role = Role.Button) { onOpen(r) } + .padding(top = 6.dp, bottom = 6.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (r.area != null) { + AreaIcon(r.area, size = 36) + } else { + Icon(GcIcons.Folder, contentDescription = null, tint = if (selected) x.accentText else x.muted, modifier = Modifier.size(20.dp)) + } + Column(Modifier.weight(1f)) { + Text( + r.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (r.area != null) FontWeight.SemiBold else FontWeight.Medium, + color = if (selected) x.accentText else MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + if (r.area != null) Text(areaLine(r.area), style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } - GcRowDivider(modifier = Modifier.padding(vertical = 8.dp)) } - GcListRow( - title = stringResource(R.string.drive_trash), - onClick = onTrash, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Trash, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, - ) - if (onStorage != null) { - GcListRow( - title = stringResource(R.string.drive_storage_title), - onClick = onStorage, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Activity, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, - ) + } +} + +/** A vault in the menu: lock icon, its name, whose it is ("Privat", "Mit Familie geteilt", a team) and whether it is locked. */ +@Composable +private fun VaultRow(e: DriveHomeViewModel.VaultEntry, area: DArea?, onClick: () -> Unit) { + val x = GateControlTheme.extraColors + val whose = when (area?.kind) { + "mine" -> stringResource(R.string.drive_vault_whose_mine) + "family" -> stringResource(R.string.drive_vault_whose_family) + "team" -> stringResource(R.string.drive_vault_whose_team, area.label) + null -> null + else -> area.label + } + val state = when (e.state) { + DriveHomeViewModel.VaultState.OPEN -> + if (e.vault.pending > 0) pluralStringResource(R.plurals.drive_vault_requests_banner, e.vault.pending, e.vault.pending) else stringResource(R.string.drive_vault_state_open) + DriveHomeViewModel.VaultState.LOCKED -> stringResource(R.string.drive_vault_state_locked) + DriveHomeViewModel.VaultState.PENDING -> stringResource(R.string.drive_vault_menu_pending) + DriveHomeViewModel.VaultState.NEW -> stringResource(R.string.drive_vault_state_new) + } + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(12.dp)) + .heightIn(min = 56.dp) + .clickable(role = Role.Button, onClick = onClick) + .padding(start = 40.dp, top = 6.dp, bottom = 6.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GcIconSquare(size = 36.dp, background = x.warnBg) { + Icon(if (e.state == DriveHomeViewModel.VaultState.OPEN) GcIcons.LockOpen else GcIcons.Lock, contentDescription = null, tint = x.warn, modifier = Modifier.size(18.dp)) } - if (onDeadlines != null) { - GcListRow( - title = stringResource(R.string.drive_deadlines), - onClick = onDeadlines, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Calendar, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, + Column(Modifier.weight(1f)) { + Text( + e.name ?: stringResource(R.string.drive_vault), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + Text(listOfNotNull(whose, state).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = x.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) } - GcListRow( - title = stringResource(R.string.drive_folders_title), - onClick = onFolders, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Upload, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, - ) - // Drive 1.8 (`cloudImport`): the import runs in the portal only - if (onImport != null) { - GcListRow( - title = stringResource(R.string.drive_import), - description = stringResource(R.string.drive_import_desc), - onClick = onImport, - leading = { - GcIconSquare(size = 40.dp, background = GateControlTheme.extraColors.panel2) { - Icon(GcIcons.Download, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) - } - }, - trailing = { Icon(GcIcons.External, contentDescription = null, tint = GateControlTheme.extraColors.muted, modifier = Modifier.size(18.dp)) }, + } +} + +/** A 44 dp icon of the menu's bottom bar, described for TalkBack and on a long press. */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun DrawerIconButton(i: DrawerIcon) { + val label = stringResource(i.label) + val context = LocalContext.current + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(13.dp)) + .background(GateControlTheme.extraColors.panel2) + .combinedClickable( + role = Role.Button, + onClickLabel = label, + onLongClick = { Toast.makeText(context, label, Toast.LENGTH_SHORT).show() }, + onClick = i.onClick, ) - } + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + Icon(i.icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt new file mode 100644 index 00000000..dd140e7b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt @@ -0,0 +1,553 @@ +package com.gatecontrol.android.ui.drive + +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DArea +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.drive.DriveCache +import com.gatecontrol.android.drive.DriveLive +import com.gatecontrol.android.drive.DriveLogic +import com.gatecontrol.android.drive.DriveSettings +import com.gatecontrol.android.drive.DriveSettingsLogic +import com.gatecontrol.android.drive.DriveSettingsValues +import com.gatecontrol.android.drive.DriveUploadNetwork +import com.gatecontrol.android.drive.backup.DriveBackup +import com.gatecontrol.android.drive.backup.DriveBackupConfig +import com.gatecontrol.android.drive.backup.DriveBackupFolder +import com.gatecontrol.android.drive.backup.DriveBackupWork +import com.gatecontrol.android.drive.backup.DriveFolderBackup +import com.gatecontrol.android.drive.backup.DriveFolderBackupWork +import com.gatecontrol.android.drive.vault.DriveVaultSession +import com.gatecontrol.android.ui.components.DeviceAuthResult +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcChevron +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.components.rememberGcDeviceAuth +import com.gatecontrol.android.ui.settings.SettingsGroup +import com.gatecontrol.android.ui.theme.GateControlTheme +import com.gatecontrol.android.ui.theme.MonoFontFamily +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** The Drive settings (Drive 1.11): kept on this phone, except the AI per area (the server's). */ +@HiltViewModel +class DriveSettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: DriveApi, + val live: DriveLive, + private val settings: DriveSettings, + private val network: DriveUploadNetwork, + private val cache: DriveCache, + private val session: DriveVaultSession, + private val backup: DriveBackup, + private val folderBackup: DriveFolderBackup, +) : ViewModel() { + val values: StateFlow = settings.values + val backupConfig: StateFlow = backup.config + val backupStats: StateFlow = backup.stats + val folders: StateFlow> = folderBackup.folders + + private val _areas = MutableStateFlow>(emptyList()) + val areas: StateFlow> = _areas.asStateFlow() + + private val _cacheSize = MutableStateFlow(null) + val cacheSize: StateFlow = _cacheSize.asStateFlow() + + /** This device for the vaults: its safety number and the vaults it holds keys for. */ + data class VaultInfo(val safetyNumber: String?, val approved: List) + + private val _vaultInfo = MutableStateFlow(VaultInfo(null, emptyList())) + val vaultInfo: StateFlow = _vaultInfo.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + private val _sort = MutableStateFlow(DriveViewPref.sort(context)) + internal val sort: StateFlow = _sort.asStateFlow() + private val _grid = MutableStateFlow(DriveViewPref.grid(context)) + val grid: StateFlow = _grid.asStateFlow() + private val _cameraOnly = MutableStateFlow(DriveViewPref.cameraOnly(context)) + val cameraOnly: StateFlow = _cameraOnly.asStateFlow() + + init { + if (live.me.value == null) viewModelScope.launch { live.current() } + } + + fun clearMessage() { _message.value = null } + + /** The screen shows (again): what may have changed elsewhere. */ + fun load() { + viewModelScope.launch { api.areas().valueOrNull()?.let { _areas.value = DriveLogic.sortAreas(it) } } + viewModelScope.launch { _cacheSize.value = cache.size() } + viewModelScope.launch { + val safety = withContext(Dispatchers.IO) { runCatching { session.safetyNumber() }.getOrNull() } + _vaultInfo.value = VaultInfo(safety, _vaultInfo.value.approved) + if (live.me.value?.has(DMe.FEATURE_VAULT) != true) return@launch + val list = api.vaults().valueOrNull() ?: return@launch + val approved = withContext(Dispatchers.IO) { list.filter { session.approved(it) }.map { session.label(it) } } + _vaultInfo.update { it.copy(approved = approved.map { n -> n ?: "" }) } + } + } + + /** The phone has a screen lock (needed for the vaults). */ + fun deviceSecure(): Boolean = context.getSystemService(KeyguardManager::class.java)?.isDeviceSecure == true + + /** "Mit Fingerabdruck öffnen" (after the person confirmed): the device key moves under the matching Keystore key. */ + fun setBiometric(on: Boolean) { + viewModelScope.launch { + if (session.protectDeviceKey(on)) { + settings.update { it.copy(vaultBiometric = on) } + } else { + _message.value = "device_key" + } + } + } + + fun setAutoLock(minutes: Int) = settings.update { it.copy(autoLockMinutes = minutes) } + + fun setLockOnLeave(on: Boolean) = settings.update { it.copy(lockOnLeave = on) } + + fun setNotifyDeadlines(on: Boolean) = settings.update { it.copy(notifyDeadlines = on) } + + fun setNotifyVault(on: Boolean) = settings.update { it.copy(notifyVaultRequests = on) } + + /** "Hochladen nur im WLAN": the queue and the backups follow at once. */ + fun setWifiOnly(on: Boolean) { + settings.update { it.copy(wifiOnly = on) } + network.update() + DriveBackupWork.apply(context, backup.config.value) + DriveFolderBackupWork.apply(context, folderBackup.active(), folderBackup.settings.value) + } + + fun clearCache() { + viewModelScope.launch { + cache.clear() + _cacheSize.value = cache.size() + _message.value = "cache_cleared" + } + } + + internal fun setSort(value: DriveLogic.FolderSort) { + _sort.value = value + DriveViewPref.setSort(context, value) + } + + fun setGrid(on: Boolean) { + _grid.value = on + DriveViewPref.setGrid(context, on) + } + + fun setCameraOnly(on: Boolean) { + _cameraOnly.value = on + DriveViewPref.setCameraOnly(context, on) + } + + /** "KI für Dokumente" of [area] on or off (the write right); the switch shows the new setting at once. */ + fun setDocAi(area: DArea, on: Boolean) { + val before = area.docAi + fun put(value: Boolean?) = _areas.update { list -> list.map { if (it.id == area.id) it.copy(docAi = value) else it } } + put(on) + viewModelScope.launch { + when (val r = api.setAreaDocAi(area.id, on)) { + is DResult.Ok -> put(r.value) + else -> { + put(before) + _message.value = r.errorCode() + } + } + } + } +} + +/** + * "Drive-Einstellungen" (Drive 1.11, the gear of the Drive menu; the app settings' row "Drive" leads here): backup, + * vaults, AI for documents, the view, the data on this phone, notifications. + */ +@Composable +fun DriveSettingsScreen( + onBack: () -> Unit, + onBackup: () -> Unit, + onFolders: () -> Unit, + viewModel: DriveSettingsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val x = GateControlTheme.extraColors + val values by viewModel.values.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() + val areas by viewModel.areas.collectAsStateWithLifecycle() + val cacheSize by viewModel.cacheSize.collectAsStateWithLifecycle() + val vaultInfo by viewModel.vaultInfo.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val backup by viewModel.backupConfig.collectAsStateWithLifecycle() + val stats by viewModel.backupStats.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() + val sort by viewModel.sort.collectAsStateWithLifecycle() + val grid by viewModel.grid.collectAsStateWithLifecycle() + val cameraOnly by viewModel.cameraOnly.collectAsStateWithLifecycle() + val auth = rememberGcDeviceAuth() + var secure by remember { mutableStateOf(viewModel.deviceSecure()) } + var askOff by remember { mutableStateOf(false) } + var pickLock by remember { mutableStateOf(false) } + var pickSort by remember { mutableStateOf(false) } + LifecycleResumeEffect(viewModel) { + secure = viewModel.deviceSecure() + viewModel.load() + onPauseOrDispose { } + } + LaunchedEffect(message) { + message?.let { + val text = when (it) { + "cache_cleared" -> context.getString(R.string.drive_settings_cache_cleared) + "device_key" -> context.getString(R.string.drive_settings_vault_key_failed) + else -> driveText(context, it) + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + val confirmTitle = stringResource(R.string.drive_vault_gate_prompt) + /** The switch "Mit Fingerabdruck öffnen": either way only after the person confirmed. */ + val switchBiometric: (Boolean) -> Unit = { on -> + auth.authenticate(confirmTitle, null) { r -> + when (r) { + DeviceAuthResult.SUCCESS -> viewModel.setBiometric(on) + DeviceAuthResult.CANCELLED -> Unit + DeviceAuthResult.FAILED, DeviceAuthResult.UNAVAILABLE -> + Toast.makeText(context, R.string.drive_vault_gate_failed, Toast.LENGTH_SHORT).show() + } + } + } + + Column(Modifier.fillMaxSize()) { + GcSubpageBar(stringResource(R.string.drive_settings_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // 1. Sicherung + SettingsGroup(stringResource(R.string.drive_settings_backup)) { + GcListRow( + title = stringResource(R.string.drive_backup_title), + description = if (backup.enabled) { + pluralStringResource(R.plurals.drive_backup_saved, stats.saved, stats.saved) + " · " + + stringResource(if (backup.wifiOnly) R.string.drive_backup_wifi_short else R.string.drive_backup_mobile_short) + } else { + stringResource(R.string.drive_backup_off_text) + }, + onClick = onBackup, + leading = { Icon(GcIcons.Camera, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + ) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_folders_title), + description = if (folders.isEmpty()) { + stringResource(R.string.drive_folders_off_text) + } else { + pluralStringResource(R.plurals.drive_folders_count, folders.size, folders.size) + }, + onClick = onFolders, + leading = { Icon(GcIcons.Folder, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { GcChevron() }, + ) + } + + // 2. Tresore + if (me?.has(DMe.FEATURE_VAULT) == true) { + SettingsGroup(stringResource(R.string.drive_vaults)) { + if (!secure) { + GcBanner(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), tone = GcTone.Warn, icon = GcIcons.Alert) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.drive_vault_no_lock), style = MaterialTheme.typography.bodyMedium) + GcOutlineButton(stringResource(R.string.drive_vault_no_lock_settings), onClick = { + runCatching { context.startActivity(Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + }, icon = GcIcons.Settings) + } + } + } + GcSwitchRow( + label = stringResource(R.string.drive_settings_vault_biometric), + description = stringResource(R.string.drive_settings_vault_biometric_desc), + checked = values.vaultBiometric, + onCheckedChange = { on -> if (on) switchBiometric(true) else askOff = true }, + icon = GcIcons.Fingerprint, + enabled = secure, + ) + if (!values.vaultBiometric) { + GcBanner(Modifier.padding(horizontal = 12.dp, vertical = 4.dp), tone = GcTone.Warn, icon = GcIcons.Alert) { + Text(stringResource(R.string.drive_settings_vault_biometric_off), style = MaterialTheme.typography.bodySmall) + } + } + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_settings_vault_auto_lock), + description = stringResource(R.string.drive_settings_vault_auto_lock_desc), + onClick = { pickLock = true }, + leading = { Icon(GcIcons.Clock, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { + Text( + pluralStringResource(R.plurals.drive_settings_vault_after, values.autoLockMinutes, values.autoLockMinutes), + style = MaterialTheme.typography.labelMedium, + color = x.muted, + ) + }, + ) + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_settings_vault_leave), + description = stringResource(R.string.drive_settings_vault_leave_desc), + checked = values.lockOnLeave, + onCheckedChange = viewModel::setLockOnLeave, + icon = GcIcons.Lock, + ) + GcRowDivider() + GcListRow( + title = stringResource(R.string.drive_settings_vault_safety), + description = stringResource(R.string.drive_settings_vault_safety_desc), + leading = { Icon(GcIcons.Key, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + ) + vaultInfo.safetyNumber?.let { + Text( + it, + style = MaterialTheme.typography.titleMedium.copy(fontFamily = MonoFontFamily), + modifier = Modifier.padding(start = 52.dp, end = 16.dp, bottom = 12.dp), + ) + } + GcRowDivider() + val approved = vaultInfo.approved + GcListRow( + title = stringResource(R.string.drive_settings_vault_approved), + description = if (approved.isEmpty()) { + stringResource(R.string.drive_settings_vault_approved_none) + } else { + approved.map { it.ifEmpty { context.getString(R.string.drive_vault) } }.joinToString(", ") + }, + leading = { Icon(GcIcons.Vault, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = if (approved.isNotEmpty()) ({ + Text(pluralStringResource(R.plurals.drive_settings_vault_count, approved.size, approved.size), style = MaterialTheme.typography.labelMedium, color = x.muted) + }) else null, + ) + } + } + + // 3. KI für Dokumente (per area, the server's; the write right) + val aiAreas = areas.filter { it.writable && it.docAi != null } + if (me?.has(DMe.FEATURE_DOC_AI) == true && aiAreas.isNotEmpty()) { + SettingsGroup(stringResource(R.string.drive_area_doc_ai)) { + aiAreas.forEachIndexed { i, a -> + if (i > 0) GcRowDivider() + GcSwitchRow( + label = a.label, + description = stringResource(R.string.drive_area_doc_ai_desc, a.label), + checked = a.docAi == true, + onCheckedChange = { on -> viewModel.setDocAi(a, on) }, + icon = GcIcons.Receipt, + ) + } + GcRowDivider() + Text( + stringResource(R.string.drive_settings_ai_vaults), + style = MaterialTheme.typography.bodySmall, + color = x.muted, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + + // 4. Ansicht + SettingsGroup(stringResource(R.string.drive_settings_view)) { + GcListRow( + title = stringResource(R.string.drive_sort), + onClick = { pickSort = true }, + leading = { Icon(GcIcons.Sliders, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { Text(sortLabel(sort), style = MaterialTheme.typography.labelMedium, color = x.muted) }, + ) + GcRowDivider() + Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.drive_settings_view_layout), style = MaterialTheme.typography.titleMedium) + GcSegmented( + options = listOf(false to stringResource(R.string.drive_settings_view_list), true to stringResource(R.string.drive_settings_view_grid)), + selected = grid, + onSelect = viewModel::setGrid, + ) + } + if (me?.has(DMe.FEATURE_PHOTOS_FOLDER) == true) { + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_settings_view_camera), + description = null, + checked = cameraOnly, + onCheckedChange = viewModel::setCameraOnly, + icon = GcIcons.Camera, + ) + } + } + + // 5. Daten auf diesem Gerät + SettingsGroup(stringResource(R.string.drive_settings_device)) { + GcListRow( + title = stringResource(R.string.drive_settings_cache), + description = stringResource(R.string.drive_settings_cache_desc, cacheSize?.let { DriveLogic.size(it) } ?: "…"), + leading = { Icon(GcIcons.Archive, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, + trailing = { TextButton(onClick = viewModel::clearCache, enabled = (cacheSize ?: 0L) > 0L) { Text(stringResource(R.string.drive_settings_cache_clear)) } }, + ) + GcRowDivider() + GcSwitchRow( + label = stringResource(R.string.drive_settings_wifi), + description = stringResource(R.string.drive_settings_wifi_desc), + checked = values.wifiOnly, + onCheckedChange = viewModel::setWifiOnly, + icon = GcIcons.Wifi, + ) + } + + // 6. Benachrichtigungen (Drive's pushes, filtered on this phone) + SettingsGroup(stringResource(R.string.drive_settings_notify)) { + if (me?.has(DMe.FEATURE_DOC_AI) == true) { + GcSwitchRow( + label = stringResource(R.string.drive_settings_notify_deadlines), + description = null, + checked = values.notifyDeadlines, + onCheckedChange = viewModel::setNotifyDeadlines, + icon = GcIcons.Calendar, + ) + GcRowDivider() + } + GcSwitchRow( + label = stringResource(R.string.drive_settings_notify_vault), + description = stringResource(R.string.drive_settings_notify_vault_desc), + checked = values.notifyVaultRequests, + onCheckedChange = viewModel::setNotifyVault, + icon = GcIcons.Bell, + ) + } + } + } + + if (askOff) { + AlertDialog( + onDismissRequest = { askOff = false }, + title = { Text(stringResource(R.string.drive_settings_vault_biometric_off_title)) }, + text = { Text(stringResource(R.string.drive_settings_vault_biometric_off)) }, + confirmButton = { TextButton(onClick = { askOff = false; switchBiometric(false) }) { Text(stringResource(R.string.drive_settings_vault_biometric_off_go)) } }, + dismissButton = { TextButton(onClick = { askOff = false }) { Text(stringResource(R.string.drive_cancel)) } }, + ) + } + if (pickLock) { + ChoiceDialog( + title = stringResource(R.string.drive_settings_vault_auto_lock), + options = DriveSettingsLogic.AUTO_LOCK_CHOICES.map { it to pluralStringResource(R.plurals.drive_settings_vault_after, it, it) }, + selected = values.autoLockMinutes, + onSelect = { pickLock = false; viewModel.setAutoLock(it) }, + onDismiss = { pickLock = false }, + ) + } + if (pickSort) { + val keys = listOf("name", "mtime", "size", "type") + val options = keys.flatMap { k -> listOf(DriveLogic.FolderSort(k, false), DriveLogic.FolderSort(k, true)) } + ChoiceDialog( + title = stringResource(R.string.drive_sort), + options = options.map { it to sortLabel(it) }, + selected = sort, + onSelect = { pickSort = false; viewModel.setSort(it) }, + onDismiss = { pickSort = false }, + ) + } +} + +/** "Name, aufsteigend" and so on. */ +@Composable +private fun sortLabel(s: DriveLogic.FolderSort): String { + val key = stringResource( + when (s.key) { + "mtime" -> R.string.drive_sort_date + "size" -> R.string.drive_sort_size + "type" -> R.string.drive_sort_type + else -> R.string.drive_sort_name + }, + ) + return key + ", " + stringResource(if (s.desc) R.string.drive_sort_desc else R.string.drive_sort_asc) +} + +/** One choice of several, as radio rows (≥ 48 dp). */ +@Composable +private fun ChoiceDialog(title: String, options: List>, selected: T, onSelect: (T) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup().verticalScroll(rememberScrollState())) { + options.forEach { (value, label) -> + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .selectable(selected = value == selected, role = Role.RadioButton, onClick = { onSelect(value) }), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = value == selected, onClick = null) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 12.dp)) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.drive_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt index a6ae9030..c4ebf66e 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt @@ -29,6 +29,11 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.RadioButton +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -71,7 +76,12 @@ import com.gatecontrol.android.drive.DriveLive import com.gatecontrol.android.drive.DriveLogic import com.gatecontrol.android.drive.vault.DriveVaultCrypto import com.gatecontrol.android.drive.vault.DriveVaultSession +import com.gatecontrol.android.drive.vault.DriveVaultLock +import com.gatecontrol.android.drive.DriveSettings +import com.gatecontrol.android.drive.DriveUploadNetwork import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.DeviceAuthResult +import com.gatecontrol.android.ui.components.rememberGcDeviceAuth import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIcons @@ -100,10 +110,14 @@ import javax.inject.Inject /** A folder of a vault: its names decrypted on this phone, or what this device must do to open the vault. */ @HiltViewModel class DriveVaultViewModel @Inject constructor( + @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, savedStateHandle: SavedStateHandle, private val api: DriveApi, val session: DriveVaultSession, private val live: DriveLive, + private val lock: DriveVaultLock, + private val settings: DriveSettings, + private val network: DriveUploadNetwork, ) : ViewModel() { val vaultId: String = savedStateHandle.get("vault").orEmpty() @@ -116,6 +130,11 @@ class DriveVaultViewModel @Inject constructor( data class Error(val code: String) : Ui() /** This device holds no keys: ask, wait, or the recovery key. */ data class Locked(val vault: DVault, val access: DriveVaultSession.Access) : Ui() + /** + * Drive 1.11: the vault is locked — it opens after the person confirms (fingerprint, face or the phone's PIN). + * [noScreenLock]: the phone has none, vaults cannot be opened. [title] is the remembered name, if any. + */ + data class Gate(val vault: DVault, val title: String?, val noScreenLock: Boolean) : Ui() data class Ready(val vault: DVault, val folder: DFolder, val title: String, val items: List>) : Ui() } @@ -139,14 +158,33 @@ class DriveVaultViewModel @Inject constructor( private var job: Job? = null private var requestsShown = false + /** The person just confirmed: the next opening may read the device key (and moves it, [DriveVaultSession.protectDeviceKey]). */ + private var confirmed = false + init { refresh() + // locked (time without use, the app in the background): the content goes, the vault asks again + viewModelScope.launch { + session.locks.collect { + val vault = when (val u = _ui.value) { + is Ui.Ready -> u.vault + is Ui.Locked -> u.vault + else -> null + } ?: return@collect + job?.cancel() + confirmed = false + _requests.value = null + _move.value = null + _conflict.value?.let { resolve(it, null) } + _ui.value = Ui.Gate(vault, session.label(vault), noScreenLock = !deviceSecure()) + } + } viewModelScope.launch { live.vault.collect { e -> if (e.vault != vaultId) return@collect when (e.event) { // approved (here or elsewhere), a new key: the keys again - "approved", "rotated" -> { session.reload(vaultId); refresh(quiet = true) } + "approved", "rotated" -> if (session.unlocked(vaultId) || _ui.value is Ui.Locked) { session.reload(vaultId); refresh(quiet = true) } "request" -> { refresh(quiet = true); if (_requests.value != null) openRequests() } } } @@ -156,6 +194,30 @@ class DriveVaultViewModel @Inject constructor( fun clearMessage() { _message.value = null } + /** The phone has a screen lock (PIN, pattern or password): without one no vault opens (Drive 1.11). */ + private fun deviceSecure(): Boolean = + context.getSystemService(android.app.KeyguardManager::class.java)?.isDeviceSecure == true + + /** The person confirmed with fingerprint, face or PIN: the device key moves behind the confirmation, the vault opens. */ + fun confirmed() { + val gate = _ui.value as? Ui.Gate ?: return + if (gate.noScreenLock) return + confirmed = true + viewModelScope.launch { + session.protectDeviceKey(settings.current.vaultBiometric) + refresh() + } + } + + /** The vault was used: the time until it locks starts again. */ + fun touch() = lock.touch() + + /** Another app opens now (picker, viewer): leaving the app this once does not lock the vault. */ + fun away() = lock.away() + + /** "Sperren" by hand: every vault. */ + fun lockNow() = lock.lockNow() + fun refresh(quiet: Boolean = false) { job?.cancel() job = viewModelScope.launch { @@ -166,16 +228,34 @@ class DriveVaultViewModel @Inject constructor( return@launch } val vault = v.value - when (val a = session.open(vault)) { + // Drive 1.11: an approved device opens the vault only after the person confirmed (no screen lock: never) + if (!session.unlocked(vault.id) && session.approved(vault)) { + val secure = deviceSecure() + if (!secure || (settings.current.vaultBiometric && !confirmed)) { + _ui.value = Ui.Gate(vault, session.label(vault), noScreenLock = !secure) + return@launch + } + } + val opened = session.open(vault) + confirmed = false + when (val a = opened) { is DResult.Ok -> if (a.value != DriveVaultSession.Access.Open) { _ui.value = Ui.Locked(vault, a.value) return@launch } + is DResult.Failed -> if (a.code == DriveVaultSession.CODE_AUTH || a.code == DriveVaultSession.CODE_NO_LOCK) { + _ui.value = Ui.Gate(vault, session.label(vault), noScreenLock = a.code == DriveVaultSession.CODE_NO_LOCK || !deviceSecure()) + return@launch + } else { + _ui.value = Ui.Error(a.code) + return@launch + } else -> { _ui.value = Ui.Error(a.errorCode() ?: "failed") return@launch } } + lock.touch() val f = api.folder(if (folderId > 0) folderId else vault.nodeId) if (f !is DResult.Ok) { _ui.value = Ui.Error(f.errorCode() ?: "failed") @@ -206,6 +286,7 @@ class DriveVaultViewModel @Inject constructor( } private fun act(done: String? = null, block: suspend () -> DResult<*>) { + lock.touch() viewModelScope.launch { _busy.value = true val r = block() @@ -254,6 +335,10 @@ class DriveVaultViewModel @Inject constructor( */ fun upload(uris: List, names: (Uri) -> String) { val r = here() ?: return + if (network.waitsForWifi()) { + _message.value = "wifi_wait" + return + } viewModelScope.launch { var last: DResult<*> = DResult.Ok(Unit) val taken = r.items.map { it.second }.toMutableList() @@ -276,7 +361,7 @@ class DriveVaultViewModel @Inject constructor( } taken += name _upload.value = name to 0f - last = session.upload(vaultId, r.folder.node.id, uri, name = name, version = version) { p -> _upload.value = name to p } + last = lock.busy { session.upload(vaultId, r.folder.node.id, uri, name = name, version = version) { p -> _upload.value = name to p } } if (last !is DResult.Ok) break } _upload.value = null @@ -289,9 +374,13 @@ class DriveVaultViewModel @Inject constructor( fun uploadVersion(node: DNode, name: String, uri: Uri) { val r = here() ?: return if (node.isDir) return + if (network.waitsForWifi()) { + _message.value = "wifi_wait" + return + } viewModelScope.launch { _upload.value = name to 0f - val res = session.upload(vaultId, r.folder.node.id, uri, name = name, version = node) { p -> _upload.value = name to p } + val res = lock.busy { session.upload(vaultId, r.folder.node.id, uri, name = name, version = node) { p -> _upload.value = name to p } } _upload.value = null _message.value = res.errorCode() ?: "done_version" refresh(quiet = true) @@ -334,6 +423,7 @@ class DriveVaultViewModel @Inject constructor( fun startMove(node: DNode, name: String) { val r = here() ?: return + lock.touch() _move.value = MovePicker(node, name, DriveLoad.Loading) browseMove(r.folder.node.id) } @@ -379,10 +469,10 @@ class DriveVaultViewModel @Inject constructor( fun delete(node: DNode) = act("done_trash") { api.delete(node.id).also { if (it is DResult.Ok) refresh(quiet = true) } } /** The file decrypted into the cache (removed when the vault closes). */ - suspend fun fetch(node: DNode, name: String): DResult { + suspend fun fetch(node: DNode, name: String): DResult = lock.busy { val r = session.fetch(node, name) // a key version this device did not hold: loaded now, so once more - return if (r is DResult.Failed && r.code == "stale_key") session.fetch(node, name) else r + if (r is DResult.Failed && r.code == "stale_key") session.fetch(node, name) else r } override fun onCleared() { @@ -420,6 +510,7 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L LifecycleResumeEffect(viewModel) { if (shown) viewModel.refresh(quiet = true) shown = true + viewModel.touch() onPauseOrDispose { } } LaunchedEffect(message) { @@ -439,16 +530,22 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L val open: (DNode, String) -> Unit = { n, name -> scope.launch { when (val r = viewModel.fetch(n, name)) { - is DResult.Ok -> openFile(context, n.copy(name = name, mime = null), r.value) + is DResult.Ok -> { + // the viewer is another app: leaving for it does not lock the vault (the time without use does) + viewModel.away() + openFile(context, n.copy(name = name, mime = null), r.value) + } else -> Toast.makeText(context, driveText(context, r.errorCode() ?: "failed"), Toast.LENGTH_LONG).show() } } } val ready = ui as? DriveVaultViewModel.Ui.Ready + val barTitle = ready?.title ?: (ui as? DriveVaultViewModel.Ui.Gate)?.title ?: stringResource(R.string.drive_vault) Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - GcSubpageBar(ready?.title ?: stringResource(R.string.drive_vault), onBack = onBack, backDescription = stringResource(R.string.drive_back)) { + GcSubpageBar(barTitle, onBack = onBack, backDescription = stringResource(R.string.drive_back)) { if (ready != null) { + GcIconButton(GcIcons.Lock, contentDescription = stringResource(R.string.drive_vault_lock_now), onClick = viewModel::lockNow, tint = MaterialTheme.colorScheme.onBackground) GcIconButton(GcIcons.Users, contentDescription = stringResource(R.string.drive_vault_requests), onClick = viewModel::openRequests, tint = MaterialTheme.colorScheme.onBackground) // a new key and a new recovery key: the write right (the server's rule) if (ready.folder.writable) { @@ -479,6 +576,7 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L when (val s = ui) { DriveVaultViewModel.Ui.Loading -> DriveLoading() is DriveVaultViewModel.Ui.Error -> DriveErrorBox(s.code, onRetry = { viewModel.refresh() }) + is DriveVaultViewModel.Ui.Gate -> VaultGate(s.noScreenLock, onConfirmed = viewModel::confirmed) is DriveVaultViewModel.Ui.Locked -> LockedVault(s.access, busy, safety = viewModel.session.safetyNumber(), onAsk = viewModel::ask, onCheck = { viewModel.refresh() }, onRecover = viewModel::recover) is DriveVaultViewModel.Ui.Ready -> { if (s.vault.pending > 0) { @@ -556,7 +654,7 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L ) GcListRow( title = stringResource(R.string.drive_upload), - onClick = { addOpen = false; pick.launch(arrayOf("*/*")) }, + onClick = { addOpen = false; viewModel.away(); pick.launch(arrayOf("*/*")) }, leading = { Icon(GcIcons.Upload, contentDescription = null, tint = GateControlTheme.extraColors.muted) }, ) } @@ -578,7 +676,7 @@ fun DriveVaultScreen(onBack: () -> Unit, onFolder: (vaultId: String, folderId: L if (writable) item(GcIcons.Edit, R.string.drive_rename) { renaming = n to name } if (writable) item(GcIcons.Folder, R.string.drive_move) { viewModel.startMove(n, name) } if (writable && !n.isDir && upload == null) { - item(GcIcons.Upload, R.string.drive_vault_new_version) { versionFor = n to name; pickVersion.launch(arrayOf("*/*")) } + item(GcIcons.Upload, R.string.drive_vault_new_version) { versionFor = n to name; viewModel.away(); pickVersion.launch(arrayOf("*/*")) } } if (writable) item(GcIcons.Trash, R.string.drive_delete_menu) { deleting = n to name } } @@ -705,6 +803,53 @@ private fun VaultMovePicker(p: DriveVaultViewModel.MovePicker, onBrowse: (Long) ) } +/** + * Drive 1.11: the vault is locked. It opens after the person confirms with fingerprint, face or the phone's PIN (asked at + * once when the page opens); without a screen lock it cannot be opened at all. + */ +@Composable +private fun VaultGate(noScreenLock: Boolean, onConfirmed: () -> Unit) { + val x = GateControlTheme.extraColors + val context = LocalContext.current + val auth = rememberGcDeviceAuth() + val title = stringResource(R.string.drive_vault_gate_prompt) + var failed by remember { mutableStateOf(false) } + val ask: () -> Unit = { + failed = false + auth.authenticate(title, null) { r -> + when (r) { + DeviceAuthResult.SUCCESS -> onConfirmed() + DeviceAuthResult.CANCELLED -> Unit + DeviceAuthResult.FAILED, DeviceAuthResult.UNAVAILABLE -> failed = true + } + } + } + // asked once when the locked vault shows (not again after cancelling) + var asked by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } + LaunchedEffect(noScreenLock) { + if (!noScreenLock && !asked && auth.available()) { + asked = true + ask() + } + } + GcCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(GcIcons.Lock, contentDescription = null, tint = x.warn, modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.drive_vault_gate_title), style = MaterialTheme.typography.titleMedium) + } + if (noScreenLock) { + Text(stringResource(R.string.drive_vault_no_lock), style = MaterialTheme.typography.bodyMedium, color = x.muted) + GcOutlineButton(stringResource(R.string.drive_vault_no_lock_settings), onClick = { + runCatching { context.startActivity(Intent(android.provider.Settings.ACTION_SECURITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + }, icon = GcIcons.Settings) + } else { + Text(stringResource(R.string.drive_vault_gate_text), style = MaterialTheme.typography.bodyMedium, color = x.muted) + if (failed) Text(stringResource(R.string.drive_vault_gate_failed), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + GcPrimaryButton(stringResource(R.string.drive_vault_gate_unlock), onClick = ask, icon = GcIcons.Fingerprint) + } + } +} + /** This device holds no keys: ask (then the safety number), or the recovery key. */ @Composable private fun LockedVault( @@ -851,31 +996,91 @@ private fun copySecret(context: Context, text: String) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) Toast.makeText(context, R.string.drive_vault_copied, Toast.LENGTH_SHORT).show() } -/** "Neuer Tresor": its name (encrypted on this phone), then the recovery key. */ +/** + * "Neuer Tresor" (Drive 1.11): its name (encrypted on this phone) and who may open it — "Nur ich" (Meine Dateien), "Mit + * der Familie teilen" (with a family seat) or a team folder with the write right. It lies in that area's top folder and + * shows in the menu under "Tresore"; then the recovery key. + */ @Composable -internal fun NewVaultDialog(busy: Boolean, onCreate: (String) -> Unit, onDismiss: () -> Unit) { +internal fun NewVaultDialog(state: DriveFolderViewModel.NewVault, onCreate: (String, com.gatecontrol.android.drive.DArea) -> Unit, onDismiss: () -> Unit) { + val x = GateControlTheme.extraColors var value by remember { mutableStateOf("") } + var areaId by remember { mutableStateOf(null) } + val areas = state.areas + val chosen = areas?.firstOrNull { it.id == areaId } ?: areas?.firstOrNull() val valid = DriveLogic.validName(value) + val busy = state.busy AlertDialog( onDismissRequest = { if (!busy) onDismiss() }, + icon = { Icon(GcIcons.Lock, contentDescription = null, tint = x.warn) }, title = { Text(stringResource(R.string.drive_vault_new)) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.drive_vault_create_text), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted) + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { GcTextField( value = value, onValueChange = { value = it.take(255) }, label = stringResource(R.string.drive_name), error = if (value.isNotEmpty() && !valid) stringResource(R.string.drive_name_invalid) else null, ) + Text(stringResource(R.string.drive_vault_who), style = MaterialTheme.typography.labelLarge, color = x.muted) + when { + areas == null -> DriveLoading() + areas.isEmpty() -> Text(stringResource(R.string.drive_vault_no_area), style = MaterialTheme.typography.bodyMedium, color = x.muted) + else -> Column(Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + areas.forEach { a -> + VaultAreaChoice(a, selected = a.id == chosen?.id, enabled = !busy, onSelect = { areaId = a.id }) + } + } + } + Row( + Modifier.fillMaxWidth().background(x.panel2, androidx.compose.foundation.shape.RoundedCornerShape(14.dp)).padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(GcIcons.Fingerprint, contentDescription = null, tint = x.accentText, modifier = Modifier.size(20.dp)) + Text( + pluralStringResource(R.plurals.drive_vault_new_hint, state.lockMinutes, state.lockMinutes), + style = MaterialTheme.typography.bodySmall, + ) + } if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) } }, - confirmButton = { TextButton(onClick = { onCreate(value.trim()) }, enabled = valid && !busy) { Text(stringResource(R.string.drive_vault_create)) } }, + confirmButton = { + TextButton(onClick = { chosen?.let { onCreate(value.trim(), it) } }, enabled = valid && !busy && chosen != null) { + Text(stringResource(R.string.drive_vault_create)) + } + }, dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.drive_cancel)) } }, ) } +/** One answer to "Wer darf ihn öffnen?": a radio row (≥ 48 dp) with what it means. */ +@Composable +private fun VaultAreaChoice(a: com.gatecontrol.android.drive.DArea, selected: Boolean, enabled: Boolean, onSelect: () -> Unit) { + val x = GateControlTheme.extraColors + val (title, desc) = when (a.kind) { + "mine" -> stringResource(R.string.drive_vault_who_me) to stringResource(R.string.drive_vault_who_me_desc) + "family" -> stringResource(R.string.drive_vault_who_family) to stringResource(R.string.drive_vault_who_family_desc) + else -> stringResource(R.string.drive_vault_who_team, a.label) to stringResource(R.string.drive_vault_who_team_desc) + } + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background(if (selected) x.accentBg else androidx.compose.ui.graphics.Color.Transparent, androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) + .border(1.dp, if (selected) x.accentText else x.border, androidx.compose.foundation.shape.RoundedCornerShape(14.dp)) + .selectable(selected = selected, enabled = enabled, role = androidx.compose.ui.semantics.Role.RadioButton, onClick = onSelect) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null, enabled = enabled) + Column(Modifier.padding(start = 8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(desc, style = MaterialTheme.typography.bodySmall, color = x.muted) + } + } +} + /** The display name of a picked file. */ private fun displayName(context: Context, uri: Uri): String = runCatching { context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt index 883f20f0..7b448dac 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveViewModels.kt @@ -19,6 +19,8 @@ import com.gatecontrol.android.drive.DriveApi import com.gatecontrol.android.drive.DriveLocal import com.gatecontrol.android.drive.DriveLogic import com.gatecontrol.android.drive.DriveTransfers +import com.gatecontrol.android.drive.DriveTree +import com.gatecontrol.android.drive.DTreeDir import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job @@ -180,19 +182,76 @@ class DriveHomeViewModel @Inject constructor( } } - // ─── vaults in the menu (Drive 1.9, feature `vault`) ──────────── + // ─── the menu's folder tree (Drive 1.11) ──────────────────────── - /** A vault of the menu: its decrypted name (null while this device holds no keys) and where this device stands. */ + private val _expanded = MutableStateFlow(DriveTree.decode(prefs.getStringSet(KEY_TREE, null))) + /** The opened folders of the menu's tree (kept on the device). */ + val expanded: StateFlow> = _expanded.asStateFlow() + + private val _children = MutableStateFlow>>(emptyMap()) + /** The loaded subfolders by folder. */ + val children: StateFlow>> = _children.asStateFlow() + private val loadingDirs = java.util.Collections.synchronizedSet(HashSet()) + + /** The arrow of [id]: opened or closed; opening loads its subfolders (once). */ + fun toggle(id: Long) { + val next = DriveTree.toggle(_expanded.value, id) + _expanded.value = next + prefs.edit().putStringSet(KEY_TREE, DriveTree.encode(next)).apply() + if (id in next) loadDirs(id) + } + + /** The subfolders of [id] (again: [force]). A folder that is gone closes. */ + fun loadDirs(id: Long, force: Boolean = false) { + if (!force && _children.value.containsKey(id)) return + if (!loadingDirs.add(id)) return + viewModelScope.launch { + try { + val apart = live.me.value?.has(DMe.FEATURE_VAULTS_APART) == true + when (val r = api.dirs(id, apart)) { + is DResult.Ok -> _children.update { it + (id to r.value) } + // gone or no longer readable: closed for good + is DResult.Failed -> if (r.http == 404 || r.http == 403) { + _children.update { it + (id to emptyList()) } + _expanded.update { it - id } + prefs.edit().putStringSet(KEY_TREE, DriveTree.encode(_expanded.value)).apply() + } else { + _expanded.update { it - id } + } + // offline: closed for now (the arrow opens it again) + else -> if (!_children.value.containsKey(id)) _expanded.update { it - id } + } + } finally { + loadingDirs.remove(id) + } + } + } + + /** The menu opens: the opened folders load again quietly (the tree stays as it is meanwhile). */ + fun refreshTree() { + val open = _expanded.value + _children.value.keys.filter { it in open }.forEach { loadDirs(it, force = true) } + } + + // ─── vaults in the menu (Drive 1.9, feature `vault`; Drive 1.11: only there) ─ + + /** A vault of the menu: its name (decrypted while open, else remembered on this phone) and where this device stands. */ data class VaultEntry(val vault: com.gatecontrol.android.drive.DVault, val name: String?, val state: VaultState) - enum class VaultState { OPEN, PENDING, LOCKED } + /** [OPEN]: its keys are in memory; [LOCKED]: approved, opens after the confirmation; [PENDING]: asked; [NEW]: not yet. */ + enum class VaultState { OPEN, LOCKED, PENDING, NEW } private val _vaults = MutableStateFlow>(emptyList()) /** Every vault the person may use (their areas), for the menu. */ val vaults: StateFlow> = _vaults.asStateFlow() private var vaultJob: Job? = null - /** Loads the vaults (the menu opens): the names of those this device is approved for are decrypted here. */ + init { + // locked (time without use, the app in the background): the menu says so + viewModelScope.launch { vaultSession.locks.collect { loadVaults() } } + } + + /** Loads the vaults (the menu opens). Nothing is opened here: that asks for the person's confirmation first. */ fun loadVaults() { if (live.me.value?.has(DMe.FEATURE_VAULT) != true) { _vaults.value = emptyList() @@ -201,37 +260,17 @@ class DriveHomeViewModel @Inject constructor( if (vaultJob?.isActive == true) return vaultJob = viewModelScope.launch { val list = api.vaults().valueOrNull() ?: return@launch - val me = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { runCatching { vaultSession.deviceId() }.getOrNull() } - _vaults.value = list.map { v -> - val mine = v.myDevices.firstOrNull { it.deviceId == me } - val state = when (mine?.state) { - "approved" -> if (vaultSession.open(v).valueOrNull() == com.gatecontrol.android.drive.vault.DriveVaultSession.Access.Open) VaultState.OPEN else VaultState.LOCKED - "pending" -> VaultState.PENDING - else -> VaultState.LOCKED - } - VaultEntry(v, if (state == VaultState.OPEN) vaultSession.nameOf(v) else null, state) - } - } - } - - // ─── AI for documents per area (Drive 1.8, feature `docAi`) ───── - - /** "KI für Dokumente" of [area] on or off (the write right); the menu shows the new setting at once. */ - fun setDocAi(area: DArea, on: Boolean) { - val home = (_state.value as? DriveLoad.Ready)?.value ?: return - if (home.areas.none { it.id == area.id }) return - val before = area.docAi - fun put(value: Boolean?) = _state.update { s -> - val h = (s as? DriveLoad.Ready)?.value ?: return@update s - DriveLoad.Ready(h.copy(areas = h.areas.map { if (it.id == area.id) it.copy(docAi = value) else it })) - } - put(on) - viewModelScope.launch { - when (val r = api.setAreaDocAi(area.id, on)) { - is DResult.Ok -> put(r.value) - else -> { - put(before) - _message.value = r.errorCode() + _vaults.value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + vaultSession.keepNames(list.map { it.id }.toSet()) + val me = runCatching { vaultSession.deviceId() }.getOrNull() + list.map { v -> + val state = when { + vaultSession.unlocked(v.id) -> VaultState.OPEN + vaultSession.approved(v) -> VaultState.LOCKED + v.myDevices.any { it.deviceId == me && it.state == "pending" } -> VaultState.PENDING + else -> VaultState.NEW + } + VaultEntry(v, vaultSession.label(v), state) } } } @@ -239,6 +278,7 @@ class DriveHomeViewModel @Inject constructor( private companion object { const val KEY_AREA = "area" + const val KEY_TREE = "tree_open" } } @@ -253,6 +293,8 @@ class DriveFolderViewModel @Inject constructor( val scanner: DriveScanner, val live: DriveLive, private val vaults: com.gatecontrol.android.drive.vault.DriveVaultSession, + private val vaultLock: com.gatecontrol.android.drive.vault.DriveVaultLock, + private val settings: com.gatecontrol.android.drive.DriveSettings, ) : ViewModel() { /** The route's `id` (a string argument, see Screen.DriveFolder); on the Drive tab the chosen area's top ([open]). */ @@ -290,6 +332,9 @@ class DriveFolderViewModel @Inject constructor( /** On screen again (back from another tab, a page on top or the background): load quietly. */ fun onResume() { visible = true + // the sort may have changed in the Drive settings meanwhile + val pref = DriveViewPref.sort(context) + if (pref != _sort.value) _sort.value = pref if (shown) refresh(quiet = true) shown = true } @@ -650,25 +695,52 @@ class DriveFolderViewModel @Inject constructor( /** The office's address of [node] ("Im Browser bearbeiten / ansehen"). */ suspend fun office(node: DNode): DResult = api.office(node.id) - /** "Neuer Tresor": busy while it is made, then the recovery key to show once. */ - data class NewVault(val busy: Boolean = false, val recoveryKey: String? = null, val vaultId: String? = null, val nodeId: Long? = null) + /** + * "Neuer Tresor" (Drive 1.11: only from the plus menu): the areas it can go to ([DriveLogic.vaultAreas], null while + * they load), busy while it is made, then the recovery key to show once. + */ + data class NewVault( + val areas: List? = null, + val busy: Boolean = false, + val recoveryKey: String? = null, + val vaultId: String? = null, + val nodeId: Long? = null, + /** Minutes until an open vault locks (the hint of the dialog). */ + val lockMinutes: Int = com.gatecontrol.android.drive.DriveSettingsLogic.DEFAULT_AUTO_LOCK, + ) private val _newVault = MutableStateFlow(null) val newVault: StateFlow = _newVault.asStateFlow() - fun startVault() { _newVault.value = NewVault() } + fun startVault() { + val start = NewVault(lockMinutes = settings.current.autoLockMinutes) + _newVault.value = start + viewModelScope.launch { + val seat = live.me.value?.seat ?: api.me().valueOrNull()?.seat + val areas = api.areas().valueOrNull() + _newVault.update { v -> if (v === start) v.copy(areas = DriveLogic.vaultAreas(DriveLogic.sortAreas(areas.orEmpty()), seat)) else v } + if (areas == null) { + _newVault.value = null + _message.value = "offline" + } + } + } fun closeVault() { _newVault.value = null } - /** A new vault [name] in this folder: encrypted here, the recovery key comes back to be written down. */ - fun createVault(name: String) { - val here = (_state.value as? DriveLoad.Ready)?.value ?: return - if (here.node.encrypted) return - _newVault.value = NewVault(busy = true) + /** + * A new vault [name] for [area]: it always lies in the area's top folder (wherever the plus was pressed; the + * server of Drive 1.11 puts it there too). Encrypted here, the recovery key comes back to be written down. + */ + fun createVault(name: String, area: DArea) { + val v = _newVault.value ?: return + if (v.busy) return + _newVault.value = v.copy(busy = true) viewModelScope.launch { - when (val r = vaults.create(here.node.id, name)) { + when (val r = vaults.create(area.rootId, name)) { is DResult.Ok -> { - _newVault.value = NewVault(recoveryKey = r.value.recoveryKey, vaultId = r.value.vault.id, nodeId = r.value.vault.nodeId) + vaultLock.touch() + _newVault.value = v.copy(busy = false, recoveryKey = r.value.recoveryKey, vaultId = r.value.vault.id, nodeId = r.value.vault.nodeId) refresh(quiet = true) } else -> { diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index f30eb811..f0f4fbae 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -83,9 +83,8 @@ fun SettingsScreen( onNavigateToConnection: () -> Unit = {}, /** GateDrop "Geteilt"; null hides the row (no GateDrop on the server). */ onNavigateToShared: (() -> Unit)? = null, - /** Drive photo backup; null hides the row (no Drive on the server). */ - onNavigateToDriveBackup: (() -> Unit)? = null, - onNavigateToDriveFolders: (() -> Unit)? = null, + /** "Drive-Einstellungen" (backup, vaults, data on this phone …); null hides the row (no Drive on the server). */ + onNavigateToDrive: (() -> Unit)? = null, /** Page shown next to the list on wide screens; its row is highlighted. */ selectedPane: SettingsPane? = null, viewModel: SettingsViewModel = hiltViewModel(), @@ -300,50 +299,29 @@ fun SettingsScreen( } } - // --- Drive (photo backup) ------------------------------------------ - if (onNavigateToDriveBackup != null) { + // --- Drive: everything of Drive is in its own settings (Drive 1.11) --- + if (onNavigateToDrive != null) { val backup by driveBackupViewModel.config.collectAsStateWithLifecycle() - val backupStats by driveBackupViewModel.stats.collectAsStateWithLifecycle() SettingsGroup(stringResource(R.string.drive_title)) { GcListRow( - title = stringResource(R.string.drive_backup_title), - description = if (backup.enabled) { - pluralStringResource(R.plurals.drive_backup_saved, backupStats.saved, backupStats.saved) + " · " + - stringResource(if (backup.wifiOnly) R.string.drive_backup_wifi_short else R.string.drive_backup_mobile_short) - } else { - stringResource(R.string.drive_backup_off_text) - }, - onClick = onNavigateToDriveBackup, - leading = { Icon(GcIcons.Camera, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) }, + title = stringResource(R.string.drive_settings_title), + description = stringResource(R.string.drive_settings_row_desc), + onClick = onNavigateToDrive, + leading = { Icon(GcIcons.Cloud, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) }, trailing = { - Text( - stringResource(if (backup.enabled) R.string.drive_backup_on else R.string.drive_backup_off), - style = MaterialTheme.typography.labelMedium, - color = if (backup.enabled) extra.accentText else extra.muted, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (backup.enabled) { + Text( + stringResource(R.string.drive_settings_row_backup_on), + style = MaterialTheme.typography.labelMedium, + color = extra.accentText, + modifier = Modifier.padding(end = 6.dp), + ) + } + GcChevron() + } }, ) - if (onNavigateToDriveFolders != null) { - val driveFolders by driveBackupViewModel.folders.collectAsStateWithLifecycle() - val activeFolders = driveFolders.count { !it.paused } - GcListRow( - title = stringResource(R.string.drive_folders_title), - description = if (driveFolders.isEmpty()) { - stringResource(R.string.drive_folders_off_text) - } else { - pluralStringResource(R.plurals.drive_folders_count, driveFolders.size, driveFolders.size) - }, - onClick = onNavigateToDriveFolders, - leading = { Icon(GcIcons.Folder, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) }, - trailing = { - Text( - stringResource(if (activeFolders > 0) R.string.drive_backup_on else R.string.drive_backup_off), - style = MaterialTheme.typography.labelMedium, - color = if (activeFolders > 0) extra.accentText else extra.muted, - ) - }, - ) - } } } diff --git a/app/src/main/res/values-de/strings_drive3.xml b/app/src/main/res/values-de/strings_drive3.xml index 6e50d253..3672e595 100644 --- a/app/src/main/res/values-de/strings_drive3.xml +++ b/app/src/main/res/values-de/strings_drive3.xml @@ -49,7 +49,6 @@ heute Erinnerung gesetzt Aus Google Drive / OneDrive importieren - Öffnet das Portal Im Browser bearbeiten Im Browser ansehen Office ist gerade nicht verfügbar. @@ -60,8 +59,6 @@ Der Tresor hat einen neuen Schlüssel. Bitte noch einmal versuchen. Tresor Neuer Tresor - Ein Ende-zu-Ende verschlüsselter Ordner - Namen und Inhalte werden auf diesem Telefon verschlüsselt. Nur deine freigegebenen Geräte können sie lesen; der Server bewahrt nur verschlüsselte Daten auf. Tresor anlegen Wiederherstellungsschlüssel Schreib diesen Schlüssel auf und bewahre ihn sicher auf. Er öffnet den Tresor auf einem neuen Gerät, wenn kein freigegebenes Gerät zur Hand ist. Er wird nur jetzt angezeigt. diff --git a/app/src/main/res/values-de/strings_drive4.xml b/app/src/main/res/values-de/strings_drive4.xml index 50d49186..fa0a7acb 100644 --- a/app/src/main/res/values-de/strings_drive4.xml +++ b/app/src/main/res/values-de/strings_drive4.xml @@ -7,8 +7,6 @@ KI für Dokumente Erkennt in „%1$s“ Rechnungen, Verträge und Fristen Tresore - Gesperrter Tresor - Freigabe anfragen oder Wiederherstellungsschlüssel Freigabe angefragt Schlüssel Tresor-Schlüssel wechseln diff --git a/app/src/main/res/values-de/strings_drive6.xml b/app/src/main/res/values-de/strings_drive6.xml new file mode 100644 index 00000000..34e6edb5 --- /dev/null +++ b/app/src/main/res/values-de/strings_drive6.xml @@ -0,0 +1,84 @@ + + + + Persönlich + Gemeinsam + Familien-Platz + Business-Platz + Aufklappen: %1$s + Zuklappen: %1$s + Privat + Mit Familie geteilt + Team „%1$s“ + geöffnet + gesperrt + nicht freigegeben + + Erscheint im Menü unter „Tresore“, nicht hier + Wer darf ihn öffnen? + Nur ich + Privat, liegt in „Meine Dateien“ + Mit der Familie teilen + Jedes Familienmitglied braucht deine Freigabe für sein Gerät + Team-Ordner „%1$s“ + Alle mit Zugriff auf den Team-Ordner, jedes Gerät nach Freigabe + Du hast keinen Bereich, in dem du einen Tresor anlegen darfst. + + Öffnen nur mit Fingerabdruck oder PIN. Der Tresor schließt sich nach %d Minute ohne Nutzung. Du findest ihn im Menü unter „Tresore“. + Öffnen nur mit Fingerabdruck oder PIN. Der Tresor schließt sich nach %d Minuten ohne Nutzung. Du findest ihn im Menü unter „Tresore“. + + + Tresor gesperrt + Bestätige mit Fingerabdruck, Gesicht oder der PIN deines Handys, um den Tresor zu öffnen. + Tresor öffnen + Entsperren + Nicht bestätigt. Versuch es noch einmal. + Tresor sperren + Tresore lassen sich nur auf einem Handy mit Displaysperre öffnen (PIN, Muster oder Passwort). Richte sie in den Einstellungen des Handys ein. + Displaysperre einrichten + + Drive-Einstellungen + Sicherung, Tresore, KI, Ansicht und Daten auf diesem Gerät + Sicherung an + Sicherung + Mit Fingerabdruck öffnen + Oder mit Gesicht oder der PIN des Handys + Tresore ohne Bestätigung öffnen? + Dann schützt nur noch die Freigabe dieses Geräts deine Tresore: Wer dein entsperrtes Handy in der Hand hat, kann sie öffnen. + Trotzdem ausschalten + Der Geräteschlüssel konnte nicht umgestellt werden. Die Einstellung bleibt, wie sie war. + Automatisch sperren + Wenn der Tresor nicht benutzt wird + + nach %d Minute + nach %d Minuten + + Beim Verlassen der App sperren + Sobald GateControl in den Hintergrund geht + Sicherheitszahl dieses Geräts + Zum Vergleichen, wenn ein anderes Gerät dieses freigibt + Freigegeben für + Noch für keinen Tresor + + %d Tresor + %d Tresore + + Tresore werden nie von der KI gelesen. + Ansicht + Darstellung + Liste + Kacheln + Fotos-Tab startet mit „Nur Kamera-Uploads“ + Daten auf diesem Gerät + Zwischenspeicher + %1$s für Vorschaubilder und geöffnete Dateien + Leeren + Zwischenspeicher geleert. + Hochladen nur im WLAN + Gilt für alle Uploads, nicht nur für die Foto-Sicherung + Wartet auf WLAN + Benachrichtigungen + Erinnerungen an Fristen + Anfragen zur Tresor-Freigabe + Wenn ein neues Gerät einen Tresor öffnen möchte + diff --git a/app/src/main/res/values/strings_drive3.xml b/app/src/main/res/values/strings_drive3.xml index 46cb942a..fe2e8f11 100644 --- a/app/src/main/res/values/strings_drive3.xml +++ b/app/src/main/res/values/strings_drive3.xml @@ -49,7 +49,6 @@ today Reminder set Import from Google Drive / OneDrive - Opens the portal Edit in the browser View in the browser The office is not available right now. @@ -60,8 +59,6 @@ The vault has a new key. Please try again. Vault New vault - An end-to-end encrypted folder - Names and contents are encrypted on this phone. Only your approved devices can read them; the server keeps nothing but ciphertext. Create vault Recovery key Write this key down and keep it safe. It opens the vault on a new device when no approved device is at hand. It is shown only now. diff --git a/app/src/main/res/values/strings_drive4.xml b/app/src/main/res/values/strings_drive4.xml index 2392e2bf..d246f5af 100644 --- a/app/src/main/res/values/strings_drive4.xml +++ b/app/src/main/res/values/strings_drive4.xml @@ -7,8 +7,6 @@ AI for documents Recognises invoices, contracts and deadlines in “%1$s” Vaults - Locked vault - Request access or use the recovery key Access requested Keys Change vault key diff --git a/app/src/main/res/values/strings_drive6.xml b/app/src/main/res/values/strings_drive6.xml new file mode 100644 index 00000000..314f1c62 --- /dev/null +++ b/app/src/main/res/values/strings_drive6.xml @@ -0,0 +1,84 @@ + + + + Personal + Shared + Family seat + Business seat + Expand: %1$s + Collapse: %1$s + Private + Shared with the family + Team “%1$s” + open + locked + not approved + + Appears in the menu under “Vaults”, not here + Who may open it? + Only me + Private, kept in “My files” + Share with the family + Every family member needs your approval for their device + Team folder “%1$s” + Everyone with access to the team folder, each device after approval + There is no area where you may create a vault. + + Opens only with your fingerprint or PIN. The vault locks after %d minute without use. You find it in the menu under “Vaults”. + Opens only with your fingerprint or PIN. The vault locks after %d minutes without use. You find it in the menu under “Vaults”. + + + Vault locked + Confirm with your fingerprint, face or your phone’s PIN to open the vault. + Open vault + Unlock + Not confirmed. Please try again. + Lock vault + Vaults open only on a phone with a screen lock (PIN, pattern or password). Set one up in your phone’s settings. + Set up screen lock + + Drive settings + Backup, vaults, AI, view and data on this device + Backup on + Backup + Open with fingerprint + Or with your face or the phone’s PIN + Open vaults without confirmation? + Then only this device’s approval protects your vaults: anyone holding your unlocked phone can open them. + Turn off anyway + The device key could not be switched. The setting stays as it was. + Lock automatically + When the vault is not in use + + after %d minute + after %d minutes + + Lock when leaving the app + As soon as GateControl goes to the background + This device’s safety number + To compare when another device approves this one + Approved for + No vault yet + + %d vault + %d vaults + + Vaults are never read by the AI. + View + Layout + List + Tiles + Photos tab starts with “Camera uploads only” + Data on this device + Cache + %1$s for previews and opened files + Clear + Cache cleared. + Upload on Wi-Fi only + Applies to all uploads, not just the photo backup + Waiting for Wi-Fi + Notifications + Deadline reminders + Vault approval requests + When a new device wants to open a vault + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveSettingsLogicTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveSettingsLogicTest.kt new file mode 100644 index 00000000..c645386b --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveSettingsLogicTest.kt @@ -0,0 +1,67 @@ +package com.gatecontrol.android.drive + +import com.gatecontrol.android.drive.vault.DriveVaultLockTimer +import com.gatecontrol.android.notify.model.PushMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.11: the settings on this phone and the vault lock's timing. */ +class DriveSettingsLogicTest { + + @Test + fun `auto lock - only the offered minutes, else 3`() { + assertEquals(listOf(1, 3, 5, 15), DriveSettingsLogic.AUTO_LOCK_CHOICES) + listOf(1, 3, 5, 15).forEach { assertEquals(it, DriveSettingsLogic.autoLock(it)) } + assertEquals(3, DriveSettingsLogic.autoLock(0)) + assertEquals(3, DriveSettingsLogic.autoLock(7)) + assertEquals(3, DriveSettingsLogic.autoLock(-1)) + assertEquals(3, DriveSettingsValues().autoLockMinutes) + assertTrue(DriveSettingsValues().vaultBiometric) + assertTrue(DriveSettingsValues().lockOnLeave) + } + + @Test + fun `upload only on wifi waits on a metered network`() { + assertTrue(DriveSettingsLogic.uploadAllowed(wifiOnly = false, connected = true, unmetered = false)) + assertTrue(DriveSettingsLogic.uploadAllowed(wifiOnly = true, connected = true, unmetered = true)) + assertFalse(DriveSettingsLogic.uploadAllowed(wifiOnly = true, connected = true, unmetered = false)) + assertFalse(DriveSettingsLogic.uploadAllowed(wifiOnly = false, connected = false, unmetered = true)) + } + + private fun msg(topic: String) = PushMessage( + seq = 1, id = 1, eventId = "e", topic = topic, priority = "normal", title = "t", body = "", + createdAt = null, expiresAt = null, collapseKey = null, silent = false, route = null, actions = emptyList(), dataJson = null, + ) + + @Test + fun `drive pushes - deadlines and vault requests can stay off this phone`() { + val all = DriveSettingsValues() + val none = all.copy(notifyDeadlines = false, notifyVaultRequests = false) + assertTrue(DrivePush.shown(msg("plugin:gatecontrol-drive:deadline"), all)) + assertFalse(DrivePush.shown(msg("plugin:gatecontrol-drive:deadline"), none)) + assertFalse(DrivePush.shown(msg("plugin:gatecontrol-drive:vault"), none)) + assertTrue(DrivePush.shown(msg("plugin:gatecontrol-drive:vault"), none.copy(notifyVaultRequests = true))) + // other Drive pushes and other plugins are never held back + assertTrue(DrivePush.shown(msg("plugin:gatecontrol-drive:guard"), none)) + assertTrue(DrivePush.shown(msg("plugin:gatecontrol-door:deadline"), none)) + } + + @Test + fun `vault lock - after the minutes without use`() { + val min = 60_000L + assertEquals(3 * min, DriveVaultLockTimer.remaining(now = 1_000, lastUse = 1_000, minutes = 3)) + assertEquals(min, DriveVaultLockTimer.remaining(now = 1_000 + 2 * min, lastUse = 1_000, minutes = 3)) + assertTrue(DriveVaultLockTimer.remaining(now = 1_000 + 3 * min, lastUse = 1_000, minutes = 3) <= 0) + assertTrue(DriveVaultLockTimer.remaining(now = 1_000 + 2 * min, lastUse = 1_000, minutes = 1) < 0) + assertEquals(15 * min, DriveVaultLockTimer.remaining(now = 5, lastUse = 5, minutes = 15)) + } + + @Test + fun `vault lock - leaving the app locks unless switched off or the vault opened another app`() { + assertTrue(DriveVaultLockTimer.lockOnStop(lockOnLeave = true, away = false)) + assertFalse(DriveVaultLockTimer.lockOnStop(lockOnLeave = true, away = true)) + assertFalse(DriveVaultLockTimer.lockOnStop(lockOnLeave = false, away = false)) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveTreeTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveTreeTest.kt new file mode 100644 index 00000000..f27aef3b --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveTreeTest.kt @@ -0,0 +1,136 @@ +package com.gatecontrol.android.drive + +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.11: the menu's folder tree, the vaults kept out of the lists, the areas of a new vault. */ +class DriveTreeTest { + + private fun area(id: String, kind: String, root: Long, role: String = "write", archived: Boolean = false) = + DArea(id, kind, id, role, null, root, archived) + + private fun dir(id: Long, name: String, hasDirs: Boolean? = null) = DTreeDir(id, name, hasDirs) + + private fun node(id: Long, name: String, dir: Boolean = true, vault: String? = null, parent: Long = 1) = + DNode(id, "mine:1", parent, name, dir, 0, null, null, 1, null, null, null, vaultId = vault) + + private val mine = area("mine:1", "mine", 1) + private val family = area("family:1", "family", 100) + + @Test + fun `closed areas show one row each, with an arrow until their folders are known`() { + val rows = DriveTree.flatten(listOf(mine, family), emptySet(), emptyMap()) + assertEquals(listOf(1L, 100L), rows.map { it.id }) + assertTrue(rows.all { it.depth == 0 && it.expandable && !it.expanded && !it.loading }) + assertEquals(mine, rows[0].area) + // an area without folders has no arrow once that is known + val empty = DriveTree.flatten(listOf(mine), emptySet(), mapOf(1L to emptyList())) + assertFalse(empty.single().expandable) + } + + @Test + fun `opened folders follow their parent one level deeper, in any depth`() { + val children = mapOf( + 1L to listOf(dir(2, "Bilder", true), dir(3, "Dokumente", false)), + 2L to listOf(dir(4, "Kamera-Uploads", true)), + 4L to listOf(dir(5, "2026", null)), + ) + val rows = DriveTree.flatten(listOf(mine, family), setOf(1L, 2L, 4L), children) + assertEquals(listOf(1L, 2L, 4L, 5L, 3L, 100L), rows.map { it.id }) + assertEquals(listOf(0, 1, 2, 3, 1, 0), rows.map { it.depth }) + assertTrue(rows.take(5).all { it.areaId == "mine:1" }) + assertEquals("family:1", rows.last().areaId) + // hasDirs false: no arrow; null (older Drive): the arrow until loaded + assertFalse(rows.first { it.id == 3L }.expandable) + assertTrue(rows.first { it.id == 5L }.expandable) + assertNull(rows.first { it.id == 2L }.area) + } + + @Test + fun `an opened folder whose subfolders are not loaded yet is loading`() { + val rows = DriveTree.flatten(listOf(mine), setOf(1L, 2L), mapOf(1L to listOf(dir(2, "Bilder", true)))) + assertTrue(rows.first { it.id == 2L }.loading) + assertEquals(listOf(2L), DriveTree.needs(rows)) + // opened but known to have none: neither open nor loading + val none = DriveTree.flatten(listOf(mine), setOf(1L, 2L), mapOf(1L to listOf(dir(2, "Bilder", false)))) + assertFalse(none.first { it.id == 2L }.expanded) + assertTrue(DriveTree.needs(none).isEmpty()) + } + + @Test + fun `a loop in the answers never hangs the tree`() { + val rows = DriveTree.flatten(listOf(mine), setOf(1L, 2L), mapOf(1L to listOf(dir(2, "a", true)), 2L to listOf(dir(1, "loop", true), dir(3, "b")))) + assertEquals(listOf(1L, 2L, 3L), rows.map { it.id }) + } + + @Test + fun `opened folders are kept as strings and read back`() { + val open = DriveTree.toggle(DriveTree.toggle(emptySet(), 5L), 7L) + assertEquals(setOf(5L, 7L), open) + assertEquals(setOf(7L), DriveTree.toggle(open, 5L)) + assertEquals(open, DriveTree.decode(DriveTree.encode(open))) + assertEquals(setOf(3L), DriveTree.decode(setOf("3", "x", "-1", "0"))) + assertTrue(DriveTree.decode(null).isEmpty()) + } + + @Test + fun `the tree lists folders only, never a vault, by name`() { + val dirs = DriveTree.dirsOf( + listOf(node(3, "zeta"), node(2, "Alpha"), node(4, "file.pdf", dir = false), node(5, "4f1c…", vault = "Vt9bJm0n2QwErTyUiOpAsD")), + mapOf(2L to true), + ) + assertEquals(listOf("Alpha", "zeta"), dirs.map { it.name }) + assertEquals(true, dirs[0].hasDirs) + assertNull(dirs[1].hasDirs) + } + + @Test + fun `json - children with hasDirs (Drive 1_11) and without (older)`() { + val o = JsonParser.parseString( + """{"items":[ + {"id":2,"areaId":"mine:1","parentId":1,"name":"Bilder","isDir":true,"hasDirs":true}, + {"id":3,"areaId":"mine:1","parentId":1,"name":"Akten","isDir":true,"hasDirs":false}, + {"id":4,"areaId":"mine:1","parentId":1,"name":"a.pdf","isDir":false}, + {"id":5,"areaId":"mine:1","parentId":1,"name":"0123456789abcdef0123456789abcdef","isDir":true,"vaultId":"Vt9bJm0n2QwErTyUiOpAsD","encrypted":true} + ],"total":4}""", + ).asJsonObject + val dirs = DriveJson.treeDirs(o)!! + assertEquals(listOf(3L to false, 2L to true), dirs.map { it.id to it.hasDirs }) + assertNull(DriveJson.treeDirs(JsonParser.parseString("{}").asJsonObject)) + } + + @Test + fun `groups - Persoenlich is Meine Dateien, Gemeinsam the rest`() { + val team = area("team:1", "team", 200) + val child = area("child:1", "child", 300) + assertEquals(listOf(mine), DriveTree.personal(listOf(family, mine, team))) + assertEquals(listOf(family, team, child), DriveTree.shared(listOf(family, mine, team, child))) + } + + @Test + fun `vault folders stay out of the lists, inside a vault nothing is left out`() { + val vault = "Vt9bJm0n2QwErTyUiOpAsD" + val top = DFolder(node(1, "", parent = 0).copy(parentId = null), emptyList(), "write", listOf(node(2, "a"), node(5, "x", vault = vault), node(6, "b.pdf", dir = false)), total = 3, next = null) + val out = DriveLogic.withoutVaults(top) + assertEquals(listOf(2L, 6L), out.items.map { it.id }) + assertEquals(2, out.total) + val inside = DFolder(node(5, "x", vault = vault), emptyList(), "write", listOf(node(7, "y", vault = vault)), total = 1, next = null) + assertEquals(inside, DriveLogic.withoutVaults(inside)) + assertEquals(listOf(2L), DriveLogic.withoutVaults(listOf(node(2, "a"), node(5, "x", vault = vault))).map { it.id }) + } + + @Test + fun `a new vault - Nur ich, the family with a family seat, team folders with a business seat`() { + val team = area("team:1", "team", 200) + val teamRead = area("team:2", "team", 201, role = "read") + val child = area("child:1", "child", 300) + val archived = area("family:2", "family", 400, archived = true) + assertEquals(listOf("mine:1", "family:1"), DriveLogic.vaultAreas(listOf(family, child, mine, archived), "family").map { it.id }) + assertEquals(listOf("mine:1", "team:1"), DriveLogic.vaultAreas(listOf(team, teamRead, mine, family), "business").map { it.id }) + assertTrue(DriveLogic.vaultAreas(listOf(mine.copy(role = "read")), "family").isEmpty()) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKeyTest.kt b/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKeyTest.kt index 0bbae352..427d5f9e 100644 --- a/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKeyTest.kt +++ b/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultDeviceKeyTest.kt @@ -1,23 +1,55 @@ package com.gatecontrol.android.drive.vault import com.gatecontrol.android.familie.emergency.AesGcmCipher +import com.gatecontrol.android.familie.emergency.CacheCipher import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File import javax.crypto.KeyGenerator +import javax.crypto.SecretKey -/** The device key lies on the disk only encrypted; an unreadable one is replaced (a new device for the vaults). */ +/** + * The device key lies on the disk only encrypted; an unreadable one is replaced (a new device for the vaults). Since + * Drive 1.11 it waits for the person's confirmation, and a key of before moves there without a new approval. + */ class DriveVaultDeviceKeyTest { @TempDir lateinit var dir: File - private val aes = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private fun aes(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - private fun key(file: File = File(dir, "device.key"), secret: javax.crypto.SecretKey = aes) = DriveVaultDeviceKey(file, AesGcmCipher { secret }) + private val plainKey = aes() + private val authKey = aes() + + /** A Keystore key that asks for the person: usable only while [authed] (as within the confirmation's time). */ + private class AuthCipher(secret: SecretKey) : CacheCipher { + var authed = false + var broken = false + private val inner = AesGcmCipher { secret } + + private fun check() { + if (broken) throw DriveVaultDeviceKey.Unavailable(IllegalStateException("keystore")) + if (!authed) throw DriveVaultDeviceKey.AuthRequired() + } + + override fun encrypt(plain: ByteArray): ByteArray = check().let { inner.encrypt(plain) } + + override fun decrypt(blob: ByteArray): ByteArray = check().let { inner.decrypt(blob) } + } + + private val auth = AuthCipher(authKey) + private var dropped = 0 + private val file get() = File(dir, "device.key") + + private fun key(plain: SecretKey = plainKey, wantAuth: Boolean = true) = + DriveVaultDeviceKey(file, AesGcmCipher { plain }, auth, { wantAuth }, { dropped++ }) private fun contains(hay: ByteArray, needle: ByteArray): Boolean = (0..hay.size - needle.size).any { i -> needle.indices.all { hay[i + it] == needle[it] } } @@ -28,7 +60,7 @@ class DriveVaultDeviceKeyTest { val again = key().keyPair() assertArrayEquals(first.publicKey, again.publicKey) assertArrayEquals(first.privateKey, again.privateKey) - val stored = File(dir, "device.key").readBytes() + val stored = file.readBytes() assertFalse(contains(stored, first.privateKey)) assertEquals(DriveVaultCrypto.deviceId(first.publicKey), key().deviceId()) assertEquals(DriveVaultCrypto.safetyNumber(first.publicKey), key().safetyNumber()) @@ -37,8 +69,71 @@ class DriveVaultDeviceKeyTest { @Test fun `a key the keystore cannot open is replaced`() { val first = key().keyPair() - val other = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val next = key(secret = other).keyPair() + val next = key(plain = aes()).keyPair() assertNotEquals(DriveVaultCrypto.deviceId(first.publicKey), DriveVaultCrypto.deviceId(next.publicKey)) } + + @Test + fun `a key of before Drive 1_11 moves behind the confirmation, the same pair, no new approval`() { + // v1: "GDVK" 1 ‖ the plain Keystore key's encryption of the private key + val pair = DriveVaultCrypto.newKeyPair() + file.writeBytes(byteArrayOf(0x47, 0x44, 0x56, 0x4b, 0x01) + AesGcmCipher { plainKey }.encrypt(pair.privateKey)) + val k = key() + // the device id needs no confirmation (the public key is written next to it now) + assertEquals(DriveVaultCrypto.deviceId(pair.publicKey), k.deviceId()) + assertEquals(2, file.readBytes()[4].toInt()) + assertEquals(false, k.protectedByAuth()) + // without the confirmation nothing moves + assertThrows(DriveVaultDeviceKey.AuthRequired::class.java) { k.protect(true) } + assertEquals(false, k.protectedByAuth()) + assertEquals(0, dropped) + // confirmed: moved, the old Keystore key goes + auth.authed = true + k.protect(true) + assertEquals(true, k.protectedByAuth()) + assertEquals(1, dropped) + assertFalse(contains(file.readBytes(), pair.privateKey)) + // later: the private key only with the confirmation, the public one always + auth.authed = false + val later = key(plain = aes()) + assertThrows(DriveVaultDeviceKey.AuthRequired::class.java) { later.keyPair() } + assertTrue(file.isFile) + assertEquals(DriveVaultCrypto.deviceId(pair.publicKey), later.deviceId()) + auth.authed = true + assertArrayEquals(pair.privateKey, later.keyPair().privateKey) + } + + @Test + fun `switching the confirmation off moves the key back, the same pair`() { + auth.authed = true + val first = key().keyPair() + assertEquals(true, key().protectedByAuth()) + key().protect(false) + auth.authed = false + val k = key(wantAuth = false) + assertEquals(false, k.protectedByAuth()) + assertArrayEquals(first.privateKey, k.keyPair().privateKey) + } + + @Test + fun `a new key waits for the confirmation, then moves`() { + assertNull(key().protectedByAuth()) + val pair = key().keyPair() + assertEquals(false, key().protectedByAuth()) + auth.authed = true + key().protect(true) + assertEquals(true, key().protectedByAuth()) + assertArrayEquals(pair.publicKey, key().keyPair().publicKey) + } + + @Test + fun `a keystore failure never throws the key away`() { + auth.authed = true + val pair = key().keyPair() + auth.broken = true + assertThrows(DriveVaultDeviceKey.Unavailable::class.java) { key().keyPair() } + assertTrue(file.isFile) + auth.broken = false + assertArrayEquals(pair.privateKey, key().keyPair().privateKey) + } } diff --git a/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultNamesTest.kt b/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultNamesTest.kt new file mode 100644 index 00000000..aaf70ec8 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/vault/DriveVaultNamesTest.kt @@ -0,0 +1,41 @@ +package com.gatecontrol.android.drive.vault + +import com.gatecontrol.android.familie.emergency.AesGcmCipher +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import javax.crypto.KeyGenerator + +/** The vaults' own names for the menu (Drive 1.11): encrypted on the phone, dropped when unreadable or gone. */ +class DriveVaultNamesTest { + + @TempDir lateinit var dir: File + + private val aes = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private val file get() = File(dir, "names.bin") + + private fun names(secret: javax.crypto.SecretKey = aes) = DriveVaultNames(file, AesGcmCipher { secret }) + + @Test + fun `names are kept encrypted and read back`() { + names().put("a", "Steuer-Unterlagen") + names().put("b", "Familien-Dokumente") + assertEquals("Steuer-Unterlagen", names().get("a")) + assertFalse(String(file.readBytes(), Charsets.ISO_8859_1).contains("Steuer")) + } + + @Test + fun `vaults that are gone lose their names, an unreadable file is dropped`() { + names().put("a", "A") + names().put("b", "B") + names().keep(setOf("b")) + assertNull(names().get("a")) + assertEquals("B", names().get("b")) + val other = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + assertNull(names(other).get("b")) + assertFalse(file.exists()) + } +} From d84836da1e83f8429fcca8981f82bfd5a8192cd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:19:49 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Drive:=20Tresor=20ohne=20Ger=C3=A4teliste?= =?UTF-8?q?=20nie=20ohne=20Best=C3=A4tigung=20=C3=B6ffnen,=20Importe=20auf?= =?UTF-8?q?ger=C3=A4umt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt | 2 -- .../java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt index 7cd55d8f..1a07a0c7 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveHomeScreen.kt @@ -60,11 +60,9 @@ import com.gatecontrol.android.drive.DriveTransfers import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIconSquare import com.gatecontrol.android.ui.components.GcIcons -import com.gatecontrol.android.ui.components.GcListRow import com.gatecontrol.android.ui.components.GcRowDivider import com.gatecontrol.android.ui.components.GcScreenTitle import com.gatecontrol.android.ui.components.GcSectionLabel -import com.gatecontrol.android.ui.components.GcSwitchRow import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import com.gatecontrol.android.ui.theme.GateControlTheme diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt index c4ebf66e..cf12e7b4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveVaultScreen.kt @@ -229,7 +229,8 @@ class DriveVaultViewModel @Inject constructor( } val vault = v.value // Drive 1.11: an approved device opens the vault only after the person confirmed (no screen lock: never) - if (!session.unlocked(vault.id) && session.approved(vault)) { + // (a vault answer without this person's devices counts as approved: never opened without the confirmation) + if (!session.unlocked(vault.id) && (vault.myDevices.isEmpty() || session.approved(vault))) { val secure = deviceSecure() if (!secure || (settings.current.vaultBiometric && !confirmed)) { _ui.value = Ui.Gate(vault, session.label(vault), noScreenLock = !secure) From dd10366989b12455c944196626941fa1b6dc74eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:30:37 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Drive-Einstellungen:=20Texte=20au=C3=9Ferha?= =?UTF-8?q?lb=20der=20Komposition=20aufl=C3=B6sen=20(Lint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../android/ui/drive/DriveSettingsScreen.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt index dd140e7b..8c996e48 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveSettingsScreen.kt @@ -252,12 +252,7 @@ fun DriveSettingsScreen( } LaunchedEffect(message) { message?.let { - val text = when (it) { - "cache_cleared" -> context.getString(R.string.drive_settings_cache_cleared) - "device_key" -> context.getString(R.string.drive_settings_vault_key_failed) - else -> driveText(context, it) - } - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + Toast.makeText(context, settingsText(context, it), Toast.LENGTH_SHORT).show() viewModel.clearMessage() } } @@ -371,12 +366,13 @@ fun DriveSettingsScreen( } GcRowDivider() val approved = vaultInfo.approved + val unnamed = stringResource(R.string.drive_vault) GcListRow( title = stringResource(R.string.drive_settings_vault_approved), description = if (approved.isEmpty()) { stringResource(R.string.drive_settings_vault_approved_none) } else { - approved.map { it.ifEmpty { context.getString(R.string.drive_vault) } }.joinToString(", ") + approved.map { it.ifEmpty { unnamed } }.joinToString(", ") }, leading = { Icon(GcIcons.Vault, contentDescription = null, tint = x.accentText, modifier = Modifier.size(22.dp)) }, trailing = if (approved.isNotEmpty()) ({ @@ -511,6 +507,13 @@ fun DriveSettingsScreen( } } +/** A message of the settings as a sentence (outside of composition). */ +private fun settingsText(context: Context, code: String): String = when (code) { + "cache_cleared" -> context.getString(R.string.drive_settings_cache_cleared) + "device_key" -> context.getString(R.string.drive_settings_vault_key_failed) + else -> driveText(context, code) +} + /** "Name, aufsteigend" and so on. */ @Composable private fun sortLabel(s: DriveLogic.FolderSort): String {