Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<DTreeDir>> {
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`. */
Expand Down Expand Up @@ -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<DChanges> =
Expand Down Expand Up @@ -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<DPhotos> =
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<List<DMonth>> =
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<List<DMemories>> =
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`,
Expand All @@ -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<DAlbum> {
Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveCache.kt
Original file line number Diff line number Diff line change
@@ -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<File> = 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")
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,20 @@ internal object DriveJson {

fun items(o: JsonObject): List<DNode>? = o.arr("items")?.mapNotNull { (it as? JsonObject)?.let(::node) }

/** `GET nodes/<id>/children[?dirs=1]` → the folders for the menu's tree (`hasDirs` from Drive 1.11 on). */
fun treeDirs(o: JsonObject): List<DTreeDir>? {
val arr = o.arr("items") ?: return null
val nodes = ArrayList<DNode>()
val hasDirs = HashMap<Long, Boolean>()
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/<id>/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`).
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,33 @@ internal object DriveLogic {
/** A vault's nodes with their names: folders first, then by name. */
fun sortVault(items: List<Pair<DNode, String>>): List<Pair<DNode, String>> =
items.sortedWith(compareBy<Pair<DNode, String>>({ !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<DNode>): List<DNode> = 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<DArea>, seat: String?): List<DArea> = 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class DrivePreviews @Inject constructor(
private val inFlight = ConcurrentHashMap<String, CompletableDeferred<Bitmap?>>()
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) }

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DrivePush.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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:<node>` | `vault:<vault>:<node>`; null for other messages. */
fun destination(message: PushMessage): String? {
if (!isDriveMessage(message)) return null
Expand Down
90 changes: 90 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveSettings.kt
Original file line number Diff line number Diff line change
@@ -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<DriveSettingsValues> = _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)
}
}
17 changes: 17 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -98,6 +99,12 @@ class DriveTransfers @Inject constructor(
val paused: StateFlow<Boolean> = _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) {
Expand All @@ -112,6 +119,11 @@ class DriveTransfers @Inject constructor(
private val _changed = MutableSharedFlow<Long>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val changed: SharedFlow<Long> = _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]. */
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading