Skip to content

Commit

Permalink
feat(mobile): add additional request headers (#10588)
Browse files Browse the repository at this point in the history
* add additional request headers

* improve interface

* move headers under advanced settings

* refactor

* refactor

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
  • Loading branch information
matejkramny and alextran1502 authored Jun 26, 2024
1 parent a3c3619 commit 922430d
Show file tree
Hide file tree
Showing 23 changed files with 320 additions and 48 deletions.
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

0 comments on commit 922430d

Please sign in to comment.