Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement custom map file support #758

Merged
merged 21 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@
"Screens.Settings.AppSettings.languageDesc": {
"message": "Display language for app"
},
"Screens.Settings.AppSettings.mapManagement": {
"message": "Map Management"
},
"Screens.Settings.AppSettings.mapManagementDesc": {
"message": "Backgrounds, Map Data"
},
"Screens.Settings.AppSettings.title": {
"message": "App Settings"
},
Expand Down Expand Up @@ -1264,6 +1270,66 @@
"screens.Settings.CreateOrJoinProject.whatIsAProject": {
"message": "What is a Project"
},
"screens.Settings.MapManagement.BackgroundMaps.ChooseMapFile.acceptedFileTypes": {
"message": "Accepted file types are .smp"
},
"screens.Settings.MapManagement.BackgroundMaps.ChooseMapFile.chooseFile": {
"message": "Choose File"
},
"screens.Settings.MapManagement.BackgroundMaps.about": {
"message": "About Custom Map"
},
"screens.Settings.MapManagement.BackgroundMaps.cannotBeUndone": {
"message": "This cannot be undone."
},
"screens.Settings.MapManagement.BackgroundMaps.close": {
"message": "Close"
},
"screens.Settings.MapManagement.BackgroundMaps.customMapAddedDescription": {
"message": "You will see this map when you are offline, but you will not see a map outside the area defined in your custom map."
},
"screens.Settings.MapManagement.BackgroundMaps.customMapAddedTitle": {
"message": "Custom Map Added"
},
"screens.Settings.MapManagement.BackgroundMaps.customMapInfoLoadError": {
"message": "Could not get custom map information. Please choose a different file."
},
"screens.Settings.MapManagement.BackgroundMaps.deleteCustomMapDescription": {
"message": "This will delete the map and its offline areas. No collected observation data will be deleted."
},
"screens.Settings.MapManagement.BackgroundMaps.deleteCustomMapTitle": {
"message": "Delete Custom Map?"
},
"screens.Settings.MapManagement.BackgroundMaps.deleteMapButtonText": {
"message": "Delete Map"
},
"screens.Settings.MapManagement.BackgroundMaps.description1": {
"message": "Adding a custom map will enable you to see a map when you are offline."
},
"screens.Settings.MapManagement.BackgroundMaps.description2": {
"message": "Your custom map is not shared with other devices in your project."
},
"screens.Settings.MapManagement.BackgroundMaps.screenTitle": {
"message": "Background Maps"
},
"screens.Settings.MapManagement.MapsList.CustomMapDetails.dateAdded": {
"message": "Date Added"
},
"screens.Settings.MapManagement.MapsList.CustomMapDetails.mapNameColumn": {
"message": "Map Name"
},
"screens.Settings.MapManagement.MapsList.CustomMapDetails.removeMap": {
"message": "Remove Map"
},
"screens.Settings.MapManagement.MapsList.CustomMapDetails.sizeInMegabytes": {
"message": "{value} MB"
},
"screens.Settings.MapManagement.backgroundMaps": {
"message": "Background Maps"
},
"screens.Settings.MapManagement.screenTitle": {
"message": "Map Management"
},
"screens.Settings.YourTeam.InviteDeclined": {
"message": "Invitation Declined"
},
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
"tiny-typed-emitter": "^2.1.0",
"uint8array-extras": "^0.5.0",
"utm": "^1.1.1",
"valibot": "^0.42.1",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm introducing this dep for response body validation, which is used when interfacing with the map server

"validate-color": "^2.2.4",
"zustand": "^4.4.6"
},
Expand Down
13 changes: 10 additions & 3 deletions src/backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import debug from 'debug'
import { join } from 'path'
import { mkdirSync } from 'fs'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
/** @type {import('../types/rn-bridge.js')} */
const rnBridge = require('rn-bridge')
import { MapeoManager, FastifyController } from '@comapeo/core'
import { createMapeoServer } from '@comapeo/ipc'
import Fastify from 'fastify'

import MessagePortLike from './message-port-like.js'
import { ServerStatus } from './status.js'

const require = createRequire(import.meta.url)

/** @type {import('../types/rn-bridge.js')} */
const rnBridge = require('rn-bridge')

