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(mobile): add additional request headers #10588

Merged
merged 7 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions mobile/assets/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_tile_title": "Advanced",
"headers_settings_tile_title": "Custom proxy headers",
"headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request",

"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
"album_info_card_backup_album_excluded": "EXCLUDED",
Expand Down Expand Up @@ -522,5 +525,8 @@
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
}
"viewer_unstack": "Un-Stack",
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"header_settings_page_title": "Proxy Headers"
}
1 change: 1 addition & 0 deletions mobile/lib/entities/store.entity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ enum StoreKey<T> {
mapThemeMode<int>(124, type: int),
mapwithPartners<bool>(125, type: bool),
enableHapticFeedback<bool>(126, type: bool),
customHeaders<String>(127, type: String),
;

const StoreKey(
Expand Down
183 changes: 183 additions & 0 deletions mobile/lib/pages/common/headers_settings.page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import 'dart:convert';

import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/store.entity.dart' as store_keys;
import 'package:hooks_riverpod/hooks_riverpod.dart';

class SettingsHeader {
String key = "";
String value = "";
}

@RoutePage()
class HeaderSettingsPage extends HookConsumerWidget {
const HeaderSettingsPage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false);

var headersStr =
store_keys.Store.get(store_keys.StoreKey.customHeaders, "");
if (!setInitialHeaders.value) {
if (headersStr.isNotEmpty) {
var customHeaders = jsonDecode(headersStr) as Map;
customHeaders.forEach((k, v) {
final header = SettingsHeader();
header.key = k;
header.value = v;
headers.value.add(header);
});
}

// add first one to help the user
if (headers.value.isEmpty) {
final header = SettingsHeader();
header.key = '';
header.value = '';

headers.value.add(header);
}
}
setInitialHeaders.value = true;

var list = [
...headers.value.map((headerValue) {
return HeaderKeyValueSettings(
header: headerValue,
onRemove: () {
headers.value.remove(headerValue);
headers.value = headers.value.toList();
},
);
}),
];

return Scaffold(
appBar: AppBar(
title: const Text('header_settings_page_title').tr(),
centerTitle: false,
actions: [
IconButton(
onPressed: () {
headers.value.add(SettingsHeader());
headers.value = headers.value.toList();
},
icon: const Icon(Icons.add_outlined),
tooltip: 'Add Header',
),
],
),
body: PopScope(
onPopInvoked: (_) => saveHeaders(headers.value),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length,
itemBuilder: (ctx, index) => list[index],
separatorBuilder: (context, index) => const Padding(
padding: EdgeInsets.only(bottom: 16.0, left: 8, right: 8),
child: Divider(),
),
),
),
);
}

saveHeaders(List<SettingsHeader> headers) {
final headersMap = {};
for (var header in headers) {
final key = header.key.trim();
final value = header.value.trim();

if (key.isEmpty || value.isEmpty) continue;
headersMap[key] = value;
}

var encoded = jsonEncode(headersMap);
store_keys.Store.put(store_keys.StoreKey.customHeaders, encoded);
}
}

class HeaderKeyValueSettings extends StatelessWidget {
final TextEditingController keyController;
final TextEditingController valueController;
final SettingsHeader header;
final Function() onRemove;

HeaderKeyValueSettings({
super.key,
required this.header,
required this.onRemove,
}) : keyController = TextEditingController(text: header.key),
valueController = TextEditingController(text: header.value);

String? emptyFieldValidator(String? value) {
if (value == null || value.isEmpty) {
return 'Value cannot be empty';
}

return null;
}

@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12.0),
child: Row(
children: [
Expanded(
child: TextFormField(
controller: keyController,
decoration: InputDecoration(
labelText: 'header_settings_header_name_input'.tr(),
border: const OutlineInputBorder(),
),
autocorrect: false,
onChanged: (headerKey) {
header.key = headerKey;
},
validator: emptyFieldValidator,
textInputAction: TextInputAction.next,
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: IconButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
color: Colors.red[400],
onPressed: onRemove,
icon: const Icon(Icons.delete_outline),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12.0),
child: TextFormField(
controller: valueController,
decoration: InputDecoration(
labelText: 'header_settings_header_name_input'.tr(),
border: const OutlineInputBorder(),
),
autocorrect: false,
onChanged: (headerValue) {
header.value = headerValue;
},
validator: emptyFieldValidator,
textInputAction: TextInputAction.done,
),
),
],
);
}
}
6 changes: 2 additions & 4 deletions mobile/lib/pages/search/person_result.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';

Expand Down Expand Up @@ -122,9 +122,7 @@ class PersonResultPage extends HookConsumerWidget {
radius: 36,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(personId),
headers: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
headers: ApiService.getRequestHeaders(),
),
),
Padding(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:video_player/video_player.dart';

Expand All @@ -26,11 +27,9 @@ Future<VideoPlayerController> videoPlayerController(
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';

final url = Uri.parse(videoUrl);
final accessToken = Store.get(StoreKey.accessToken);

controller = VideoPlayerController.networkUrl(
url,
httpHeaders: {"x-immich-user-token": accessToken},
httpHeaders: ApiService.getRequestHeaders(),
videoPlayerOptions: asset.livePhotoVideoId != null
? VideoPlayerOptions(mixWithOthers: true)
: VideoPlayerOptions(mixWithOthers: false),
Expand Down

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

6 changes: 2 additions & 4 deletions mobile/lib/providers/image/cache/image_loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';

/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
Expand All @@ -17,9 +17,7 @@ class ImageLoader {
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
}) async {
final headers = {
'x-immich-user-token': Store.get(StoreKey.accessToken),
};
final headers = ApiService.getRequestHeaders();

final stream = cache.getFileStream(
uri,
Expand Down
4 changes: 2 additions & 2 deletions mobile/lib/providers/websocket.provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:logging/logging.dart';
Expand Down Expand Up @@ -105,10 +106,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
final authenticationState = _ref.read(authenticationProvider);

if (authenticationState.isAuthenticated) {
final accessToken = Store.get(StoreKey.accessToken);
try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint));
final headers = {"x-immich-user-token": accessToken};
final headers = ApiService.getRequestHeaders();
if (endpoint.userInfo.isNotEmpty) {
headers["Authorization"] =
"Basic ${base64.encode(utf8.encode(endpoint.userInfo))}";
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/routing/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/app_log.page.dart';
import 'package:immich_mobile/pages/common/app_log_detail.page.dart';
import 'package:immich_mobile/pages/common/create_album.page.dart';
import 'package:immich_mobile/pages/common/gallery_viewer.page.dart';
import 'package:immich_mobile/pages/common/headers_settings.page.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/pages/common/tab_controller.page.dart';
Expand Down Expand Up @@ -222,6 +223,10 @@ class AppRouter extends _$AppRouter {
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.noTransition,
),
AutoRoute(
page: HeaderSettingsRoute.page,
guards: [_duplicateGuard],
),
];
}

Expand Down
20 changes: 20 additions & 0 deletions mobile/lib/routing/router.gr.dart

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

Loading
Loading