mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(native): an iOS map tile store, so viewed maps keep working offline
WKWebView runs no service worker for a self-hosted server, so on iOS nothing kept a map tile once it scrolled away. Binaries with the tileCache feature answer a trek-tile:// scheme from a store on disk: a tile viewed once is served from there for up to 30 days (Mapbox's limit for a device cache, applied to every provider), then fetched anew; the store keeps under 250 MB, dropping the least recently used tiles first, sends a unique app User-Agent, and never prefetches. Mapbox's own resources (style, tiles, glyphs, sprites) and Leaflet's raster tiles go through it, telemetry never does. Offline, a Mapbox map in such an app now stays Mapbox where it was viewed, and the pill offers the stored trip region instead. The offline settings show what the store takes against its limit, with a way to empty it.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import CryptoKit
|
||||
import WebKit
|
||||
|
||||
/// Points the WebView at the TREK server the user picked instead of the bundled
|
||||
/// address screen. The UI then comes from that server, in the version it runs,
|
||||
@@ -17,7 +19,18 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
/// The map tile store answers trek-tile:// (TrekTileSchemeHandler below). A
|
||||
/// scheme handler can only be set on the configuration the WebView is built from.
|
||||
private let tileHandler = TrekTileSchemeHandler()
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let configuration = super.webViewConfiguration(for: instanceConfiguration)
|
||||
configuration.setURLSchemeHandler(tileHandler, forURLScheme: TrekTileSchemeHandler.scheme)
|
||||
return configuration
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
TrekTileStore.shared.prune()
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
@@ -44,3 +57,218 @@ enum TrekServerStore {
|
||||
return support.appendingPathComponent("offline-start-page.html")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tile store (trek-tile://)
|
||||
|
||||
/// Map tiles the user looked at, on disk, for the WebView's maps
|
||||
/// (client/src/native/tileCache.ts). WKWebView runs no service worker for a
|
||||
/// self-hosted server, so without this nothing kept a tile once the app closed.
|
||||
///
|
||||
/// Each tile lives as `<sha256>.bin` with its content type in `<sha256>.type`.
|
||||
/// A tile older than 30 days is never served again (Mapbox's limit for a device
|
||||
/// cache, applied to every provider) and is fetched anew. The store keeps under
|
||||
/// 250 MB: past that, the tiles used least recently go first. It sits in Caches,
|
||||
/// which iOS may empty under storage pressure and never backs up.
|
||||
final class TrekTileStore {
|
||||
static let shared = TrekTileStore()
|
||||
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
static let maxBytes: Int64 = 250 * 1024 * 1024
|
||||
/// Pruning brings the store down to this share of the limit, so it does not
|
||||
/// run again on the very next tile.
|
||||
static let pruneTarget = 0.8
|
||||
|
||||
let directory: URL
|
||||
let queue = DispatchQueue(label: "com.liketrek.trek.tiles", qos: .utility)
|
||||
private var writesSincePrune = 0
|
||||
|
||||
private init() {
|
||||
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
directory = caches.appendingPathComponent("trek-tiles", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
/// The tile's identity without what changes per user or session: tokens and
|
||||
/// keys come out, the rest of the query is sorted.
|
||||
static func key(for url: URL) -> String {
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let dropped: Set<String> = ["access_token", "sku", "key", "api_key", "apikey"]
|
||||
let kept = components?.queryItems?
|
||||
.filter { !dropped.contains($0.name.lowercased()) }
|
||||
.sorted { $0.name < $1.name }
|
||||
components?.queryItems = (kept?.isEmpty ?? true) ? nil : kept
|
||||
let identity = components?.string ?? url.absoluteString
|
||||
return SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func dataURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".bin") }
|
||||
private func typeURL(_ key: String) -> URL { directory.appendingPathComponent(key + ".type") }
|
||||
|
||||
/// A stored tile younger than 30 days, marked as just used. Call on `queue`.
|
||||
func read(_ key: String) -> (data: Data, type: String)? {
|
||||
let file = dataURL(key)
|
||||
guard let attributes = try? FileManager.default.attributesOfItem(atPath: file.path),
|
||||
let created = attributes[.creationDate] as? Date else { return nil }
|
||||
if Date().timeIntervalSince(created) > TrekTileStore.maxAge {
|
||||
remove(key)
|
||||
return nil
|
||||
}
|
||||
guard let data = try? Data(contentsOf: file) else { return nil }
|
||||
let type = (try? String(contentsOf: typeURL(key), encoding: .utf8)) ?? "application/octet-stream"
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
|
||||
return (data, type)
|
||||
}
|
||||
|
||||
func write(_ key: String, data: Data, type: String) {
|
||||
queue.async {
|
||||
try? data.write(to: self.dataURL(key), options: .atomic)
|
||||
try? type.write(to: self.typeURL(key), atomically: true, encoding: .utf8)
|
||||
self.writesSincePrune += 1
|
||||
if self.writesSincePrune >= 200 { self.pruneNow() }
|
||||
}
|
||||
}
|
||||
|
||||
private func remove(_ key: String) {
|
||||
try? FileManager.default.removeItem(at: dataURL(key))
|
||||
try? FileManager.default.removeItem(at: typeURL(key))
|
||||
}
|
||||
|
||||
private struct Entry {
|
||||
let key: String
|
||||
let size: Int64
|
||||
let used: Date
|
||||
let created: Date
|
||||
}
|
||||
|
||||
private func entries() -> [Entry] {
|
||||
let keys: [URLResourceKey] = [.fileSizeKey, .contentModificationDateKey, .creationDateKey]
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? []
|
||||
return files.filter { $0.pathExtension == "bin" }.compactMap { file in
|
||||
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
||||
return Entry(
|
||||
key: file.deletingPathExtension().lastPathComponent,
|
||||
size: Int64(values.fileSize ?? 0),
|
||||
used: values.contentModificationDate ?? .distantPast,
|
||||
created: values.creationDate ?? .distantPast
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops what is past 30 days, then the least recently used tiles while the
|
||||
/// store is over its limit.
|
||||
func prune() { queue.async { self.pruneNow() } }
|
||||
|
||||
private func pruneNow() {
|
||||
writesSincePrune = 0
|
||||
let now = Date()
|
||||
var live: [Entry] = []
|
||||
for entry in entries() {
|
||||
if now.timeIntervalSince(entry.created) > TrekTileStore.maxAge { remove(entry.key) } else { live.append(entry) }
|
||||
}
|
||||
var total = live.reduce(Int64(0)) { $0 + $1.size }
|
||||
guard total > TrekTileStore.maxBytes else { return }
|
||||
let target = Int64(Double(TrekTileStore.maxBytes) * TrekTileStore.pruneTarget)
|
||||
for entry in live.sorted(by: { $0.used < $1.used }) {
|
||||
if total <= target { break }
|
||||
remove(entry.key)
|
||||
total -= entry.size
|
||||
}
|
||||
}
|
||||
|
||||
func usage(_ done: @escaping (Int64) -> Void) {
|
||||
queue.async { done(self.entries().reduce(Int64(0)) { $0 + $1.size }) }
|
||||
}
|
||||
|
||||
func clear(_ done: @escaping () -> Void) {
|
||||
queue.async {
|
||||
try? FileManager.default.removeItem(at: self.directory)
|
||||
try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true)
|
||||
done()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves trek-tile://host/path from the store, or from https://host/path when
|
||||
/// it has no fresh copy, storing what comes back. Offline and not stored, the
|
||||
/// map gets an error and draws nothing there, as it would for a missing tile.
|
||||
final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
static let scheme = "trek-tile"
|
||||
|
||||
/// Tasks WebKit still wants an answer for. Touched on the main thread only:
|
||||
/// start and stop arrive there, and every answer is posted back there.
|
||||
private var active = Set<ObjectIdentifier>()
|
||||
|
||||
private let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.timeoutIntervalForRequest = 20
|
||||
configuration.httpMaximumConnectionsPerHost = 4
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
|
||||
// A unique app User-Agent, as OpenFreeMap and OpenStreetMap ask of apps.
|
||||
configuration.httpAdditionalHeaders = ["User-Agent": "TREK-App/\(version) (iOS; +https://liketrek.com)"]
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let id = ObjectIdentifier(urlSchemeTask)
|
||||
guard let url = urlSchemeTask.request.url,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
urlSchemeTask.didFailWithError(URLError(.badURL))
|
||||
return
|
||||
}
|
||||
components.scheme = "https"
|
||||
guard let remote = components.url else {
|
||||
urlSchemeTask.didFailWithError(URLError(.badURL))
|
||||
return
|
||||
}
|
||||
active.insert(id)
|
||||
let key = TrekTileStore.key(for: remote)
|
||||
let store = TrekTileStore.shared
|
||||
let accept = urlSchemeTask.request.value(forHTTPHeaderField: "Accept")
|
||||
store.queue.async {
|
||||
if let hit = store.read(key) {
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: 200, type: hit.type, data: hit.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: remote)
|
||||
if let accept { request.setValue(accept, forHTTPHeaderField: "Accept") }
|
||||
self.session.dataTask(with: request) { data, response, _ in
|
||||
guard let data, let http = response as? HTTPURLResponse else {
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: 504, type: "text/plain", data: Data())
|
||||
}
|
||||
return
|
||||
}
|
||||
let type = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
|
||||
if http.statusCode == 200, !data.isEmpty { store.write(key, data: data, type: type) }
|
||||
DispatchQueue.main.async {
|
||||
self.respond(urlSchemeTask, id: id, url: url, status: http.statusCode, type: type, data: data)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
active.remove(ObjectIdentifier(urlSchemeTask))
|
||||
}
|
||||
|
||||
/// Answers once, and never after WebKit stopped the task: that would throw.
|
||||
private func respond(_ task: WKURLSchemeTask, id: ObjectIdentifier, url: URL, status: Int, type: String, data: Data) {
|
||||
guard active.remove(id) != nil else { return }
|
||||
let headers = [
|
||||
"Content-Type": type,
|
||||
"Content-Length": String(data.count),
|
||||
// The page's origin is the user's server; WebGL reads tiles cross-origin.
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-store",
|
||||
]
|
||||
guard let response = HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers) else {
|
||||
task.didFailWithError(URLError(.badServerResponse))
|
||||
return
|
||||
}
|
||||
task.didReceive(response)
|
||||
task.didReceive(data)
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -24,7 +26,20 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
static let features: [String] = ["capabilities", "edgeBack"]
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache"]
|
||||
|
||||
// MARK: - Map tile store
|
||||
|
||||
/// How much the stored map tiles take on disk, for the offline settings.
|
||||
@objc func getTileCacheUsage(_ call: CAPPluginCall) {
|
||||
TrekTileStore.shared.usage { bytes in
|
||||
call.resolve(["bytes": bytes, "limit": TrekTileStore.maxBytes])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func clearTileCache(_ call: CAPPluginCall) {
|
||||
TrekTileStore.shared.clear { call.resolve() }
|
||||
}
|
||||
|
||||
@objc func getCapabilities(_ call: CAPPluginCall) {
|
||||
let info = Bundle.main.infoDictionary
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap, initialMapStyle } from '../Map/glSupport'
|
||||
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
@@ -395,6 +396,9 @@ function JourneyMapGL(
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// In the iOS app, Mapbox's own resources go through the app's tile store, so
|
||||
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
|
||||
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
@@ -68,6 +68,7 @@ import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { nativeRasterTemplate } from '../../native/tileCache'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
@@ -938,7 +939,7 @@ export const MapView = memo(function MapView({
|
||||
) : (
|
||||
<TileLayer
|
||||
key="raster"
|
||||
url={basemap.url}
|
||||
url={nativeRasterTemplate(basemap.url)}
|
||||
attribution={attributionForTile(basemap.url)}
|
||||
maxZoom={19}
|
||||
keepBuffer={8}
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
* markers and routes, and says so.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { MapViewAuto } from './MapViewAuto';
|
||||
|
||||
const env = vi.hoisted(() => ({ offline: false, dark: false }));
|
||||
const env = vi.hoisted(() => ({ offline: false, dark: false, tileStore: false }));
|
||||
const seen = vi.hoisted(() => ({
|
||||
leaflet: null as null | { tileUrl?: string },
|
||||
gl: null as null | { engine: string; glProvider?: string; styleOverride?: string },
|
||||
@@ -40,6 +40,7 @@ vi.mock('../../hooks/useNetworkMode', () => ({
|
||||
useNetworkMode: () => ({ offline: env.offline, forced: false, setForced: () => {} }),
|
||||
}));
|
||||
vi.mock('../../hooks/useIsDark', () => ({ useIsDark: () => env.dark }));
|
||||
vi.mock('../../native/tileCache', () => ({ tileCacheActive: () => env.tileStore }));
|
||||
vi.mock('../../native/platform', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../native/platform')>()),
|
||||
isNativeIos: () => false,
|
||||
@@ -52,6 +53,7 @@ beforeEach(() => {
|
||||
resetWebGLProbe();
|
||||
env.offline = false;
|
||||
env.dark = false;
|
||||
env.tileStore = false;
|
||||
seen.leaflet = null;
|
||||
seen.gl = null;
|
||||
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
|
||||
@@ -120,4 +122,30 @@ describe('MapViewAuto offline', () => {
|
||||
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
|
||||
expect(pill()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-006: in the iOS app with its tile store, Mapbox stays offline and the pill switches to the stored map', async () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'mapbox-gl', mapbox_access_token: 'pk.test' } });
|
||||
env.offline = true;
|
||||
env.tileStore = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'mapbox', styleOverride: undefined });
|
||||
const toggle = screen.getByRole('button', { pressed: false });
|
||||
fireEvent.click(toggle);
|
||||
await screen.findByTestId('gl-map');
|
||||
expect(seen.gl).toMatchObject({ engine: 'maplibre', styleOverride: OFM_POSITRON });
|
||||
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-OFFLINE-007: with the tile store, a raster template stays offline in the app too', () => {
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'leaflet' } });
|
||||
env.offline = true;
|
||||
env.tileStore = true;
|
||||
|
||||
render(<MapViewAuto places={[]} tileUrl="https://tiles.example.com/{z}/{x}/{y}.png" />);
|
||||
|
||||
expect(seen.leaflet?.tileUrl).toBe('https://tiles.example.com/{z}/{x}/{y}.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { AlertTriangle, Clock, CloudOff, Info } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -13,6 +13,7 @@ import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap'
|
||||
import { tileCacheActive } from '../../native/tileCache'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
@@ -43,12 +44,26 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
const { offline } = useNetworkMode()
|
||||
const dark = useIsDark()
|
||||
const offlineStyle = offlineStyleFor(dark)
|
||||
// Offline on a basemap that has nothing stored: draw the stored one with MapLibre.
|
||||
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle)
|
||||
// In the iOS app with its tile store, a Mapbox map keeps what was viewed for 30
|
||||
// days, so offline it stays Mapbox, and the pill offers the stored trip region
|
||||
// instead for the places never looked at. Everywhere else a basemap with
|
||||
// nothing stored is swapped for the stored one straight away.
|
||||
const nativeStore = tileCacheActive()
|
||||
const [preferStored, setPreferStored] = useState(false)
|
||||
const mapboxKept = configuredGl === 'mapbox-gl' && nativeStore
|
||||
const glFallback = !!configuredGl && offline && !glWorksOffline(configuredGl, maplibreStyle) && (!mapboxKept || preferStored)
|
||||
const glProvider = glFallback ? 'maplibre-gl' : configuredGl
|
||||
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON)
|
||||
const leafletFallback = !configuredGl && offline && !leafletWorksOffline(mapProps.tileUrl ?? OFM_POSITRON, nativeIosWithoutStore(nativeStore))
|
||||
if (leafletFallback) mapProps.tileUrl = offlineStyle
|
||||
const pill = (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
|
||||
const pill = offline && mapboxKept
|
||||
? (
|
||||
<OfflineBasemapPill
|
||||
label={preferStored ? t('map.offlineBasemap') : t('map.offlineShowStored')}
|
||||
pressed={preferStored}
|
||||
onToggle={() => setPreferStored(v => !v)}
|
||||
/>
|
||||
)
|
||||
: (glFallback || leafletFallback) && <OfflineBasemapPill label={t('map.offlineBasemap')} />
|
||||
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
|
||||
// maplibre-gl out of each other's downloads.
|
||||
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
|
||||
@@ -79,13 +94,29 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Says the map on screen is the stored offline basemap, not the user's own. */
|
||||
function OfflineBasemapPill({ label }: { label: string }) {
|
||||
/** A raster template cannot be served offline in the iOS app unless it has its tile store. */
|
||||
function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
|
||||
return nativeStore ? false : undefined
|
||||
}
|
||||
|
||||
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
|
||||
|
||||
/**
|
||||
* Says the map on screen is the stored offline basemap, not the user's own. With
|
||||
* onToggle it is a switch between the user's map (as far as it was viewed and
|
||||
* stored) and the stored trip region.
|
||||
*/
|
||||
function OfflineBasemapPill({ label, pressed, onToggle }: { label: string; pressed?: boolean; onToggle?: () => void }) {
|
||||
if (onToggle) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={pressed} className={`${PILL} pointer-events-auto`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-none absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]"
|
||||
>
|
||||
<div role="status" className={`${PILL} pointer-events-none`}>
|
||||
<CloudOff size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import { buildGlMap, initialMapStyle } from './glSupport'
|
||||
import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -1018,6 +1019,9 @@ export function MapViewGL({
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// In the iOS app, Mapbox's own resources go through the app's tile store, so
|
||||
// what was viewed stays on the device for up to 30 days (native/tileCache.ts).
|
||||
if (!isMapLibre && tileCacheActive()) mapOptions.transformRequest = mapboxTileCacheRequest
|
||||
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { QueuedMutation } from '../../../db/offlineDb'
|
||||
import { MSetCard, MSetEyebrow, MSetRow, MSetSegments, MSetButton } from './MSettingsUi'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MTileCacheUsage from './MTileCacheUsage'
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
@@ -159,6 +160,8 @@ export default function MSettingsOffline() {
|
||||
sub={t('settings.offline.storage.tilesHint')}
|
||||
trailing={<MToggle checked={prefs.cacheTiles} onChange={handleToggleTiles} ariaLabel={t('settings.offline.storage.tiles')} />}
|
||||
/>
|
||||
{/* The iOS app's own store of the map tiles looked at, beside the trip regions above. */}
|
||||
<MTileCacheUsage />
|
||||
</div>
|
||||
{allTrips.length > 0 && (
|
||||
<div className="mt-3 border-t border-[color:var(--m-rowbr)] pt-3">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { tileCacheActive } from '../../../native/tileCache';
|
||||
import { TrekShell } from '../../../native/trekShell';
|
||||
import MTileCacheUsage from './MTileCacheUsage';
|
||||
|
||||
vi.mock('../../../native/tileCache', () => ({ tileCacheActive: vi.fn() }));
|
||||
vi.mock('../../../native/trekShell', () => ({
|
||||
TrekShell: { getTileCacheUsage: vi.fn(), clearTileCache: vi.fn() },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tileCacheActive).mockReturnValue(true);
|
||||
vi.mocked(TrekShell.getTileCacheUsage)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ bytes: 84 * 1024 * 1024, limit: 250 * 1024 * 1024 });
|
||||
vi.mocked(TrekShell.clearTileCache).mockReset().mockResolvedValue();
|
||||
});
|
||||
|
||||
describe('MTileCacheUsage', () => {
|
||||
it('shows what the map tile store takes against its limit, and empties it', async () => {
|
||||
render(<MTileCacheUsage />);
|
||||
expect(await screen.findByText(/84/)).toHaveTextContent(/250/);
|
||||
|
||||
vi.mocked(TrekShell.getTileCacheUsage).mockResolvedValue({ bytes: 0, limit: 250 * 1024 * 1024 });
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => expect(TrekShell.clearTileCache).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByRole('button')).toBeDisabled());
|
||||
});
|
||||
|
||||
it('renders nothing outside a binary with the store', () => {
|
||||
vi.mocked(tileCacheActive).mockReturnValue(false);
|
||||
const { container } = render(<MTileCacheUsage />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(TrekShell.getTileCacheUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { reportNativeFailure } from '../../../native/reportFailure';
|
||||
import { tileCacheActive } from '../../../native/tileCache';
|
||||
import { TrekShell } from '../../../native/trekShell';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { MSetButton, MSetRow } from './MSettingsUi';
|
||||
|
||||
/**
|
||||
* How much room the iOS app's map tile store takes (native/tileCache.ts), with
|
||||
* a way to empty it. The store keeps itself under its limit and drops tiles
|
||||
* older than 30 days on its own; this is for seeing it and taking the room back
|
||||
* by hand. Renders nothing outside a binary that has the store.
|
||||
*/
|
||||
export default function MTileCacheUsage() {
|
||||
const { t } = useTranslation();
|
||||
const active = tileCacheActive();
|
||||
const [usage, setUsage] = useState<{ bytes: number; limit: number } | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
TrekShell.getTileCacheUsage().then(setUsage, reportNativeFailure);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) refresh();
|
||||
}, [active, refresh]);
|
||||
|
||||
if (!active || !usage) return null;
|
||||
|
||||
const clear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
await TrekShell.clearTileCache();
|
||||
} catch (err) {
|
||||
reportNativeFailure(err);
|
||||
}
|
||||
setClearing(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<MSetRow
|
||||
label={t('settings.offline.tileStore.title')}
|
||||
sub={t('settings.offline.tileStore.usage', { used: formatBytes(usage.bytes), limit: formatBytes(usage.limit) })}
|
||||
trailing={
|
||||
<MSetButton variant="ghost" disabled={clearing || usage.bytes === 0} onClick={() => void clear()}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.tileStore.clear')}
|
||||
</MSetButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { isNativeIos } from './platform';
|
||||
import { mapboxTileCacheRequest, nativeRasterTemplate, tileCacheActive, toTileCacheUrl } from './tileCache';
|
||||
|
||||
vi.mock('./platform', () => ({ isNativeIos: vi.fn() }));
|
||||
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isNativeIos).mockReturnValue(true);
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('tileCache', () => {
|
||||
it('is on only in an iOS binary that has the store', () => {
|
||||
expect(tileCacheActive()).toBe(true);
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(false);
|
||||
expect(tileCacheActive()).toBe(false);
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(true);
|
||||
vi.mocked(isNativeIos).mockReturnValue(false);
|
||||
expect(tileCacheActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('swaps only the scheme, so the store knows where the tile lives', () => {
|
||||
expect(toTileCacheUrl('https://a.tile.example.com/1/2/3.png?k=v')).toBe(
|
||||
'trek-tile://a.tile.example.com/1/2/3.png?k=v'
|
||||
);
|
||||
expect(toTileCacheUrl('http://insecure.example.com/x')).toBe('http://insecure.example.com/x');
|
||||
});
|
||||
|
||||
it('routes Leaflet raster templates through the store only where there is one', () => {
|
||||
expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe(
|
||||
'trek-tile://{s}.tiles.example.com/{z}/{x}/{y}.png'
|
||||
);
|
||||
vi.mocked(hasNativeFeature).mockReturnValue(false);
|
||||
expect(nativeRasterTemplate('https://{s}.tiles.example.com/{z}/{x}/{y}.png')).toBe(
|
||||
'https://{s}.tiles.example.com/{z}/{x}/{y}.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends what a Mapbox map draws through the store, never its telemetry', () => {
|
||||
expect(
|
||||
mapboxTileCacheRequest('https://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk', 'Style').url
|
||||
).toBe('trek-tile://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=pk');
|
||||
expect(mapboxTileCacheRequest('https://a.tiles.mapbox.com/v4/x/1/2/3.vector.pbf', 'Tile').url).toMatch(
|
||||
/^trek-tile:/
|
||||
);
|
||||
expect(mapboxTileCacheRequest('https://events.mapbox.com/events/v2', 'Unknown').url).toBe(
|
||||
'https://events.mapbox.com/events/v2'
|
||||
);
|
||||
expect(mapboxTileCacheRequest('https://api.mapbox.com/map-sessions/v1', 'Unknown').url).toBe(
|
||||
'https://api.mapbox.com/map-sessions/v1'
|
||||
);
|
||||
expect(mapboxTileCacheRequest('https://tiles.openfreemap.org/planet/1/2/3.pbf', 'Tile').url).toBe(
|
||||
'https://tiles.openfreemap.org/planet/1/2/3.pbf'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { hasNativeFeature } from './capabilities';
|
||||
import { isNativeIos } from './platform';
|
||||
|
||||
/**
|
||||
* The iOS app's own tile store (TrekTileSchemeHandler.swift), reached through
|
||||
* the trek-tile:// scheme.
|
||||
*
|
||||
* WKWebView runs no service worker for a self-hosted server, so on iOS nothing
|
||||
* kept the map tiles a user had looked at. Binaries that report the tileCache
|
||||
* feature serve every trek-tile:// request from a store on disk, filled as the
|
||||
* tiles are viewed and kept for at most 30 days (Mapbox's limit for a device
|
||||
* cache, applied to every provider alike), and from the network otherwise. The
|
||||
* URL keeps its host and path, only the scheme changes, so the handler knows
|
||||
* where the tile really lives.
|
||||
*
|
||||
* Nothing is prefetched through here: Mapbox forbids bulk downloads, and the
|
||||
* OpenFreeMap region a trip stores goes through Cache Storage (offlineTiles.ts).
|
||||
*/
|
||||
export const TILE_CACHE_SCHEME = 'trek-tile';
|
||||
|
||||
export function tileCacheActive(): boolean {
|
||||
return isNativeIos() && hasNativeFeature('tileCache');
|
||||
}
|
||||
|
||||
/** https://host/path?query -> trek-tile://host/path?query. Anything else stays. */
|
||||
export function toTileCacheUrl(url: string): string {
|
||||
return url.startsWith('https://') ? `${TILE_CACHE_SCHEME}://${url.slice('https://'.length)}` : url;
|
||||
}
|
||||
|
||||
/** A raster tile template for Leaflet, through the store where the app has one. */
|
||||
export function nativeRasterTemplate(template: string): string {
|
||||
return tileCacheActive() ? toTileCacheUrl(template) : template;
|
||||
}
|
||||
|
||||
const MAPBOX_HOST = /^https:\/\/(?:api\.mapbox\.com|[a-z0-9-]+\.tiles\.mapbox\.com)\//i;
|
||||
/** What a map draws; telemetry and anything else keeps going straight out. */
|
||||
const CACHED_RESOURCES = new Set([
|
||||
'Style',
|
||||
'Source',
|
||||
'Tile',
|
||||
'Glyphs',
|
||||
'SpriteImage',
|
||||
'SpriteJSON',
|
||||
'Image',
|
||||
'Iconset',
|
||||
'Model',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Mapbox GL's transformRequest for the iOS app: the map's own resources from
|
||||
* Mapbox's servers go through the store, events and sessions never do.
|
||||
*/
|
||||
export function mapboxTileCacheRequest(url: string, resourceType?: string): { url: string } {
|
||||
if (!MAPBOX_HOST.test(url)) return { url };
|
||||
if (resourceType && !CACHED_RESOURCES.has(resourceType)) return { url };
|
||||
return { url: toTileCacheUrl(url) };
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export interface TrekShellPlugin {
|
||||
openOfflineCopy(): Promise<{ opened: boolean }>;
|
||||
/** What this binary offers the UI (native/capabilities.ts). Missing before shell API 2. */
|
||||
getCapabilities(): Promise<NativeCapabilities>;
|
||||
/** iOS, feature tileCache: what the map tile store takes on disk, and its limit. */
|
||||
getTileCacheUsage(): Promise<{ bytes: number; limit: number }>;
|
||||
/** iOS, feature tileCache: empties the map tile store. */
|
||||
clearTileCache(): Promise<void>;
|
||||
}
|
||||
|
||||
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
|
||||
|
||||
Reference in New Issue
Block a user