// Do not touch these!
const DB_DIR_NAME = 'sqlite-dbs'
const CORE_STORAGE_DIR_NAME = 'core-storage'
const CUSTOM_MAPS_DIR_NAME = 'maps'
const DEFAULT_CUSTOM_MAP_FILE_NAME = 'default.smp'
Comment on lines 18 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that this requires the developer to know the names of the directories? If so, that seems quite fragile, maybe something to consider refactoring at the UK retreat.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah there's some duplication between frontend and backend in terms of knowing where this directory is. However, there's no good way to share code between the two unless you involve an unnecessary IPC roundtrip, which I'd rather avoid in this case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for posterity: the way this custom map feature works is the following:

  1. The frontend is responsible for moving a file to and from an app-specific directory on the device. In this case, we the android "document directory" which is something like file:///data/user/0/com.comapeo.dev/files/ and make a maps/default.smp there.
  2. The backend is configured to use the path in (1) as the path to watch for determining if there's a custom map file that can be used for the map server.


const MAPBOX_ACCESS_TOKEN =
'pk.eyJ1IjoiZGlnaWRlbSIsImEiOiJjbHRyaGh3cm0wN3l4Mmpsam95NDI3c2xiIn0.daq2iZFZXQ08BD0VZWAGUw'
Expand Down Expand Up @@ -64,9 +68,11 @@ export async function init({
const privateStorageDir = rnBridge.app.datadir()
const dbDir = join(privateStorageDir, DB_DIR_NAME)
const indexDir = join(privateStorageDir, CORE_STORAGE_DIR_NAME)
const customMapsDir = join(privateStorageDir, CUSTOM_MAPS_DIR_NAME)

mkdirSync(dbDir, { recursive: true })
mkdirSync(indexDir, { recursive: true })
mkdirSync(customMapsDir, { recursive: true })

const fastify = Fastify()
const fastifyController = new FastifyController({ fastify })
Expand All @@ -80,6 +86,7 @@ export async function init({
fastify,
defaultConfigPath,
defaultOnlineStyleUrl: DEFAULT_ONLINE_MAP_STYLE_URL,
customMapPath: join(customMapsDir, DEFAULT_CUSTOM_MAP_FILE_NAME),
})

// Don't await, methods that use the server will await this internally
Expand Down
22 changes: 20 additions & 2 deletions src/frontend/Navigation/Stack/AppScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ import {
Audio,
navigationOptions as audioNavigationOptions,
} from '../../screens/Audio/index.tsx';
import {
createNavigationOptions as createMapManagementNavigationOptions,
MapManagementScreen,
} from '../../screens/Settings/MapManagement';
import {
createNavigationOptions as createBackgroundMapsNavigationOptions,
BackgroundMapsScreen,
} from '../../screens/Settings/MapManagement/BackgroundMaps.tsx';

export const TAB_BAR_HEIGHT = 70;

Expand Down Expand Up @@ -314,13 +322,11 @@ export const createDefaultScreenGroup = ({
component={TrackEdit}
options={{headerTitle: intl(TrackEdit.navTitle)}}
/>

<RootStack.Screen
name="Config"
component={Config}
options={{headerTitle: intl(Config.navTitle)}}
/>

<RootStack.Screen
name="HowToLeaveProject"
component={HowToLeaveProject}
Expand All @@ -333,6 +339,18 @@ export const createDefaultScreenGroup = ({
component={Audio}
/>
)}

<RootStack.Screen
name="MapManagement"
component={MapManagementScreen}
options={createMapManagementNavigationOptions({intl})}
/>
<RootStack.Screen
name="BackgroundMaps"
component={BackgroundMapsScreen}
options={createBackgroundMapsNavigationOptions({intl})}
/>

{process.env.EXPO_PUBLIC_FEATURE_TEST_DATA_UI && (
<RootStack.Screen
name="CreateTestData"
Expand Down
42 changes: 42 additions & 0 deletions src/frontend/hooks/refreshTokenStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {create} from 'zustand';

interface RefreshTokenStoreSlice {
value: number;
actions: {
refresh: () => void;
};
}

/**
* Factory for creating a bound store instance and return its relevant hooks,
* which allows the creation of isolated stores to account for contextual needs.
*/
export function createRefreshTokenStore(initialValue?: number) {
const useRefreshTokenStore = create<RefreshTokenStoreSlice>(set => {
return {
value: typeof initialValue === 'number' ? initialValue : Date.now(),
actions: {
refresh: () => {
set({value: Date.now()});
},
},
};
});

return {
useRefreshToken: () => {
return useRefreshTokenStore(valueSelector);
},
useRefreshTokenActions: () => {
return useRefreshTokenStore(actionsSelector);
},
};
}

function valueSelector(state: RefreshTokenStoreSlice) {
return state.value;
}

function actionsSelector(state: RefreshTokenStoreSlice) {
return state.actions;
}
16 changes: 0 additions & 16 deletions src/frontend/hooks/server/mapStyleUrl.ts

This file was deleted.

117 changes: 117 additions & 0 deletions src/frontend/hooks/server/maps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import * as FileSystem from 'expo-file-system';
import * as v from 'valibot';

import {useApi} from '../../contexts/ApiContext';
import {DOCUMENT_DIRECTORY, selectFile} from '../../lib/file-system';

import {createRefreshTokenStore} from '../refreshTokenStore';

export const MAPS_QUERY_KEY = 'maps';

const CUSTOM_MAPS_DIRECTORY = new URL('maps', DOCUMENT_DIRECTORY).href;
const DEFAULT_CUSTOM_MAP_FILE_PATH = CUSTOM_MAPS_DIRECTORY + '/default.smp';

const CustomMapInfoSchema = v.object({
created: v.pipe(
v.string(),
v.isoTimestamp(),
v.transform(input => new Date(input)),
),
name: v.string(),
size: v.pipe(v.number(), v.minValue(0)),
});

export type CustomMapInfo = v.InferOutput<typeof CustomMapInfoSchema>;

const {useRefreshToken, useRefreshTokenActions} = createRefreshTokenStore();

export function useMapStyleJsonUrl() {
const api = useApi();
const refreshToken = useRefreshToken();

return useQuery({
queryKey: [MAPS_QUERY_KEY, 'stylejson-url', refreshToken],
queryFn: async () => {
let result = await api.getMapStyleJsonUrl();
return result + `?refresh_token=${refreshToken}`;
},
});
}

export function useSelectCustomMapFile() {
return useMutation({
mutationFn: () => {
return selectFile({
extensionFilters: ['smp'],
});
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think based on how simple this function is, we should just have a useSelectCustomFile(extenstionfilters?:[string]), and the consuming component can set the extensions. What do you think?

Copy link
Member Author

@achou11 achou11 Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed via dcac9e0

Think ideally this stuff in useSelectFileAndImportConfig.ts also lives in the file I created here, but would consider that more of a follow-up item


export function useImportCustomMapFile() {
const queryClient = useQueryClient();
const {refresh} = useRefreshTokenActions();

return useMutation({
mutationFn: async (opts: {uri: string}) => {
return FileSystem.moveAsync({
from: opts.uri,
to: DEFAULT_CUSTOM_MAP_FILE_PATH,
});
},
onSuccess: () => {
refresh();
queryClient.invalidateQueries({
queryKey: [MAPS_QUERY_KEY],
});
Comment on lines +68 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't fully understand the need for the refresh token? From a surface perspective it looks like it is doing the same thing as invalidating the query

Copy link
Member Author

@achou11 achou11 Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the tricky part here is that the invalidation we need is controlled by the consuming map view, not react query. for example:

  1. We have an initial value of: http://localhost:8000/maps/style.json. This is used by the map view, which makes the http request internally to fetch a response. let's say { "name": "my-map" } for the sake of the example.
  2. We do something that should change what style.json we get back in 1 (such as add or remove a map).
  3. We invalidate the query, but that isn't enough in some cases because you can still get the same value of http://localhost:8000/maps/style.json, in which case the mapview won't actually get a new response from that URL because of HTTP caching.

In order to actually force the mapview to fully fetch the response of the same url and ignore the cache, we introduce the refresh token in the form of a url query string, which counts as a new url, even though the query string goes unused by the server that we're calling. we did something similar in Mapeo Mobile for other http resources that would have the same issue (e.g. here)

},
});
}

export function useRemoveCustomMapFile() {
const queryClient = useQueryClient();
const {refresh} = useRefreshTokenActions();

return useMutation({
mutationFn: () => {
return FileSystem.deleteAsync(DEFAULT_CUSTOM_MAP_FILE_PATH, {
idempotent: true,
});
},
onSuccess: () => {
refresh();
queryClient.invalidateQueries({
queryKey: [MAPS_QUERY_KEY],
});
},
});
}

/**
* Returns `null` if no viable map is found. Throws an error if a detected map is invalid.
*/
export function useGetCustomMapInfo() {
const api = useApi();

return useQuery({
queryKey: [MAPS_QUERY_KEY, 'custom', 'info'],
queryFn: async () => {
const styleUrl = await api.getMapStyleJsonUrl();

const infoUrl = new URL('/maps/custom/info', styleUrl).href;

const response = await fetch(infoUrl);

if (response.status === 404) {
return null;
}

if (!response.ok) {
throw new Error(`Cannot get custom map info: ${response.statusText}`);
}

return v.parse(CustomMapInfoSchema, await response.json());
},
});
}
3 changes: 3 additions & 0 deletions src/frontend/lib/bytesToMegabytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function bytesToMegabytes(bytes: number) {
return bytes / 2 ** 20;
}
Loading
Loading