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
73 changes: 67 additions & 6 deletions app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.BufferedSink
import okio.buffer
import okio.source
import retrofit2.Response
import timber.log.Timber
Expand All @@ -43,6 +44,7 @@ class DriveApi @Inject constructor(
private val setupRepository: SetupRepository,
private val apiClientProvider: ApiClientProvider,
private val settingsRepository: SettingsRepository,
private val homeCache: com.gatecontrol.android.homecache.HomeCacheLan,
) {
companion object {
const val CODE_INVALID = "invalid_answer"
Expand Down Expand Up @@ -137,7 +139,13 @@ class DriveApi @Inject constructor(

// ─── who, where ─────────────────────────────────────────────────

suspend fun me(): DResult<DMe> = get("me", parse = DriveJson::me)
suspend fun me(): DResult<DMe> = get("me", parse = DriveJson::me).also { r ->
if (r is DResult.Ok) homeCacheOn = r.value.has(DMe.FEATURE_HOME_CACHE)
}

/** The last `GET me` named `homeCache` (Drive 1.20 on GateControl 1.231+): downloads may come from the home gateway. */
@Volatile
private var homeCacheOn = false

suspend fun areas(): DResult<List<DArea>> = get("areas", parse = DriveJson::areas)

Expand Down Expand Up @@ -231,6 +239,17 @@ class DriveApi @Inject constructor(

suspend fun deleteVersion(id: Long, versionId: Long): DResult<Unit> = del("nodes/$id/versions/$versionId", unit)

// ─── "Offline verfügbar" (Drive 1.20, feature `offline`) ─────────

/** The files and folders I marked, newest first (only ones I may still read, not in the trash). */
suspend fun offline(): DResult<List<DNode>> = get("offline", parse = DriveJson::items)

/** Marks [id] (a file, or a folder with everything below it); at most 500 a person (`invalid { field: offline }`). */
suspend fun markOffline(id: Long): DResult<DNode> = put("nodes/$id/offline", JsonObject(), DriveJson::nodeAnswer)

/** Takes the mark off [id] (fine for a node that is gone too). */
suspend fun unmarkOffline(id: Long): DResult<Unit> = del("nodes/$id/offline", unit)

suspend fun trash(area: String? = null): DResult<List<DNode>> {
val q = if (area == null) emptyMap() else mapOf("area" to (DriveJson.areaId(area) ?: return bad()))
return get("trash", q, DriveJson::items)
Expand Down Expand Up @@ -727,18 +746,38 @@ class DriveApi @Inject constructor(
/**
* The file [nodeId] (or one of its [versionId]s) as a stream. `GET …/content` comes first: it names the media and
* tells a child that a parent looked into its folder. The caller closes the body; null when unreachable or refused.
*
* Drive 1.20 with GateControl's home cache (`homeCache`): at home the content request carries the device's LAN
* addresses (`X-GC-Local`) and may answer a `lan` hint — then the file comes from the home gateway (pinned
* certificate, checked against [expectedSha256] and the hint's hash, [onProgress] while it loads) and the body
* reads that copy; any error there takes the normal download, without a word.
*/
suspend fun download(nodeId: Long, versionId: Long? = null): DResult<Response<ResponseBody>> {
suspend fun download(
nodeId: Long,
versionId: Long? = null,
expectedSha256: String? = null,
onProgress: (done: Long, total: Long) -> Unit = { _, _ -> },
): DResult<Response<ResponseBody>> {
val path = if (versionId == null) "nodes/$nodeId/content" else "nodes/$nodeId/versions/$versionId/content"
val media = get(path, parse = DriveJson::contentMediaId)
if (media !is DResult.Ok) return media.notOk()
// only the current content can come with a hint; the addresses go only while the server says "at home"
val local = if (versionId == null && homeCacheOn) homeCache.localHeader() else null
val content = if (local == null) {
get(path, parse = DriveJson::content)
} else {
call(request = { pluginGetWithLocal(DRIVE_PLUGIN, path, lang(), local) }, parse = DriveJson::content)
}
if (content !is DResult.Ok) return content.notOk()
val media = content.value
// An empty file has no media to fetch: hand back an empty body.
if (media.value == DriveJson.EMPTY_CONTENT) return DResult.Ok(Response.success(ByteArray(0).toResponseBody(null)))
if (media.mediaId == DriveJson.EMPTY_CONTENT) return DResult.Ok(Response.success(ByteArray(0).toResponseBody(null)))
media.lan?.let { hint ->
homeCache.fetch(hint, expectedSha256, media.size, onProgress)?.let { file -> return DResult.Ok(Response.success(fileBody(file))) }
}
val url = setupRepository.getServerUrl()
if (url.isBlank() || !setupRepository.isConfigured()) return DResult.NotConfigured
return try {
// `423 scan_pending`: the file is still being checked — asked again after `Retry-After` (a few times)
val a = MediaScanHttp.get { withTimeoutOrNull(TIMEOUT_MS) { apiClientProvider.mediaClient(url).pluginMediaGet(DRIVE_PLUGIN, media.value) } }
val a = MediaScanHttp.get { withTimeoutOrNull(TIMEOUT_MS) { apiClientProvider.mediaClient(url).pluginMediaGet(DRIVE_PLUGIN, media.mediaId) } }
?: return DResult.Offline
val r = a.response
if (r.isSuccessful && r.body() != null) DResult.Ok(r) else {
Expand All @@ -755,6 +794,28 @@ class DriveApi @Inject constructor(
}
}

/** A body reading [file] (a copy from the home gateway), which goes when the body is closed. */
private fun fileBody(file: File): ResponseBody = object : ResponseBody() {
private val source by lazy {
object : okio.ForwardingSource(file.source()) {
override fun close() {
try {
super.close()
} finally {
file.delete()
}
}
}.buffer()
}

override fun contentType(): MediaType? = null

override fun contentLength(): Long = file.length()

// closing the body closes the source (also when nothing was read): the copy goes
override fun source(): okio.BufferedSource = source
}

/**
* Uploads [file] as [mime] → its media id. Files above [RESUMABLE_FROM] go resumable: parts of [PART_BYTES] as
* `PUT …/media?uploadId&offset` with `Content-Range`; after an interruption or a 409 the upload continues at the
Expand Down
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 @@ -281,6 +281,20 @@ internal object DriveJson {
/** Stands for "no media, the file is empty" in [contentMediaId]; never a real media id. */
const val EMPTY_CONTENT = ""

/**
* `GET nodes/<id>/content` → the media id ([contentMediaId]), its size and — Drive 1.20 with GateControl's home
* cache, for an app at home — the `lan` hint to load it from the home gateway ([HomeCache.parse]; a hint that is not
* right is dropped).
*/
fun content(o: JsonObject): DContent? {
val media = contentMediaId(o) ?: return null
return DContent(
mediaId = media,
size = o.long("size")?.takeIf { it >= 0 },
lan = if (media == EMPTY_CONTENT) null else com.gatecontrol.android.network.HomeCache.parse(o.get("lan")),
)
}

fun versions(o: JsonObject): DVersions? {
val current = node(o.obj("current")) ?: return null
return DVersions(
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,19 @@ data class DMe(
const val FEATURE_DEADLINE_MAIL_CALENDAR = "deadlineMailCalendar"
/** Drive 1.18: the overview of Drive in one call (`GET overview`); without it the app builds it from the rest. */
const val FEATURE_OVERVIEW = "overview"
/** Drive 1.20: "Offline verfügbar" marks (`GET offline`, `PUT|DELETE nodes/<id>/offline`). */
const val FEATURE_OFFLINE = "offline"
/**
* Drive 1.20 on GateControl 1.231+: files in the home gateways' caches — `GET nodes/<id>/content` may carry a
* `lan` hint when the app sends its LAN addresses (`X-GC-Local`).
*/
const val FEATURE_HOME_CACHE = "homeCache"
}
}

/** `GET nodes/<id>/content`: the media to load ([DriveJson.EMPTY_CONTENT]: an empty file), its size, the home cache's hint. */
data class DContent(val mediaId: String, val size: Long?, val lan: com.gatecontrol.android.network.LanHint?)

/** `me.links`: the portal page of a file or folder (template with `{id}`) and of Drive itself. */
data class DPortalLinks(val node: String?, val drive: String?)

Expand Down
67 changes: 67 additions & 0 deletions app/src/main/java/com/gatecontrol/android/drive/DriveOffline.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.gatecontrol.android.drive

import android.os.SystemClock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton

/**
* "Offline verfügbar" (Drive 1.20, feature `offline`): the files and folders the person marked. Drive offers them to
* the home gateways' caches first (GateControl's "Zwischenspeicher im Heimnetz"); at home the app then loads them from
* the gateway. This app keeps no copies of its own beyond the opened files' cache: the mark means "kept ready at home",
* not "on this phone". [ids] marks the rows (a folder's content is marked through the folder only).
*/
@Singleton
class DriveOffline @Inject constructor(private val api: DriveApi) {

private val _ids = MutableStateFlow<Set<Long>>(emptySet())
/** The marked nodes as far as known (after [list] or [refresh]). */
val ids: StateFlow<Set<Long>> = _ids.asStateFlow()

@Volatile
private var loadedAt = 0L

/** The marked nodes, newest first; [ids] follows. */
suspend fun list(): DResult<List<DNode>> = api.offline().also { r ->
if (r is DResult.Ok) {
_ids.value = r.value.mapTo(HashSet()) { it.id }
loadedAt = SystemClock.elapsedRealtime()
}
}

/** Loads the marks when they are older than a minute (a folder shown again). */
suspend fun refresh(me: DMe?) {
if (me?.has(DMe.FEATURE_OFFLINE) != true) return
if (loadedAt != 0L && SystemClock.elapsedRealtime() - loadedAt < STALE_MS) return
list()
}

/** Marks or unmarks [node] → null on success, else the code (`offline_max` at the limit of 500). */
suspend fun set(node: DNode, on: Boolean): String? {
val r: DResult<*> = if (on) api.markOffline(node.id) else api.unmarkOffline(node.id)
if (r is DResult.Ok) {
_ids.update { if (on) it + node.id else it - node.id }
return null
}
return when (r) {
is DResult.Failed -> if (DriveOfflineLogic.atLimit(r)) "offline_max" else r.code
DResult.Offline -> "offline"
DResult.NotConfigured -> "not_configured"
is DResult.Ok -> null
}
}

private companion object {
const val STALE_MS = 60_000L
}
}

/** Pure parts of "Offline verfügbar", unit-tested. */
object DriveOfflineLogic {
/** Drive's refusal at the limit: `400 invalid { field: 'offline', max: 500 }`. */
fun atLimit(f: DResult.Failed): Boolean =
f.code == "invalid" && f.body?.get("field")?.takeIf { it.isJsonPrimitive }?.asString == "offline"
}
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,15 @@ class DriveTransfers @Inject constructor(
return@withContext DResult.Ok(target)
}
_downloading.update { it + (node.id to 0f) }
// the bar only moves forward: a copy from the home gateway is loaded first, then read here at once
val progress: (Long, Long) -> Unit = { done, total ->
if (total > 0) {
val f = (done.toFloat() / total).coerceIn(0f, 1f)
_downloading.update { m -> if ((m[node.id] ?: 0f) < f) m + (node.id to f) else m }
}
}
try {
when (val r = api.download(node.id, versionId)) {
when (val r = api.download(node.id, versionId, expectedSha256 = node.sha256.takeIf { versionId == null }, onProgress = progress)) {
is DResult.Ok -> {
dir.mkdirs()
val part = File(dir, "$name.part")
Expand All @@ -436,7 +443,7 @@ class DriveTransfers @Inject constructor(
if (n < 0) break
out.write(buf, 0, n)
done += n
if (total > 0) _downloading.update { it + (node.id to (done.toFloat() / total).coerceIn(0f, 1f)) }
progress(done, total)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,8 @@ class DriveVaultSession @Inject constructor(
val dir = File(openDir(), node.id.toString()).apply { mkdirs() }
val target = File(dir, DriveLogic.safeName(name) ?: "file")
val part = File(dir, target.name + ".part")
val r = api.download(node.id)
// the ciphertext — from the home gateway too, when it holds it (checked against the node's hash)
val r = api.download(node.id, expectedSha256 = node.sha256)
if (r !is DResult.Ok) return@withContext r.notOk()
try {
val body = r.value.body() ?: return@withContext DResult.Failed(DriveApi.CODE_INVALID, 0)
Expand Down
Loading
Loading