mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +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:
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user