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 116e2519..dc484636 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveApi.kt @@ -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 @@ -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" @@ -137,7 +139,13 @@ class DriveApi @Inject constructor( // ─── who, where ───────────────────────────────────────────────── - suspend fun me(): DResult = get("me", parse = DriveJson::me) + suspend fun me(): DResult = 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> = get("areas", parse = DriveJson::areas) @@ -231,6 +239,17 @@ class DriveApi @Inject constructor( suspend fun deleteVersion(id: Long, versionId: Long): DResult = 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> = 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 = 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 = del("nodes/$id/offline", unit) + suspend fun trash(area: String? = null): DResult> { val q = if (area == null) emptyMap() else mapOf("area" to (DriveJson.areaId(area) ?: return bad())) return get("trash", q, DriveJson::items) @@ -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> { + suspend fun download( + nodeId: Long, + versionId: Long? = null, + expectedSha256: String? = null, + onProgress: (done: Long, total: Long) -> Unit = { _, _ -> }, + ): DResult> { 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 { @@ -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 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 3ec3f3ba..8b342757 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveJson.kt @@ -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//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( 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 7126db6f..54ee3842 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveModels.kt @@ -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//offline`). */ + const val FEATURE_OFFLINE = "offline" + /** + * Drive 1.20 on GateControl 1.231+: files in the home gateways' caches — `GET nodes//content` may carry a + * `lan` hint when the app sends its LAN addresses (`X-GC-Local`). + */ + const val FEATURE_HOME_CACHE = "homeCache" } } +/** `GET nodes//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?) diff --git a/app/src/main/java/com/gatecontrol/android/drive/DriveOffline.kt b/app/src/main/java/com/gatecontrol/android/drive/DriveOffline.kt new file mode 100644 index 00000000..6e659a4c --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveOffline.kt @@ -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>(emptySet()) + /** The marked nodes as far as known (after [list] or [refresh]). */ + val ids: StateFlow> = _ids.asStateFlow() + + @Volatile + private var loadedAt = 0L + + /** The marked nodes, newest first; [ids] follows. */ + suspend fun list(): DResult> = 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" +} 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 917f926b..b541381a 100644 --- a/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt +++ b/app/src/main/java/com/gatecontrol/android/drive/DriveTransfers.kt @@ -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") @@ -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) } } } 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 0badefc6..316c2d8c 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 @@ -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) diff --git a/app/src/main/java/com/gatecontrol/android/homecache/HomeCacheLan.kt b/app/src/main/java/com/gatecontrol/android/homecache/HomeCacheLan.kt new file mode 100644 index 00000000..85343600 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/homecache/HomeCacheLan.kt @@ -0,0 +1,183 @@ +package com.gatecontrol.android.homecache + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkAddress +import android.net.Network +import android.net.NetworkCapabilities +import com.gatecontrol.android.common.VpnSubnet +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.network.HomeCache +import com.gatecontrol.android.network.HomeCacheFetcher +import com.gatecontrol.android.network.HomeNetworkStatus +import com.gatecontrol.android.network.LanHint +import com.gatecontrol.android.network.isPrivateIpv4 +import com.gatecontrol.android.network.probeHomeNetwork +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.net.Inet4Address +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.SocketFactory + +/** + * Whether this device is in the home network, remembered per set of LAN addresses for a while (the server's answer to + * `GET /api/v1/client/home-network`). Home is kept [HOME_MS], "not home" [NOT_HOME_MS], no answer only [UNKNOWN_MS] + * (asked again soon). Pure, unit-tested. + */ +internal class HomeSiteMemory(private val now: () -> Long = System::currentTimeMillis) { + private data class Entry(val home: Boolean, val until: Long) + + private val entries = ConcurrentHashMap() + + /** The remembered answer for [key] (null: none, or it is too old). */ + fun get(key: String): Boolean? = entries[key]?.takeIf { it.until > now() }?.home + + fun put(key: String, status: HomeNetworkStatus) { + if (entries.size > MAX) entries.clear() + val t = now() + entries[key] = when (status) { + is HomeNetworkStatus.Home -> Entry(true, t + HOME_MS) + is HomeNetworkStatus.NotHome, HomeNetworkStatus.Unsupported -> Entry(false, t + NOT_HOME_MS) + HomeNetworkStatus.Unknown -> Entry(false, t + UNKNOWN_MS) + } + } + + fun clear() = entries.clear() + + companion object { + const val HOME_MS = 10 * 60_000L + const val NOT_HOME_MS = 10 * 60_000L + const val UNKNOWN_MS = 60_000L + private const val MAX = 16 + } +} + +/** + * The app side of the home cache ("Zwischenspeicher im Heimnetz", GateControl ≥ 1.231.0): the `X-GC-Local` header + * for a download that may come with a `lan` hint, and loading such a hint from the gateway ([HomeCacheFetcher]: the + * certificate pinned, the bytes checked, any error → the caller takes the normal download). + * + * The header carries the device's private IPv4 addresses on its Wi-Fi / Ethernet networks — never the VPN's own + * addresses or mobile data — and only while the server's home-network check says this device is at home (the same + * check as "Vertrauenswürdige WLANs" and Familie's presence; asked at most every few minutes, see [HomeSiteMemory]). + * + * The connection to the gateway is bound to the Wi-Fi / Ethernet network whose subnet holds the gateway's address, + * so it goes straight through the LAN even while the tunnel is up (this app owns the VPN and may use the networks + * below it, as GateDrop's direct transfer does). Without such a network the system routes it (through the tunnel, + * if it carries the home network — correct, only not faster). + */ +@Singleton +class HomeCacheLan @Inject constructor( + @ApplicationContext private val context: Context, + private val setupRepository: SetupRepository, + private val apiClientProvider: ApiClientProvider, +) { + private val cm: ConnectivityManager? get() = runCatching { context.getSystemService(ConnectivityManager::class.java) }.getOrNull() + private val memory = HomeSiteMemory() + private val probing = Mutex() + + private val fetcher = HomeCacheFetcher(socketFactoryFor = ::socketFactoryFor) + + /** The Wi-Fi / Ethernet networks (never the VPN) with their private IPv4 addresses, Wi-Fi first. */ + private fun lanNetworks(): List>> { + val cm = cm ?: return emptyList() + val vpnSubnet = apiClientProvider.vpnSubnet + return runCatching { + @Suppress("DEPRECATION") + cm.allNetworks.mapNotNull { net -> + val caps = cm.getNetworkCapabilities(net) ?: return@mapNotNull null + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@mapNotNull null + val wifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + if (!wifi && !caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) return@mapNotNull null + val addrs = cm.getLinkProperties(net)?.linkAddresses.orEmpty().filter { la -> + val ip = (la.address as? Inet4Address)?.hostAddress ?: return@filter false + isPrivateIpv4(ip) && !VpnSubnet.contains(vpnSubnet, ip) + } + if (addrs.isEmpty()) null else Triple(net, addrs, wifi) + }.sortedByDescending { it.third }.map { it.first to it.second } + }.getOrDefault(emptyList()) + } + + /** This device's own LAN IPv4 addresses (Wi-Fi / Ethernet, private, not the VPN's). */ + fun localAddresses(): List = lanNetworks().flatMap { (_, addrs) -> addrs.mapNotNull { it.address.hostAddress } }.distinct() + + /** + * The `X-GC-Local` value for a request now: the LAN addresses while the server says this device is at home; null + * otherwise (no Wi-Fi / Ethernet, mobile data only, not at home, no answer). Never throws except cancellation. + */ + suspend fun localHeader(): String? { + val local = HomeCache.localHeader(localAddresses(), excluded = { VpnSubnet.contains(apiClientProvider.vpnSubnet, it) }) ?: return null + memory.get(local)?.let { return if (it) local else null } + return probing.withLock { + memory.get(local)?.let { return@withLock if (it) local else null } + val url = setupRepository.getServerUrl() + if (url.isBlank()) return@withLock null + val status = try { + apiClientProvider.getClient(url).probeHomeNetwork(local.split(','), timeoutMs = PROBE_TIMEOUT_MS) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + HomeNetworkStatus.Unknown + } + memory.put(local, status) + Timber.d("Home cache: home network %s", if (status is HomeNetworkStatus.Home) "yes" else "no") + if (status is HomeNetworkStatus.Home) local else null + } + } + + /** Forget whether this is home (e.g. after a network change the next request asks again anyway: other addresses). */ + fun forget() = memory.clear() + + /** Sockets to [host]: bound to the Wi-Fi / Ethernet network whose subnet holds it; null: the system decides. */ + private fun socketFactoryFor(host: String): SocketFactory? = + lanNetworks().firstOrNull { (_, addrs) -> + addrs.any { la -> la.address.hostAddress?.let { HomeCache.inSubnet(host, it, la.prefixLength) } == true } + }?.first?.socketFactory + + private fun dir(): File = File(context.cacheDir, DIR).apply { mkdirs() } + + /** + * [hint] from the gateway into a new file of the app cache (the caller deletes it) — null when anything went wrong + * (then nothing is left behind). [expectedSha256] / [expectedSize]: what the caller knows of the content. + */ + suspend fun fetch( + hint: LanHint, + expectedSha256: String? = null, + expectedSize: Long? = null, + onProgress: (Long, Long) -> Unit = { _, _ -> }, + ): File? = withContext(Dispatchers.IO) { + prune() + val target = File.createTempFile("lan", ".bin", dir()) + val r = try { + fetcher.fetch(hint, target, expectedSha256, expectedSize, onProgress) + } catch (e: CancellationException) { + target.delete() + throw e + } + if (r is HomeCacheFetcher.Result.Ok) r.file else { + target.delete() + null + } + } + + /** Left-overs of an interrupted run (older than an hour) go. */ + private fun prune() { + val old = System.currentTimeMillis() - 3_600_000L + runCatching { File(context.cacheDir, DIR).listFiles()?.forEach { if (it.lastModified() < old) it.delete() } } + } + + private companion object { + const val DIR = "home-cache" + /** The home check before a download: shorter than the usual one, a slow answer only means the normal way. */ + const val PROBE_TIMEOUT_MS = 4_000L + } +} 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 cb875ea0..4582a166 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -1890,6 +1890,7 @@ private fun DriveHome(navController: NavHostController, onShown: (Long?) -> Unit shown = onShown, search = { nav.go(Screen.DriveSearch.route) }, trash = { nav.go(Screen.DriveTrash.route) }, + offline = { nav.go(Screen.DriveOffline.route) }, storage = { nav.go(Screen.DriveStorage.route) }, folders = { nav.go(Screen.DriveFolders.route) }, album = { nav.go(Screen.DriveAlbum.create(it)) }, @@ -1991,6 +1992,9 @@ private fun NavGraphBuilder.driveRoutes( composable(Screen.DriveTrash.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveTrashScreen(onBack = back) } } + composable(Screen.DriveOffline.route) { + GcContentWidth { com.gatecontrol.android.ui.drive.DriveOfflineScreen(onBack = back, onFolder = folder) } + } composable(Screen.DriveSearch.route) { GcContentWidth { com.gatecontrol.android.ui.drive.DriveSearchScreen(onBack = back, onFolder = folder) } } 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 31cafd6e..971c39c9 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -279,6 +279,8 @@ sealed class Screen(val route: String) { fun create(id: Long) = "drive/folder/$id" } data object DriveTrash : Screen("drive/trash") + /** "Offline verfügbar" (Drive 1.20): the marked files and folders, kept ready in the home network. */ + data object DriveOffline : Screen("drive/offline") data object DriveSearch : Screen("drive/search") data object DriveBackup : Screen("drive/backup") /** "Aufräumen" of the work photos (Drive 1.13): the AI's suggestions among the photos backed up. */ 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 1002d404..d677b11b 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 @@ -266,10 +266,12 @@ internal fun NodeRow( onMore: (() -> Unit)? = null, fresh: Boolean = false, title: String? = null, + /** "Offline verfügbar" (Drive 1.20): marked by the person, kept ready in the home network. */ + offline: Boolean = false, ) { // the virus scan's mark (Drive 1.19) needs the full row; without `scan` the row is as before val scanMark = node.scan?.badge != null - if (!selecting && onMore == null && !fresh && !scanMark) { + if (!selecting && onMore == null && !fresh && !scanMark && !offline) { Column { GcListRow( title = title ?: nodeTitle(node), @@ -322,6 +324,7 @@ internal fun NodeRow( Text(name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) if (fresh) NewHereBadge() if (scanMark) ScanChip(node.scan, Modifier.padding(start = 8.dp)) + if (offline) OfflineMark(Modifier.padding(start = 8.dp)) } val d = description ?: nodeLine(node) if (d.isNotBlank()) Text(d, style = MaterialTheme.typography.bodySmall, color = x.muted) @@ -337,6 +340,18 @@ internal fun NodeRow( } } +/** The mark of a node that is "offline verfügbar" (Drive 1.20): the house of the home network, read out as such. */ +@Composable +internal fun OfflineMark(modifier: Modifier = Modifier) { + val label = stringResource(R.string.drive_offline_badge) + Icon( + GcIcons.House, + contentDescription = label, + tint = GateControlTheme.extraColors.accentText, + modifier = modifier.size(16.dp), + ) +} + /** A Drive code as a sentence. */ @Composable internal fun driveMessage(code: String?): String? { @@ -354,6 +369,9 @@ internal fun driveText(context: Context, code: String): String = driveScanText(c "done_version" -> context.getString(R.string.drive_done_version) "done_restore" -> context.getString(R.string.drive_done_restore) "done_purge" -> context.getString(R.string.drive_done_purge) + "done_offline_on" -> context.getString(R.string.drive_offline_done_on) + "done_offline_off" -> context.getString(R.string.drive_offline_done_off) + "offline_max" -> context.getString(R.string.drive_offline_max) "offline" -> context.getString(R.string.drive_error_offline) "not_licensed" -> context.getString(R.string.drive_not_licensed) "no_seat" -> context.getString(R.string.drive_no_seat) @@ -672,6 +690,8 @@ internal fun NodeGrid( selected: Set = emptySet(), onMore: ((DNode) -> Unit)? = null, fresh: (DNode) -> Boolean = { false }, + /** "Offline verfügbar" (Drive 1.20). */ + offline: (DNode) -> Boolean = { false }, ) { val selecting = selected.isNotEmpty() BoxWithConstraints(Modifier.fillMaxWidth()) { @@ -689,6 +709,7 @@ internal fun NodeGrid( selected = n.id in selected, onMore = onMore?.let { m -> { m(n) } }, fresh = fresh(n), + offline = offline(n), modifier = Modifier.weight(1f), ) } @@ -711,6 +732,7 @@ private fun NodeTile( onMore: (() -> Unit)?, fresh: Boolean, modifier: Modifier = Modifier, + offline: Boolean = false, ) { val x = GateControlTheme.extraColors val look = if (node.encrypted) { @@ -765,13 +787,17 @@ private fun NodeTile( } Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f).padding(start = 8.dp, top = 6.dp, bottom = 6.dp, end = if (onMore != null && !selecting) 0.dp else 8.dp)) { - Text( - name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (offline) OfflineMark(Modifier.padding(start = 4.dp)) + } if (fresh) Text(stringResource(R.string.drive_new_here), style = MaterialTheme.typography.labelSmall, color = x.accentText) } if (onMore != null && !selecting) { 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 5cbbddcc..2e94a6c1 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 @@ -187,6 +187,7 @@ internal fun DriveFolderContent( if (result.resultCode == Activity.RESULT_OK && c != null) viewModel.deletedLocally(c) } val me by viewModel.live.me.collectAsStateWithLifecycle() + val offlineIds by viewModel.offline.ids.collectAsStateWithLifecycle() LifecycleResumeEffect(viewModel) { viewModel.onResume() @@ -284,6 +285,7 @@ internal fun DriveFolderContent( selected = selected, onMore = { menuFor = it }, fresh = isFresh, + offline = { it.id in offlineIds }, ) } else { GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { @@ -298,6 +300,7 @@ internal fun DriveFolderContent( selected = n.id in selected, onMore = { menuFor = n }, fresh = isFresh(n), + offline = n.id in offlineIds, ) } } @@ -441,6 +444,8 @@ internal fun DriveFolderContent( if (one != null && one.isDir && writable && me?.has(DMe.FEATURE_TIME_TRAVEL) == true) add(MoreItem(GcIcons.History, stringResource(R.string.drive_tt_menu)) { viewModel.startTimeTravel(one) }) // Drive 1.6 (feature `links`): the file or folder in the portal. one?.let { n -> DriveLogic.portalNodeUrl(me, n.id) }?.let { url -> add(MoreItem(GcIcons.Globe, stringResource(R.string.drive_open_portal)) { openPortal(context, url) }) } + // Drive 1.20 (feature `offline`): kept ready in the home network's cache + if (one != null) offlineItem(me, one, one.id in offlineIds, viewModel::setOffline)?.let(::add) } val last = if (me?.has(DMe.FEATURE_ALBUMS) == true) { listOf(MoreItem(GcIcons.Photo, stringResource(R.string.drive_more_album), can = can(DriveBulk.Action.ALBUM)) { bulk.openAlbums(ns) }) 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 2b697cf9..49aff77c 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 @@ -117,6 +117,8 @@ data class DriveNav( val shown: (Long?) -> Unit = {}, /** The photo backup (the overview's "Fotos gesichert", Drive 1.18). */ val backup: () -> Unit = {}, + /** "Offline verfügbar" (Drive 1.20). */ + val offline: () -> Unit = {}, /** Mail › "Zum Ablegen" (the overview's attachments to file); null: no mail here. */ val mailFiling: (() -> Unit)? = null, ) @@ -249,6 +251,8 @@ fun DriveHomeScreen(nav: DriveNav, viewModel: DriveHomeViewModel = hiltViewModel onVault = { e -> close(); nav.vault(e.vault.id, e.vault.nodeId) }, icons = buildList { add(DrawerIcon(GcIcons.Trash, R.string.drive_trash) { close(); nav.trash() }) + // Drive 1.20 (`offline`): what is kept ready in the home network + if (me?.has(DMe.FEATURE_OFFLINE) == true) add(DrawerIcon(GcIcons.House, R.string.drive_offline_title) { close(); nav.offline() }) 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() }) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt index a7960a6e..e247d494 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveMoreScreens.kt @@ -154,6 +154,7 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D val busy by viewModel.bulk.busy.collectAsStateWithLifecycle() val areas by viewModel.areas.collectAsStateWithLifecycle() val me by viewModel.live.me.collectAsStateWithLifecycle() + val offlineIds by viewModel.offline.ids.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -293,6 +294,7 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D selecting = selecting, selected = n.id in selected, onMore = { menuFor = n }, + offline = n.id in offlineIds, ) } } @@ -355,6 +357,8 @@ fun DriveSearchScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: D }) } one?.let { n -> DriveLogic.portalNodeUrl(me, n.id) }?.let { url -> add(MoreItem(GcIcons.Globe, stringResource(R.string.drive_open_portal)) { openPortal(context, url) }) } + // Drive 1.20 (feature `offline`): kept ready in the home network's cache + if (one != null) offlineItem(me, one, one.id in offlineIds, viewModel::setOffline)?.let(::add) } val last = if (me?.has(DMe.FEATURE_ALBUMS) == true) { listOf(MoreItem(GcIcons.Photo, stringResource(R.string.drive_more_album), can = can(DriveBulk.Action.ALBUM)) { bulk.openAlbums(ns) }) diff --git a/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOfflineUi.kt b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOfflineUi.kt new file mode 100644 index 00000000..a677ea63 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/drive/DriveOfflineUi.kt @@ -0,0 +1,127 @@ +package com.gatecontrol.android.ui.drive + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DMe +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcRowDivider +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.scan.rememberScanAsk +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.launch + +/** + * The menu entry "Offline verfügbar" / "Nicht mehr offline verfügbar" of a file or folder (Drive 1.20, feature + * `offline`); null while Drive does not have it. The app keeps no copies of its own: the mark means "kept ready in the + * home network's cache", which the description says (and, without GateControl's cache yet, "vorgemerkt"). + */ +@Composable +internal fun offlineItem(me: DMe?, node: DNode, marked: Boolean, onSet: (DNode, Boolean) -> Unit): MoreItem? { + if (me?.has(DMe.FEATURE_OFFLINE) != true) return null + return if (marked) { + MoreItem(GcIcons.House, stringResource(R.string.drive_offline_unmark), stringResource(R.string.drive_offline_unmark_desc)) { onSet(node, false) } + } else { + val desc = if (me.has(DMe.FEATURE_HOME_CACHE)) R.string.drive_offline_mark_desc else R.string.drive_offline_mark_desc_later + MoreItem(GcIcons.House, stringResource(R.string.drive_offline_mark), stringResource(desc)) { onSet(node, true) } + } +} + +/** + * "Offline verfügbar" (Drive 1.20): the files and folders the person marked, newest first. A folder opens, a file + * opens in an app (at home from the gateway's cache), the cross takes the mark off. + */ +@Composable +fun DriveOfflineScreen(onBack: () -> Unit, onFolder: (Long) -> Unit, viewModel: DriveOfflineViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val me by viewModel.live.me.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val x = GateControlTheme.extraColors + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, driveText(context, it), Toast.LENGTH_SHORT).show() + viewModel.clearMessage() + } + } + val ask = rememberScanAsk() + val open: (DNode) -> Unit = { n -> + ask(n.scan) { + scope.launch { + fetchThen(context, n, viewModel::fetch, then = { f -> openFile(context, n, f) }) { code -> + Toast.makeText(context, driveText(context, code), Toast.LENGTH_LONG).show() + } + } + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(stringResource(R.string.drive_offline_title), onBack = onBack, backDescription = stringResource(R.string.drive_back)) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + GcBanner(tone = GcTone.Info, icon = GcIcons.House) { + val intro = if (me?.has(DMe.FEATURE_HOME_CACHE) == false) R.string.drive_offline_intro_later else R.string.drive_offline_intro + Text(stringResource(intro), style = MaterialTheme.typography.bodyMedium) + } + when (val s = state) { + DriveLoad.Loading -> DriveLoading() + is DriveLoad.Error -> DriveErrorBox(s.code, onRetry = viewModel::refresh) + is DriveLoad.Ready -> if (s.value.isEmpty()) { + Text(stringResource(R.string.drive_offline_empty), color = x.muted, modifier = Modifier.padding(4.dp)) + } else { + GcCard(contentPadding = PaddingValues(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(0.dp)) { + s.value.forEachIndexed { i, n -> + if (i > 0) GcRowDivider() + NodeRow( + node = n, + onClick = { + when { + // a vault's content opens on the vault's own pages only + n.encrypted -> Unit + n.isDir -> onFolder(n.id) + else -> open(n) + } + }, + description = nodeLine(n, n.path?.substringBeforeLast('/', "")?.takeIf { it.isNotEmpty() }), + trailing = { + GcIconButton( + GcIcons.Close, + contentDescription = stringResource(R.string.drive_offline_unmark) + ": " + nodeTitle(n), + onClick = { viewModel.unmark(n) }, + size = 44.dp, + iconSize = 18.dp, + ) + }, + ) + } + } + } + } + } + } +} 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 96b18456..46c98e0d 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 @@ -298,6 +298,8 @@ class DriveFolderViewModel @Inject constructor( val ops: com.gatecontrol.android.drive.DriveOps, private val workPhotos: com.gatecontrol.android.drive.work.DriveWorkPhotos, private val filing: com.gatecontrol.android.drive.DriveFiling, + /** "Offline verfügbar" (Drive 1.20): the marks of the rows and the menu's entry. */ + val offline: com.gatecontrol.android.drive.DriveOffline, ) : ViewModel() { /** The route's `id` (a string argument, see Screen.DriveFolder); on the Drive tab the chosen area's top ([open]). */ @@ -357,6 +359,12 @@ class DriveFolderViewModel @Inject constructor( if (pref != _sort.value) _sort.value = pref if (shown) refresh(quiet = true) shown = true + viewModelScope.launch { offline.refresh(live.me.value ?: live.current()) } + } + + /** "Offline verfügbar" on or off for [node] (Drive 1.20). */ + fun setOffline(node: DNode, on: Boolean) { + viewModelScope.launch { _message.value = offline.set(node, on) ?: if (on) "done_offline_on" else "done_offline_off" } } fun onPause() { @@ -1001,6 +1009,40 @@ class DriveTrashViewModel @Inject constructor(private val api: DriveApi) : ViewM } } +/** "Offline verfügbar" (Drive 1.20): what the person marked, to open or to take off the list. */ +@HiltViewModel +class DriveOfflineViewModel @Inject constructor( + private val offline: com.gatecontrol.android.drive.DriveOffline, + private val transfers: DriveTransfers, + val live: DriveLive, +) : ViewModel() { + private val _state = MutableStateFlow>>(DriveLoad.Loading) + val state: StateFlow>> = _state.asStateFlow() + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + init { + refresh() + if (live.me.value == null) viewModelScope.launch { live.current() } + } + + fun refresh() { + viewModelScope.launch { _state.value = offline.list().toLoad { it } } + } + + fun clearMessage() { _message.value = null } + + fun unmark(node: DNode) { + viewModelScope.launch { + val code = offline.set(node, false) + _message.value = code ?: "done_offline_off" + if (code == null) _state.update { s -> if (s is DriveLoad.Ready) DriveLoad.Ready(s.value.filterNot { it.id == node.id }) else s } + } + } + + suspend fun fetch(node: DNode): DResult = transfers.fetch(node) +} + /** Search by name across the areas; the hits can be ticked and handled together (Drive 1.12). */ @HiltViewModel class DriveSearchViewModel @Inject constructor( @@ -1009,6 +1051,8 @@ class DriveSearchViewModel @Inject constructor( private val transfers: DriveTransfers, val live: DriveLive, val ops: com.gatecontrol.android.drive.DriveOps, + /** "Offline verfügbar" (Drive 1.20). */ + val offline: com.gatecontrol.android.drive.DriveOffline, ) : ViewModel() { private val _state = MutableStateFlow>?>(null) val state: StateFlow>?> = _state.asStateFlow() @@ -1074,6 +1118,11 @@ class DriveSearchViewModel @Inject constructor( } } + /** "Offline verfügbar" on or off for [node] (Drive 1.20). */ + fun setOffline(node: DNode, on: Boolean) { + viewModelScope.launch { _message.value = offline.set(node, on) ?: if (on) "done_offline_on" else "done_offline_off" } + } + suspend fun office(node: DNode): DResult = api.office(node.id) suspend fun fetch(node: DNode): DResult = transfers.fetch(node) diff --git a/app/src/main/res/values-de/strings_drive13.xml b/app/src/main/res/values-de/strings_drive13.xml new file mode 100644 index 00000000..f38af844 --- /dev/null +++ b/app/src/main/res/values-de/strings_drive13.xml @@ -0,0 +1,17 @@ + + + + Offline verfügbar + Offline verfügbar + Nicht mehr offline verfügbar + Zu Hause im Zwischenspeicher bereithalten, öffnet im WLAN zu Hause schneller + Für den Zwischenspeicher im Heimnetz vormerken + Zu Hause nicht mehr bevorzugt bereithalten + Offline verfügbar + Wird im Heimnetz bereitgehalten + Nicht mehr offline verfügbar + Höchstens 500 Dateien und Ordner können offline verfügbar sein. + GateControl hält diese Dateien und Ordner im Zwischenspeicher deines Heim-Gateways bereit. Im WLAN zu Hause öffnen sie schneller. Auf diesem Handy wird nichts dauerhaft gespeichert. + Drive merkt sie vor. Sobald GateControl einen Zwischenspeicher im Heimnetz hat, werden sie dort bereitgehalten. Auf diesem Handy wird nichts dauerhaft gespeichert. + Noch nichts offline verfügbar. Im Menü ⋮ einer Datei oder eines Ordners: „Offline verfügbar“. + diff --git a/app/src/main/res/values/strings_drive13.xml b/app/src/main/res/values/strings_drive13.xml new file mode 100644 index 00000000..3204a78f --- /dev/null +++ b/app/src/main/res/values/strings_drive13.xml @@ -0,0 +1,17 @@ + + + + Available offline + Available offline + No longer available offline + Kept ready in the cache at home, opens faster on your home Wi-Fi + Marked for the cache in the home network + No longer kept ready at home first + Available offline + Kept ready in the home network + No longer available offline + At most 500 files and folders can be available offline. + GateControl keeps these files and folders ready in the cache of your home gateway. On your home Wi-Fi they open faster. Nothing is stored on this phone for good. + Drive keeps these marks. As soon as GateControl has a cache in your home network, the files are kept ready there. Nothing is stored on this phone for good. + Nothing is available offline yet. In the ⋮ menu of a file or folder: “Available offline”. + diff --git a/app/src/test/java/com/gatecontrol/android/drive/DriveHomeCacheTest.kt b/app/src/test/java/com/gatecontrol/android/drive/DriveHomeCacheTest.kt new file mode 100644 index 00000000..f4a5bc83 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/drive/DriveHomeCacheTest.kt @@ -0,0 +1,57 @@ +package com.gatecontrol.android.drive + +import com.google.gson.JsonObject +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Drive 1.20: the `lan` hint of `GET nodes//content` and "Offline verfügbar". */ +class DriveHomeCacheTest { + + private fun json(s: String): JsonObject = JsonParser.parseString(s).asJsonObject + + private val id = "0123456789abcdef0123456789abcdef" + private val sha = "a".repeat(64) + private val pin = "b".repeat(64) + + @Test + fun `content with a lan hint`() { + val c = DriveJson.content(json("""{"url":"/plugin-media/x","mediaId":"$id","size":12,"lan":{"url":"https://192.168.1.23:8443/blob/$sha?exp=1700000600&sig=${"c".repeat(64)}","sha256":"$sha","tlsSha256":"$pin","exp":1700000600}}""")) + assertNotNull(c) + assertEquals(id, c!!.mediaId) + assertEquals(12L, c.size) + assertEquals("192.168.1.23", c.lan?.host) + assertEquals(sha, c.lan?.sha256) + } + + @Test + fun `content without a hint, or with one that is not right`() { + assertNull(DriveJson.content(json("""{"mediaId":"$id","size":12}"""))!!.lan) + // a public address, plain http: the normal download + assertNull(DriveJson.content(json("""{"mediaId":"$id","size":12,"lan":{"url":"https://203.0.113.5:8443/blob/$sha","sha256":"$sha","tlsSha256":"$pin","exp":1700000600}}"""))!!.lan) + assertNull(DriveJson.content(json("""{"mediaId":"$id","size":12,"lan":{"url":"http://192.168.1.23:8443/blob/$sha","sha256":"$sha","tlsSha256":"$pin","exp":1700000600}}"""))!!.lan) + assertNull(DriveJson.content(json("""{"mediaId":"$id","size":12,"lan":null}"""))!!.lan) + // an empty file has nothing to load + val empty = DriveJson.content(json("""{"size":0,"mediaId":null,"empty":true,"lan":{"url":"https://192.168.1.23:8443/blob/$sha","sha256":"$sha","tlsSha256":"$pin","exp":1700000600}}""")) + assertEquals(DriveJson.EMPTY_CONTENT, empty!!.mediaId) + assertNull(empty.lan) + assertNull(DriveJson.content(json("""{"size":12,"mediaId":null}"""))) + } + + @Test + fun `the limit of 500 marks is recognised`() { + assertTrue(DriveOfflineLogic.atLimit(DResult.Failed("invalid", 400, json("""{"code":"invalid","field":"offline","max":500}""")))) + assertFalse(DriveOfflineLogic.atLimit(DResult.Failed("invalid", 400, json("""{"code":"invalid","field":"name"}""")))) + assertFalse(DriveOfflineLogic.atLimit(DResult.Failed("not_found", 404, null))) + } + + @Test + fun `offline list is read as nodes`() { + val items = DriveJson.items(json("""{"items":[{"id":7,"areaId":"u1","name":"Steuer","isDir":true,"offline":true,"offlineAt":"2026-11-05T10:00:00Z"},{"id":9,"areaId":"u1","name":"a.pdf","size":3,"offline":true}]}""")) + assertEquals(listOf(7L, 9L), items?.map { it.id }) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/homecache/HomeSiteMemoryTest.kt b/app/src/test/java/com/gatecontrol/android/homecache/HomeSiteMemoryTest.kt new file mode 100644 index 00000000..3fad637f --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/homecache/HomeSiteMemoryTest.kt @@ -0,0 +1,41 @@ +package com.gatecontrol.android.homecache + +import com.gatecontrol.android.network.HomeNetworkStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class HomeSiteMemoryTest { + private var now = 1_000_000L + private val memory = HomeSiteMemory { now } + + @Test + fun `home is kept for a while`() { + memory.put("192.168.1.5", HomeNetworkStatus.Home(viaTunnel = false)) + assertEquals(true, memory.get("192.168.1.5")) + now += HomeSiteMemory.HOME_MS - 1 + assertEquals(true, memory.get("192.168.1.5")) + now += 1 + assertNull(memory.get("192.168.1.5")) + } + + @Test + fun `other addresses are another network`() { + memory.put("192.168.1.5", HomeNetworkStatus.Home(viaTunnel = false)) + assertNull(memory.get("10.0.0.7")) + } + + @Test + fun `not home, an older server and no answer mean no header`() { + memory.put("a", HomeNetworkStatus.NotHome(viaTunnel = false, siteAware = true)) + memory.put("b", HomeNetworkStatus.Unsupported) + memory.put("c", HomeNetworkStatus.Unknown) + assertEquals(false, memory.get("a")) + assertEquals(false, memory.get("b")) + assertEquals(false, memory.get("c")) + // no answer is asked again soon + now += HomeSiteMemory.UNKNOWN_MS + assertNull(memory.get("c")) + assertEquals(false, memory.get("a")) + } +} diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt index d5800c3b..e6c8ca11 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClient.kt @@ -141,6 +141,18 @@ interface ApiClient { @QueryMap query: Map = emptyMap(), ): Response + /** + * [pluginGet] with this device's LAN addresses in [HomeCache.HEADER_LOCAL] (null: no header) — the home cache's + * site check for a `lan` hint (e.g. Drive's `nodes//content`). Only when the device is in its home network. + */ + @GET("api/v1/client/plugins/{id}/api/{path}") + suspend fun pluginGetWithLocal( + @Path("id") plugin: String, + @Path(value = "path", encoded = true) path: String, + @QueryMap query: Map, + @Header(HomeCache.HEADER_LOCAL) local: String?, + ): Response + @POST("api/v1/client/plugins/{id}/api/{path}") suspend fun pluginPost( @Path("id") plugin: String, diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt index 52a7e9ae..5ecdd318 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt @@ -135,6 +135,8 @@ class ApiClientProvider @Inject constructor( "Cookie", "Set-Cookie", "X-Machine-Fingerprint", + // the device's LAN addresses for the home cache's site check + HomeCache.HEADER_LOCAL, ) } diff --git a/core/network/src/main/java/com/gatecontrol/android/network/HomeCache.kt b/core/network/src/main/java/com/gatecontrol/android/network/HomeCache.kt new file mode 100644 index 00000000..1a6e9f91 --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/HomeCache.kt @@ -0,0 +1,341 @@ +package com.gatecontrol.android.network + +import android.annotation.SuppressLint +import com.google.gson.JsonElement +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext +import okhttp3.ConnectionSpec +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.Proxy +import java.net.SocketTimeoutException +import java.security.MessageDigest +import java.security.cert.Certificate +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.SocketFactory +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSession +import javax.net.ssl.SSLSocket +import javax.net.ssl.X509TrustManager +import kotlin.coroutines.coroutineContext + +/** + * The "Zwischenspeicher im Heimnetz" (home cache, GateControl ≥ 1.231.0): a home gateway keeps files and app + * updates and hands them out on its LAN address. The server (or a plugin such as Drive) adds a `lan` hint to a + * download when this device is at the gateway's site: `{ url, sha256, tlsSha256, exp }`. The app loads `url` + * directly in the LAN, pins the gateway's self-signed certificate to `tlsSha256`, checks the bytes against + * `sha256` and, on any error, quietly takes the normal download. + * + * [url] carries a signature in its query (`exp`, `sig`): it is never logged — [logLabel] is the loggable form. + */ +data class LanHint( + val url: String, + /** The gateway's LAN address in [url] (a private IPv4 literal when it came through [HomeCache.parse]). */ + val host: String, + val port: Int, + /** SHA-256 of the content, 64 hex, lower case. */ + val sha256: String, + /** SHA-256 of the gateway certificate's DER, 64 hex, lower case. */ + val tlsSha256: String, + /** Unix seconds after which the gateway refuses [url]. */ + val exp: Long, +) { + /** For logs: where and what, never the signature. */ + val logLabel: String get() = "$host:$port/blob/${sha256.take(12)}" + + override fun toString(): String = "LanHint($logLabel, exp=$exp)" +} + +object HomeCache { + /** Header with this device's LAN IPv4 addresses (`a.b.c.d[,…]`): the server's site check for the `lan` hint. */ + const val HEADER_LOCAL = "X-GC-Local" + + /** A hint is used only while at least this much of its time is left (the request must reach the gateway in time). */ + const val EXP_MARGIN_SECONDS = 5L + + private val HEX64 = Regex("^[0-9a-f]{64}$") + + /** A SHA-256 in hex (colons and upper case allowed, as in certificate fingerprints) → 64 hex lower case; else null. */ + fun normalizeSha256(value: String?): String? = + value?.trim()?.replace(":", "")?.lowercase()?.takeIf { HEX64.matches(it) } + + /** + * The `lan` object of an answer → [LanHint], or null when it is missing or anything about it is off: [LanHint.url] + * must be `https://[:port]/blob/` (no user info, no fragment), `sha256` and `tlsSha256` + * SHA-256 hex, `exp` positive Unix seconds. A hint that is not right is simply not used. + */ + fun parse(element: JsonElement?): LanHint? { + val o = element?.takeIf { it.isJsonObject }?.asJsonObject ?: return null + fun text(key: String): String? = o.get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() } + val sha256 = normalizeSha256(text("sha256")) ?: return null + val pin = normalizeSha256(text("tlsSha256")) ?: return null + val exp = o.get("exp")?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber } + ?.let { runCatching { it.asLong }.getOrNull() }?.takeIf { it > 0 } ?: return null + val raw = text("url")?.trim()?.takeIf { it.length <= 2048 } ?: return null + val url = raw.toHttpUrlOrNull() ?: return null + if (url.scheme != "https" || url.username.isNotEmpty() || url.password.isNotEmpty() || url.fragment != null) return null + if (!isPrivateIpv4(url.host)) return null + if (url.encodedPath != "/blob/$sha256") return null + return LanHint(url = raw, host = url.host, port = url.port, sha256 = sha256, tlsSha256 = pin, exp = exp) + } + + /** [hint] still has time ([EXP_MARGIN_SECONDS] at least). */ + fun usable(hint: LanHint, nowSeconds: Long): Boolean = hint.exp > nowSeconds + EXP_MARGIN_SECONDS + + /** + * The [HEADER_LOCAL] value for [addresses] (the device's own addresses on its Wi-Fi / Ethernet networks): private + * IPv4 (RFC 1918) only, without the ones [excluded] names (the VPN's own subnet), at most + * [HOME_NETWORK_MAX_LOCAL]; null when nothing is left. + */ + fun localHeader(addresses: List, excluded: (String) -> Boolean = { false }): String? = + homeNetworkLocalQuery(addresses.map { it.trim() }.filterNot(excluded)) + + /** [host] (dotted IPv4) lies in the subnet of [address]/[prefix] (IPv4 only). */ + fun inSubnet(host: String, address: String, prefix: Int): Boolean { + val h = ipv4(host) ?: return false + val a = ipv4(address) ?: return false + if (prefix !in 0..32) return false + val mask = if (prefix == 0) 0L else (0xFFFFFFFFL shl (32 - prefix)) and 0xFFFFFFFFL + return (h and mask) == (a and mask) + } + + private fun ipv4(s: String): Long? { + val parts = s.split('.') + if (parts.size != 4) return null + var v = 0L + for (p in parts) { + if (p.isEmpty() || p.length > 3 || !p.all { it in '0'..'9' }) return null + val n = p.toInt() + if (n > 255) return null + v = (v shl 8) or n.toLong() + } + return v + } + + internal fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + + internal fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i -> hex.substring(2 * i, 2 * i + 2).toInt(16).toByte() } +} + +/** + * Trusts exactly one certificate: the one whose DER has the SHA-256 [pinHex]. Only for the connection to a home + * gateway's cache ([HomeCacheFetcher]), whose certificate is self-signed and carries no name — the pin from the + * server's signed hint is the only trust there is; no CA, no other certificate, no client certificates. + */ +@SuppressLint("CustomX509TrustManager") // deliberate: pinning to the one certificate the server named, nothing else +class PinnedCertTrustManager(pinHex: String) : X509TrustManager { + private val pin: ByteArray = HomeCache.hexToBytes( + requireNotNull(HomeCache.normalizeSha256(pinHex)) { "pin must be a SHA-256 in hex" }, + ) + + /** [cert] is the pinned one. */ + fun matches(cert: Certificate): Boolean = try { + MessageDigest.isEqual(MessageDigest.getInstance("SHA-256").digest(cert.encoded), pin) + } catch (_: Exception) { + false + } + + override fun checkClientTrusted(chain: Array?, authType: String?) { + throw CertificateException("client certificates are not accepted") + } + + override fun checkServerTrusted(chain: Array?, authType: String?) { + val leaf = chain?.firstOrNull() ?: throw CertificateException("no server certificate") + if (!matches(leaf)) throw CertificateException("server certificate does not match the pin") + } + + override fun getAcceptedIssuers(): Array = emptyArray() +} + +/** + * The host name check of a pinned connection: the address asked for is [host] and the session's certificate is the + * pinned one. The gateway's certificate has no name to compare, so the pin takes the place of the name. + */ +class PinnedHostnameVerifier(private val host: String, private val trust: PinnedCertTrustManager) : HostnameVerifier { + override fun verify(hostname: String?, session: SSLSession?): Boolean { + if (hostname == null || session == null || !hostname.equals(host, ignoreCase = true)) return false + val leaf = try { + session.peerCertificates.firstOrNull() + } catch (_: SSLPeerUnverifiedException) { + null + } ?: return false + return trust.matches(leaf) + } +} + +/** + * Loads a [LanHint] from the home gateway into a file: pinned TLS ([PinnedCertTrustManager], [PinnedHostnameVerifier] + * — for this one connection only), no proxy, no redirects, no credentials of the app. The bytes are checked against + * [LanHint.sha256] (and the expected size) before the file is there; anything wrong gives [Result.Failed] and no file, + * so the caller takes the normal download. [socketFactoryFor] binds the socket to the Wi-Fi / Ethernet network that + * holds the gateway's address (not the VPN); null: the system routes it. + */ +class HomeCacheFetcher( + private val socketFactoryFor: (String) -> SocketFactory? = { null }, + private val nowSeconds: () -> Long = { System.currentTimeMillis() / 1000 }, + private val base: OkHttpClient = DEFAULT_BASE, + private val connectTimeoutMs: Long = CONNECT_TIMEOUT_MS, + private val readTimeoutMs: Long = READ_TIMEOUT_MS, +) { + sealed interface Result { + data class Ok(val file: File, val bytes: Long) : Result + + /** [reason]: `expired`, `sha256_differs`, `tls`, `timeout`, `unreachable`, `http_`, `size`, `sha256`, `io`. */ + data class Failed(val reason: String) : Result + } + + /** + * [hint] into [target] (replaced only on success). [expectedSha256] / [expectedSize]: what the caller knows of the + * content (e.g. the node's) — a hint for other content is not used. [onProgress] gets bytes done and the total (0 + * when unknown). + */ + suspend fun fetch( + hint: LanHint, + target: File, + expectedSha256: String? = null, + expectedSize: Long? = null, + onProgress: (done: Long, total: Long) -> Unit = { _, _ -> }, + ): Result { + val expected = HomeCache.normalizeSha256(expectedSha256) + if (expected != null && expected != hint.sha256) return failed(hint, "sha256_differs") + if (!HomeCache.usable(hint, nowSeconds())) return failed(hint, "expired") + val job = coroutineContext[Job] + return withContext(Dispatchers.IO) { + val part = File(target.parentFile, target.name + ".part") + val r = try { + load(hint, part, expectedSize, onProgress, job) + } catch (e: CancellationException) { + part.delete() + throw e + } + if (r !is Result.Ok) { + part.delete() + return@withContext r + } + if (target.exists()) target.delete() + if (!part.renameTo(target)) { + part.delete() + return@withContext failed(hint, "io") + } + Timber.i("Home cache: loaded %s from the gateway (%d bytes)", hint.logLabel, r.bytes) + Result.Ok(target, r.bytes) + } + } + + private fun load(hint: LanHint, part: File, expectedSize: Long?, onProgress: (Long, Long) -> Unit, job: Job?): Result { + val trust = PinnedCertTrustManager(hint.tlsSha256) + val tls = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trust), null) } + val client = base.newBuilder() + .sslSocketFactory(tls.socketFactory, trust) + .hostnameVerifier(PinnedHostnameVerifier(hint.host, trust)) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS)) + .socketFactory(socketFactoryFor(hint.host) ?: SocketFactory.getDefault()) + .proxy(Proxy.NO_PROXY) + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .cache(null) + .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + // checked in the handshake already (trust manager, host name check); once more on the socket actually used, + // before a byte of the request leaves + .addNetworkInterceptor { chain -> + val leaf = (chain.connection()?.socket() as? SSLSocket)?.session + ?.let { s -> runCatching { s.peerCertificates.firstOrNull() }.getOrNull() } + if (leaf == null || !trust.matches(leaf)) throw SSLPeerUnverifiedException("not the pinned certificate") + chain.proceed(chain.request()) + } + .build() + // the bytes as they are on the gateway (no transparent gzip): what is hashed is what is stored + val request = Request.Builder().url(hint.url).header("Accept-Encoding", "identity").get().build() + val call = client.newCall(request) + val cancel = job?.invokeOnCompletion { call.cancel() } + try { + call.execute().use { response -> + if (response.code != 200) return failed(hint, "http_${response.code}") + val body = response.body + val length = body.contentLength() + if (expectedSize != null && expectedSize >= 0 && length >= 0 && length != expectedSize) return failed(hint, "size") + val total = if (length >= 0) length else expectedSize ?: 0L + val digest = MessageDigest.getInstance("SHA-256") + var done = 0L + body.byteStream().use { input -> + part.outputStream().use { out -> + val buf = ByteArray(256 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + done += n + if (expectedSize != null && expectedSize >= 0 && done > expectedSize) return failed(hint, "size") + digest.update(buf, 0, n) + out.write(buf, 0, n) + onProgress(done, total) + } + } + } + if (expectedSize != null && expectedSize >= 0 && done != expectedSize) return failed(hint, "size") + val got = digest.digest().joinToString("") { "%02x".format(it) } + if (got != hint.sha256) return failed(hint, "sha256") + return Result.Ok(part, done) + } + } catch (e: SocketTimeoutException) { + return failed(hint, "timeout") + } catch (e: SSLException) { + // a certificate other than the pinned one ends the handshake here + return failed(hint, "tls") + } catch (e: ConnectException) { + return failed(hint, "unreachable") + } catch (e: NoRouteToHostException) { + return failed(hint, "unreachable") + } catch (e: IOException) { + if (job?.isCancelled == true) throw CancellationException("cancelled") + return failed(hint, if (tlsFailure(e)) "tls" else "io") + } catch (e: IllegalArgumentException) { + return failed(hint, "io") + } finally { + cancel?.dispose() + } + } + + /** A TLS or certificate error somewhere in [e]'s causes (how it is wrapped differs between platforms). */ + private fun tlsFailure(e: Throwable): Boolean { + val seen = HashSet() + val queue = ArrayDeque().apply { add(e) } + while (queue.isNotEmpty()) { + val t = queue.removeFirst() + if (!seen.add(t) || seen.size > 16) continue + if (t is SSLException || t is CertificateException) return true + t.cause?.let(queue::add) + t.suppressed.forEach(queue::add) + } + return false + } + + private fun failed(hint: LanHint, reason: String): Result.Failed { + // the label only: the URL's query is a signature + Timber.i("Home cache: %s not loaded from the gateway (%s), normal download", hint.logLabel, reason) + return Result.Failed(reason) + } + + companion object { + const val CONNECT_TIMEOUT_MS = 3_000L + const val READ_TIMEOUT_MS = 15_000L + + /** A plain client: none of the app's interceptors, tokens or cookies ever go to a LAN address. */ + private val DEFAULT_BASE: OkHttpClient by lazy { OkHttpClient.Builder().build() } + } +} diff --git a/core/network/src/test/java/com/gatecontrol/android/network/HomeCacheTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/HomeCacheTest.kt new file mode 100644 index 00000000..ea5e781d --- /dev/null +++ b/core/network/src/test/java/com/gatecontrol/android/network/HomeCacheTest.kt @@ -0,0 +1,322 @@ +package com.gatecontrol.android.network + +import com.google.gson.JsonParser +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.jupiter.api.AfterEach +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.security.KeyStore +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory + +// runBlocking, not runTest: the TLS connection runs on real time. +class HomeCacheTest { + + companion object { + private const val PASS = "home-cache-test" + private lateinit var serverTls: SSLSocketFactory + private lateinit var cert: X509Certificate + private lateinit var otherCert: X509Certificate + + /** A self-signed certificate without a name, as the gateway has (made with the JDK's keytool, nothing kept). */ + private fun keyStore(dir: File, alias: String): KeyStore { + val file = File(dir, "$alias.p12") + val keytool = File(System.getProperty("java.home"), "bin/keytool").path + val p = ProcessBuilder( + keytool, "-genkeypair", "-alias", alias, "-keyalg", "EC", "-groupname", "secp256r1", + "-sigalg", "SHA256withECDSA", "-dname", "CN=gatecontrol-home-cache", "-validity", "2", + "-storetype", "PKCS12", "-keystore", file.path, "-storepass", PASS, "-keypass", PASS, + ).redirectErrorStream(true).start() + p.inputStream.readBytes() + check(p.waitFor(60, TimeUnit.SECONDS) && p.exitValue() == 0) { "keytool failed" } + return KeyStore.getInstance("PKCS12").apply { file.inputStream().use { load(it, PASS.toCharArray()) } } + } + + @JvmStatic + @BeforeAll + fun certificates(@TempDir dir: File) { + val ks = keyStore(dir, "gw") + cert = ks.getCertificate("gw") as X509Certificate + otherCert = keyStore(dir, "other").getCertificate("other") as X509Certificate + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(ks, PASS.toCharArray()) } + serverTls = SSLContext.getInstance("TLS").apply { init(kmf.keyManagers, null, null) }.socketFactory + } + + private fun pinOf(c: X509Certificate) = HomeCache.sha256Hex(c.encoded) + } + + private lateinit var server: MockWebServer + private var now = 1_000_000L + + @TempDir + lateinit var tmp: File + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.useHttps(serverTls, false) + server.start() + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + private val content = "Urlaubsfotos 2026".toByteArray() + private val contentSha = HomeCache.sha256Hex(content) + + private fun hint(sha: String = contentSha, pin: String = pinOf(cert), exp: Long = now + 600) = LanHint( + url = server.url("/blob/$sha?exp=$exp&sig=00ff").toString(), + host = server.hostName, + port = server.port, + sha256 = sha, + tlsSha256 = pin, + exp = exp, + ) + + private fun fetcher() = HomeCacheFetcher(nowSeconds = { now }, connectTimeoutMs = 2_000, readTimeoutMs = 5_000) + + private fun ok(body: ByteArray = content) = MockResponse().setResponseCode(200).setBody(Buffer().write(body)) + + // ─── loading ──────────────────────────────────────────────────── + + @Test + fun `pin matches and the bytes are right - the file is there`() = runBlocking { + server.enqueue(ok()) + val target = File(tmp, "out.bin") + val r = fetcher().fetch(hint(), target, expectedSha256 = contentSha, expectedSize = content.size.toLong()) + assertTrue(r is HomeCacheFetcher.Result.Ok, "$r") + assertArrayEquals(content, target.readBytes()) + assertFalse(File(tmp, "out.bin.part").exists()) + val req = server.takeRequest() + assertEquals("/blob/$contentSha?exp=${now + 600}&sig=00ff", req.path) + // none of the app's credentials ever go to the LAN address + assertNull(req.getHeader("Authorization")) + assertNull(req.getHeader("X-API-Token")) + assertNull(req.getHeader(HomeCache.HEADER_LOCAL)) + } + + @Test + fun `a pin given with colons and in upper case works too`() = runBlocking { + server.enqueue(ok()) + val pin = pinOf(cert).uppercase().chunked(2).joinToString(":") + val r = fetcher().fetch(hint(pin = HomeCache.normalizeSha256(pin)!!), File(tmp, "a")) + assertTrue(r is HomeCacheFetcher.Result.Ok, "$r") + } + + @Test + fun `wrong pin - nothing loaded, fall back`() = runBlocking { + server.enqueue(ok()) + val target = File(tmp, "out.bin") + val r = fetcher().fetch(hint(pin = pinOf(otherCert)), target) + // the handshake fails (how the platform reports it varies): the request with its signature never leaves + assertTrue(r is HomeCacheFetcher.Result.Failed, "$r") + assertEquals(0, server.requestCount) + assertFalse(target.exists()) + assertFalse(File(tmp, "out.bin.part").exists()) + } + + @Test + fun `other bytes than the hash says - no file, fall back`() = runBlocking { + server.enqueue(ok("manipuliert!!!!!!".toByteArray())) + val target = File(tmp, "out.bin") + val r = fetcher().fetch(hint(), target) + assertEquals(HomeCacheFetcher.Result.Failed("sha256"), r) + assertFalse(target.exists()) + assertFalse(File(tmp, "out.bin.part").exists()) + } + + @Test + fun `a file that was there stays when the new load fails`() = runBlocking { + val target = File(tmp, "out.bin").apply { writeText("vorher") } + server.enqueue(ok("anders".toByteArray())) + val r = fetcher().fetch(hint(), target) + assertTrue(r is HomeCacheFetcher.Result.Failed) + assertEquals("vorher", target.readText()) + } + + @Test + fun `refused or missing on the gateway - fall back`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(403).setBody("""{"error":"expired"}""")) + assertEquals(HomeCacheFetcher.Result.Failed("http_403"), fetcher().fetch(hint(), File(tmp, "a"))) + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_cached"}""")) + assertEquals(HomeCacheFetcher.Result.Failed("http_404"), fetcher().fetch(hint(), File(tmp, "b"))) + } + + @Test + fun `a redirect is not followed`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", "https://example.com/x")) + assertEquals(HomeCacheFetcher.Result.Failed("http_302"), fetcher().fetch(hint(), File(tmp, "a"))) + assertEquals(1, server.requestCount) + } + + @Test + fun `expired hint - not even asked`() = runBlocking { + val r = fetcher().fetch(hint(exp = now + 3), File(tmp, "a")) + assertEquals(HomeCacheFetcher.Result.Failed("expired"), r) + assertEquals(0, server.requestCount) + } + + @Test + fun `a hint for other content than the file's - not used`() = runBlocking { + val r = fetcher().fetch(hint(), File(tmp, "a"), expectedSha256 = "ab".repeat(32)) + assertEquals(HomeCacheFetcher.Result.Failed("sha256_differs"), r) + assertEquals(0, server.requestCount) + } + + @Test + fun `more bytes than the file has - fall back`() = runBlocking { + server.enqueue(ok()) + val r = fetcher().fetch(hint(), File(tmp, "a"), expectedSize = content.size - 1L) + assertEquals(HomeCacheFetcher.Result.Failed("size"), r) + } + + @Test + fun `nobody listening - fall back`() = runBlocking { + val h = hint() + server.shutdown() + val r = fetcher().fetch(h, File(tmp, "a")) + assertTrue(r is HomeCacheFetcher.Result.Failed, "$r") + } + + @Test + fun `progress is reported`() = runBlocking { + server.enqueue(ok()) + var last = 0L to 0L + fetcher().fetch(hint(), File(tmp, "a")) { done, total -> last = done to total } + assertEquals(content.size.toLong() to content.size.toLong(), last) + } + + // ─── the pin itself ───────────────────────────────────────────── + + @Test + fun `trust manager takes the pinned certificate only`() { + val tm = PinnedCertTrustManager(pinOf(cert)) + tm.checkServerTrusted(arrayOf(cert), "ECDHE_ECDSA") + assertThrowsCert { tm.checkServerTrusted(arrayOf(otherCert), "ECDHE_ECDSA") } + assertThrowsCert { tm.checkServerTrusted(arrayOf(), "ECDHE_ECDSA") } + assertThrowsCert { tm.checkClientTrusted(arrayOf(cert), "ECDHE_ECDSA") } + assertEquals(0, tm.acceptedIssuers.size) + } + + private fun assertThrowsCert(block: () -> Unit) { + try { + block() + } catch (_: CertificateException) { + return + } + throw AssertionError("CertificateException expected") + } + + // ─── the hint ─────────────────────────────────────────────────── + + private val sha = "a".repeat(64) + private val pin = "b".repeat(64) + + private fun parse(json: String) = HomeCache.parse(JsonParser.parseString(json)) + + @Test + fun `hint is read`() { + val h = parse("""{"url":"https://192.168.1.23:8443/blob/$sha?exp=1700000600&sig=${"c".repeat(64)}","sha256":"$sha","tlsSha256":"${pin.uppercase()}","exp":1700000600}""") + assertNotNull(h) + assertEquals("192.168.1.23", h!!.host) + assertEquals(8443, h.port) + assertEquals(pin, h.tlsSha256) + assertEquals(1_700_000_600L, h.exp) + // logs never see the signature + assertFalse(h.toString().contains("sig")) + assertFalse(h.logLabel.contains("?")) + } + + @Test + fun `hint that is not right is not used`() { + val good = mapOf( + "url" to "\"https://10.0.0.5:8443/blob/$sha?exp=1&sig=00\"", + "sha256" to "\"$sha\"", + "tlsSha256" to "\"$pin\"", + "exp" to "1700000600", + ) + fun with(key: String, value: String?) = "{" + good.toMutableMap().apply { if (value == null) remove(key) else put(key, value) } + .entries.joinToString(",") { "\"${it.key}\":${it.value}" } + "}" + assertNotNull(parse(with("exp", "1700000600"))) + // plain http, a public address, a name, IPv6, a path for other content, user info + assertNull(parse(with("url", "\"http://10.0.0.5:8443/blob/$sha?exp=1&sig=00\""))) + assertNull(parse(with("url", "\"https://8.8.8.8:8443/blob/$sha?exp=1&sig=00\""))) + assertNull(parse(with("url", "\"https://gateway.example.com:8443/blob/$sha\""))) + assertNull(parse(with("url", "\"https://[fd00::1]:8443/blob/$sha\""))) + assertNull(parse(with("url", "\"https://10.0.0.5:8443/blob/${"d".repeat(64)}\""))) + assertNull(parse(with("url", "\"https://u:p@10.0.0.5:8443/blob/$sha\""))) + assertNull(parse(with("url", null))) + // hashes that are no SHA-256, no or a bad expiry + assertNull(parse(with("sha256", "\"xyz\""))) + assertNull(parse(with("tlsSha256", "\"${"b".repeat(63)}\""))) + assertNull(parse(with("tlsSha256", null))) + assertNull(parse(with("exp", null))) + assertNull(parse(with("exp", "\"soon\""))) + assertNull(parse(with("exp", "0"))) + assertNull(HomeCache.parse(null)) + assertNull(HomeCache.parse(JsonParser.parseString("null"))) + assertNull(HomeCache.parse(JsonParser.parseString("\"https://10.0.0.5/blob/$sha\""))) + } + + @Test + fun `expiry leaves a margin`() { + val h = LanHint("https://10.0.0.5/blob/$sha", "10.0.0.5", 443, sha, pin, exp = 1_000L) + assertTrue(HomeCache.usable(h, 900)) + assertTrue(HomeCache.usable(h, 1_000L - HomeCache.EXP_MARGIN_SECONDS - 1)) + assertFalse(HomeCache.usable(h, 1_000L - HomeCache.EXP_MARGIN_SECONDS)) + assertFalse(HomeCache.usable(h, 1_001)) + } + + // ─── the X-GC-Local header ────────────────────────────────────── + + @Test + fun `header carries private IPv4 addresses only`() { + assertEquals( + "192.168.1.5,10.0.0.2,172.16.4.1", + HomeCache.localHeader(listOf("192.168.1.5", "8.8.8.8", "10.0.0.2", "fe80::1", "100.64.0.1", "127.0.0.1", "169.254.1.1", "172.16.4.1", "172.32.0.1")), + ) + assertNull(HomeCache.localHeader(listOf("8.8.8.8", "2001:db8::1"))) + assertNull(HomeCache.localHeader(emptyList())) + } + + @Test + fun `header leaves out the VPN's own addresses`() { + val vpn: (String) -> Boolean = { HomeCache.inSubnet(it, "10.8.0.0", 24) } + assertEquals("192.168.1.5", HomeCache.localHeader(listOf("10.8.0.7", "192.168.1.5"), vpn)) + assertNull(HomeCache.localHeader(listOf("10.8.0.7"), vpn)) + } + + @Test + fun `header has at most four addresses`() { + val many = (1..8).map { "192.168.1.$it" } + assertEquals(4, HomeCache.localHeader(many)!!.split(',').size) + } + + @Test + fun `subnet check`() { + assertTrue(HomeCache.inSubnet("192.168.1.23", "192.168.1.5", 24)) + assertFalse(HomeCache.inSubnet("192.168.2.23", "192.168.1.5", 24)) + assertTrue(HomeCache.inSubnet("10.1.2.3", "10.0.0.1", 8)) + assertTrue(HomeCache.inSubnet("1.2.3.4", "9.9.9.9", 0)) + assertFalse(HomeCache.inSubnet("192.168.1.23", "192.168.1.5", 33)) + assertFalse(HomeCache.inSubnet("fd00::1", "192.168.1.5", 24)) + } +}