Skip to content

Commit

Permalink
feat: serve map tile styles from tiles.immich.cloud (#12858)
Browse files Browse the repository at this point in the history
Co-authored-by: shenlong-tanwen <[email protected]>
  • Loading branch information
zackpollard and shenlong-tanwen authored Sep 23, 2024
1 parent e41785b commit bcd4164
Show file tree
Hide file tree
Showing 30 changed files with 680 additions and 952 deletions.
6 changes: 3 additions & 3 deletions e2e/package-lock.json

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

65 changes: 1 addition & 64 deletions e2e/src/api/specs/map.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
Expand All @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/map', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let asset: AssetMediaResponseDto;

beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);

websocket = await utils.connectWebsocket(admin.accessToken);

asset = await utils.createAsset(admin.accessToken);

const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
utils.resetEvents();
const uploadFile = async (input: string) => {
Expand Down Expand Up @@ -103,63 +97,6 @@ describe('/map', () => {
});
});

describe('GET /map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});

it('should allow shared link access', async () => {
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Individual,
assetIds: [asset.id],
});
const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' });

expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});

it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark']));
}
});

it('should return the light style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});

it('should return the dark style.json', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});

it('should not require admin authentication', async () => {
const { status, body } = await request(app)
.get('/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});

describe('GET /map/reverse-geocode', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/map/reverse-geocode');
Expand Down
2 changes: 2 additions & 0 deletions e2e/src/api/specs/server-info.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ describe('/server-info', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});
Expand Down
2 changes: 2 additions & 0 deletions e2e/src/api/specs/server.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ describe('/server', () => {
isInitialized: true,
externalDomain: '',
isOnboarded: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion mobile/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.24.0",
"dart.flutterSdkPath": ".fvm/versions/3.24.3",
"search.exclude": {
"**/.fvm": true
},
Expand Down
10 changes: 9 additions & 1 deletion mobile/lib/models/server_info/server_config.model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ class ServerConfig {
final int trashDays;
final String oauthButtonText;
final String externalDomain;
final String mapDarkStyleUrl;
final String mapLightStyleUrl;

const ServerConfig({
required this.trashDays,
required this.oauthButtonText,
required this.externalDomain,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
});

ServerConfig copyWith({
Expand All @@ -20,6 +24,8 @@ class ServerConfig {
trashDays: trashDays ?? this.trashDays,
oauthButtonText: oauthButtonText ?? this.oauthButtonText,
externalDomain: externalDomain ?? this.externalDomain,
mapDarkStyleUrl: mapDarkStyleUrl,
mapLightStyleUrl: mapLightStyleUrl,
);
}

Expand All @@ -30,7 +36,9 @@ class ServerConfig {
ServerConfig.fromDto(ServerConfigDto dto)
: trashDays = dto.trashDays,
oauthButtonText = dto.oauthButtonText,
externalDomain = dto.externalDomain;
externalDomain = dto.externalDomain,
mapDarkStyleUrl = dto.mapDarkStyleUrl,
mapLightStyleUrl = dto.mapLightStyleUrl;

@override
bool operator ==(covariant ServerConfig other) {
Expand Down
15 changes: 8 additions & 7 deletions mobile/lib/pages/search/map/map.page.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:math';

import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
Expand All @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:maplibre_gl/maplibre_gl.dart';

@RoutePage()
Expand Down Expand Up @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget {
),
Positioned(
right: 0,
bottom: MediaQuery.of(context).padding.bottom + 16,
bottom: MediaQuery.paddingOf(context).bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
Expand Down
75 changes: 8 additions & 67 deletions mobile/lib/providers/map/map_state.provider.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/models/map/map_state.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'map_state.provider.g.dart';

@Riverpod(keepAlive: true)
class MapStateNotifier extends _$MapStateNotifier {
final _log = Logger("MapStateNotifier");

@override
MapState build() {
final appSettingsProvider = ref.read(appSettingsServiceProvider);

// Fetch and save the Style JSONs
loadStyles();
final lightStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl;
final darkStyleUrl =
ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl;

return MapState(
themeMode: ThemeMode.values[
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
Expand All @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier {
appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapwithPartners),
relativeTime:
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
lightStyleFetched: AsyncData(lightStyleUrl),
darkStyleFetched: AsyncData(darkStyleUrl),
);
}

void loadStyles() async {
final documents = (await getApplicationDocumentsDirectory()).path;

// Set to loading
state = state.copyWith(lightStyleFetched: const AsyncLoading());

// Fetch and save light theme
final lightResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.light);

if (lightResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map light style",
lightResponse.toLoggerString(),
);
return;
}

final lightJSON = lightResponse.body;
final lightFile = await File("$documents/map-style-light.json")
.writeAsString(lightJSON, flush: true);

// Update state with path
state =
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));

// Set to loading
state = state.copyWith(darkStyleFetched: const AsyncLoading());

// Fetch and save dark theme
final darkResponse = await ref
.read(apiServiceProvider)
.mapApi
.getMapStyleWithHttpInfo(MapTheme.dark);

if (darkResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
_log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
return;
}

final darkJSON = darkResponse.body;
final darkFile = await File("$documents/map-style-dark.json")
.writeAsString(darkJSON, flush: true);

// Update state with path
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
}

void switchTheme(ThemeMode mode) {
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapThemeMode,
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/providers/server_info.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
trashDays: 30,
oauthButtonText: '',
externalDomain: '',
mapLightStyleUrl:
'https://tiles.immich.cloud/v1/style/light.json',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
),
serverDiskInfo: const ServerDiskInfo(
diskAvailable: "0",
Expand Down
13 changes: 13 additions & 0 deletions mobile/lib/utils/openapi_patching.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson());
}
break;
case 'ServerConfigDto':
if (value is Map) {
addDefault(
value,
'mapLightStyleUrl',
'https://tiles.immich.cloud/v1/style/light.json',
);
addDefault(
value,
'mapDarkStyleUrl',
'https://tiles.immich.cloud/v1/style/dark.json',
);
}
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
Expand Down
2 changes: 0 additions & 2 deletions mobile/openapi/README.md

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

1 change: 0 additions & 1 deletion mobile/openapi/lib/api.dart

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

Loading

0 comments on commit bcd4164

Please sign in to comment.