From b53c40257e16fbc78871a4384b874f27eea71b10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Gapin=CC=81ski?= Date: Thu, 13 Nov 2025 19:34:28 +0100 Subject: [PATCH] WIP: Removal of android.html iframe Introduce Flutter based virtual display. Move touchscreen implementation to Flutter. Move browser gps implementation to flutter Compile to WebAssembly --- README.md | 6 +- jenkins/multi-branch-ci.groovy | 2 +- lib/common/di/ta_locator.config.dart | 33 +- lib/common/navigation/ta_page_factory.dart | 2 + .../network/base_websocket_transport.dart | 5 + lib/common/utils/logger.dart | 14 +- lib/feature/display/cubit/display_cubit.dart | 144 +++++--- lib/feature/display/cubit/display_state.dart | 9 +- .../display/model/remote_display_state.dart | 64 +--- .../display/model/remote_display_state.g.dart | 12 +- .../display/transport/display_transport.dart | 20 ++ .../display/utils/display_decoder.dart | 73 +++++ .../display/utils/h264_webcodecs_decoder.dart | 309 ++++++++++++++++++ .../display/utils/video_frame_to_image.dart | 22 ++ .../display/utils/webcodecs_bindings.dart | 93 ++++++ lib/feature/display/widget/display_view.dart | 50 ++- lib/feature/gps/cubit/gps_cubit.dart | 98 ++++++ lib/feature/gps/cubit/gps_state.dart | 30 ++ lib/feature/gps/model/gps_data.dart | 42 +++ lib/feature/gps/model/gps_data.g.dart | 25 ++ lib/feature/gps/transport/gps_transport.dart | 7 + lib/feature/gps/util/web_location.dart | 113 +++++++ lib/feature/gps/util/web_location_data.dart | 29 ++ lib/feature/home/home_page.dart | 26 +- .../bloc/display_configuration_cubit.dart | 8 +- .../bloc/display_configuration_state.dart | 4 +- .../settings/widget/display_settings.dart | 19 +- .../touchscreen/cubit/touchscreen_cubit.dart | 37 +-- .../model/virtual_touchscreen_command.dart | 2 +- lib/feature/touchscreen/touchscreen_view.dart | 81 ++--- .../transport/touchscreen_transport.dart | 39 +++ lib/main.dart | 36 ++ pubspec.yaml | 2 +- 33 files changed, 1209 insertions(+), 247 deletions(-) create mode 100644 lib/feature/display/transport/display_transport.dart create mode 100644 lib/feature/display/utils/display_decoder.dart create mode 100644 lib/feature/display/utils/h264_webcodecs_decoder.dart create mode 100644 lib/feature/display/utils/video_frame_to_image.dart create mode 100644 lib/feature/display/utils/webcodecs_bindings.dart create mode 100644 lib/feature/gps/cubit/gps_cubit.dart create mode 100644 lib/feature/gps/cubit/gps_state.dart create mode 100644 lib/feature/gps/model/gps_data.dart create mode 100644 lib/feature/gps/model/gps_data.g.dart create mode 100644 lib/feature/gps/transport/gps_transport.dart create mode 100644 lib/feature/gps/util/web_location.dart create mode 100644 lib/feature/gps/util/web_location_data.dart create mode 100644 lib/feature/touchscreen/transport/touchscreen_transport.dart diff --git a/README.md b/README.md index 43ee28c..d8c9da9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Please refer to https://teslaandroid.com for release notes, hardware requirement ## Getting Started ``` -flutter pub get -flutter packages pub run build_runner build --delete-conflicting-outputs -flutter build web +fvm flutter pub get +fvm flutter packages pub run build_runner build --delete-conflicting-outputs +fvm flutter build web --wasm ``` In order to build this project for debugging make sure to disable cors in Chrome and connect to Tesla Android Wi-Fi network diff --git a/jenkins/multi-branch-ci.groovy b/jenkins/multi-branch-ci.groovy index 40ce0db..ba95428 100644 --- a/jenkins/multi-branch-ci.groovy +++ b/jenkins/multi-branch-ci.groovy @@ -53,7 +53,7 @@ pipeline { script { SENTRY_RELEASE = 'flutter-app-ci-' + getCurrentBranch() + '-' + getCommitSha() } - sh('fvm flutter build web --no-web-resources-cdn') + sh('fvm flutter build web --no-web-resources-cdn --wasm') } } stage('Prepare artifacts') { diff --git a/lib/common/di/ta_locator.config.dart b/lib/common/di/ta_locator.config.dart index ef1b0b4..5cd00d9 100644 --- a/lib/common/di/ta_locator.config.dart +++ b/lib/common/di/ta_locator.config.dart @@ -29,6 +29,12 @@ import 'package:tesla_android/feature/connectivityCheck/cubit/connectivity_check import 'package:tesla_android/feature/display/cubit/display_cubit.dart' as _i14; import 'package:tesla_android/feature/display/repository/display_repository.dart' as _i271; +import 'package:tesla_android/feature/display/transport/display_transport.dart' + as _i802; +import 'package:tesla_android/feature/gps/cubit/gps_cubit.dart' as _i1072; +import 'package:tesla_android/feature/gps/transport/gps_transport.dart' + as _i774; +import 'package:tesla_android/feature/gps/util/web_location.dart' as _i469; import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart' as _i68; import 'package:tesla_android/feature/home/repository/github_release_repository.dart' as _i865; @@ -54,6 +60,8 @@ import 'package:tesla_android/feature/settings/repository/system_configuration_r as _i608; import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart' as _i680; +import 'package:tesla_android/feature/touchscreen/transport/touchscreen_transport.dart' + as _i303; extension GetItInjectableX on _i174.GetIt { // initializes the registration of main-scope dependencies inside of GetIt @@ -65,10 +73,13 @@ extension GetItInjectableX on _i174.GetIt { final appModule = _$AppModule(); final networkModule = _$NetworkModule(); gh.factory<_i557.TAPageFactory>(() => _i557.TAPageFactory()); - gh.factory<_i680.TouchscreenCubit>(() => _i680.TouchscreenCubit()); gh.factory<_i841.ReleaseNotesRepository>( () => _i841.ReleaseNotesRepository(), ); + gh.factory<_i802.DisplayTransport>(() => _i802.DisplayTransport()); + gh.factory<_i303.TouchScreenTransport>(() => _i303.TouchScreenTransport()); + gh.factory<_i774.GpsTransport>(() => _i774.GpsTransport()); + gh.factory<_i469.WebLocation>(() => _i469.WebLocation()); gh.singleton<_i544.Flavor>(() => appModule.provideFlavor); await gh.singletonAsync<_i460.SharedPreferences>( () => appModule.sharedPreferences, @@ -109,6 +120,16 @@ extension GetItInjectableX on _i174.GetIt { gh<_i723.DeviceInfoService>(), ), ); + gh.factory<_i1072.GpsCubit>( + () => _i1072.GpsCubit( + gh<_i469.WebLocation>(), + gh<_i774.GpsTransport>(), + gh<_i608.SystemConfigurationRepository>(), + ), + ); + gh.factory<_i680.TouchscreenCubit>( + () => _i680.TouchscreenCubit(gh<_i303.TouchScreenTransport>()), + ); gh.factory<_i68.OTAUpdateCubit>( () => _i68.OTAUpdateCubit( gh<_i865.GitHubReleaseRepository>(), @@ -146,13 +167,15 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i685.DisplayConfigurationCubit>( () => _i685.DisplayConfigurationCubit(gh<_i271.DisplayRepository>()), ); + gh.factory<_i14.DisplayCubit>( + () => _i14.DisplayCubit( + gh<_i271.DisplayRepository>(), + gh<_i802.DisplayTransport>(), + ), + ); gh.factory<_i1064.DeviceInfoCubit>( () => _i1064.DeviceInfoCubit(gh<_i708.DeviceInfoRepository>()), ); - gh.factory<_i14.DisplayCubit>( - () => - _i14.DisplayCubit(gh<_i271.DisplayRepository>(), gh<_i544.Flavor>()), - ); return this; } } diff --git a/lib/common/navigation/ta_page_factory.dart b/lib/common/navigation/ta_page_factory.dart index fc80f11..c9ce124 100644 --- a/lib/common/navigation/ta_page_factory.dart +++ b/lib/common/navigation/ta_page_factory.dart @@ -6,6 +6,7 @@ import 'package:tesla_android/common/navigation/ta_page.dart'; import 'package:tesla_android/feature/about/about_page.dart'; import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; import 'package:tesla_android/feature/donations/widget/donation_page.dart'; +import 'package:tesla_android/feature/gps/cubit/gps_cubit.dart'; import 'package:tesla_android/feature/home/cubit/ota_update_cubit.dart'; import 'package:tesla_android/feature/home/home_page.dart'; import 'package:tesla_android/feature/releaseNotes/cubit/release_notes_cubit.dart'; @@ -37,6 +38,7 @@ class TAPageFactory { BlocProvider(create: (_) => getIt()), BlocProvider(create: (_) => getIt()), BlocProvider(create: (_) => getIt()), + BlocProvider(create: (_) => getIt()), BlocProvider(create: (_) => getIt()), BlocProvider( create: (_) => getIt()..checkForUpdates(), diff --git a/lib/common/network/base_websocket_transport.dart b/lib/common/network/base_websocket_transport.dart index 8700c81..3cc03cf 100644 --- a/lib/common/network/base_websocket_transport.dart +++ b/lib/common/network/base_websocket_transport.dart @@ -38,6 +38,11 @@ abstract class BaseWebsocketTransport with Logger { _webSocketChannel = null; } + void reconnect() { + disconnect(); + connect(); + } + Future _connect() async { _webSocketChannel = WebSocket( Uri.parse(_flavor.getString( diff --git a/lib/common/utils/logger.dart b/lib/common/utils/logger.dart index e853328..26b9df1 100644 --- a/lib/common/utils/logger.dart +++ b/lib/common/utils/logger.dart @@ -1,10 +1,16 @@ +import 'package:flutter/foundation.dart'; + mixin Logger { void log(String message) { - print("[$runtimeType $hashCode] $message" ); + if (kDebugMode) { + print("[$runtimeType $hashCode] $message" ); + } } - void logException({exception, StackTrace? stackTrace}) { - print("[$runtimeType] ${exception.toString()}"); - print("[$runtimeType] ${stackTrace.toString()}"); + void logException({dynamic exception, StackTrace? stackTrace}) { + if (kDebugMode) { + print("[$runtimeType] ${exception.toString()}"); + print("[$runtimeType] ${stackTrace.toString()}"); + } } } diff --git a/lib/feature/display/cubit/display_cubit.dart b/lib/feature/display/cubit/display_cubit.dart index a4bc0bc..a6cc2cd 100644 --- a/lib/feature/display/cubit/display_cubit.dart +++ b/lib/feature/display/cubit/display_cubit.dart @@ -1,35 +1,44 @@ import 'dart:async'; import 'dart:math' as math; - import 'dart:ui' hide window; -import 'package:flavor/flavor.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:injectable/injectable.dart'; -import 'package:tesla_android/common/network/base_websocket_transport.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/display/cubit/display_state.dart'; import 'package:tesla_android/feature/display/model/remote_display_state.dart'; import 'package:tesla_android/feature/display/repository/display_repository.dart'; +import 'package:tesla_android/feature/display/transport/display_transport.dart'; +import 'package:tesla_android/feature/display/utils/display_decoder.dart'; @injectable class DisplayCubit extends Cubit with Logger { final DisplayRepository _repository; - final Flavor _flavor; + final DisplayTransport _transport; - DisplayCubit(this._repository, this._flavor) : super(DisplayStateInitial()); + final PublishSubject _frameSubject = PublishSubject(); - BaseWebsocketTransport? activeTransport; + Stream get frameStream => _frameSubject.stream; StreamSubscription? _transportStreamSubscription; Timer? _resizeCoolDownTimer; static const Duration _coolDownDuration = Duration(seconds: 1); + DisplayDecoder? _decoder; + + DisplayCubit(this._repository, this._transport) + : super(DisplayStateInitial()) { + _subscribeToVideoStream(); + } + @override Future close() { _resizeCoolDownTimer?.cancel(); + _transport.disconnect(); _transportStreamSubscription?.cancel(); + _decoder?.dispose(); + _frameSubject.close(); log("close"); return super.close(); } @@ -63,6 +72,18 @@ class DisplayCubit extends Cubit with Logger { _startResize(); } + void _subscribeToVideoStream() { + _transport.connect(); + _transportStreamSubscription ??= + _transport.videoData.listen((frame) { + final decoder = _decoder; + if (decoder == null) { + return; + } + decoder.handleMessage(frame); + }); + } + void _startResize({Size viewSize = Size.zero}) async { if (state is DisplayStateResizeCoolDown) { final currentState = state as DisplayStateResizeCoolDown; @@ -83,7 +104,6 @@ class DisplayCubit extends Cubit with Logger { final isRearDisplayEnabled = remoteState.isRearDisplayEnabled == 1; final resolutionPreset = remoteState.resolutionPreset; final isHeadless = (remoteState.isHeadless ?? 1) == 1; - final renderer = _getRenderer(remoteState); final isResponsive = remoteState.isResponsive == 1; final quality = remoteState.quality; final isH264 = remoteState.isH264 == 1; @@ -120,13 +140,17 @@ class DisplayCubit extends Cubit with Logger { _resizeCoolDownTimer?.cancel(); _resizeCoolDownTimer = null; if (!isClosed) { - emit( - DisplayStateNormal( - viewSize: viewSize, - adjustedSize: desiredSize, - rendererType: renderer, - ), + final normal = DisplayStateNormal( + viewSize: viewSize, + adjustedSize: desiredSize, + isH264: isH264, ); + emit(normal); + _initDecoderForNormalState( + normal, + isH264: isH264, + ); + _transport.reconnect(); } return; } @@ -136,14 +160,18 @@ class DisplayCubit extends Cubit with Logger { _resizeCoolDownTimer = null; desiredSize = remoteSize; if (!isClosed) { - emit( - DisplayStateNormal( - viewSize: viewSize, - adjustedSize: desiredSize, - rendererType: renderer, - ), + final normal = DisplayStateNormal( + viewSize: viewSize, + adjustedSize: desiredSize, + isH264: isH264, + ); + emit(normal); + _initDecoderForNormalState( + normal, + isH264: isH264, ); } + return; } } @@ -153,7 +181,6 @@ class DisplayCubit extends Cubit with Logger { viewSize: viewSize, adjustedSize: desiredSize, resolutionPreset: resolutionPreset, - rendererType: renderer, isH264: remoteState.isH264 == 1, isResponsive: remoteState.isResponsive == 1, quality: quality, @@ -173,8 +200,7 @@ class DisplayCubit extends Cubit with Logger { final viewSize = currentState.viewSize; final adjustedSize = currentState.adjustedSize; final resolutionPreset = currentState.resolutionPreset; - final renderer = currentState.rendererType; - final isH264 = renderer != DisplayRendererType.mjpeg; + final isH264 = currentState.isH264; final density = resolutionPreset.density(isH264: isH264); final quality = currentState.quality; final refreshRate = currentState.refreshRate; @@ -189,7 +215,6 @@ class DisplayCubit extends Cubit with Logger { height: adjustedSize.height.toInt(), density: density, resolutionPreset: resolutionPreset, - renderer: renderer, isResponsive: currentState.isResponsive ? 1 : 0, isH264: isH264 ? 1 : 0, quality: quality, @@ -200,12 +225,15 @@ class DisplayCubit extends Cubit with Logger { ); await Future.delayed(_coolDownDuration, () { if (isClosed) return; - emit( - DisplayStateNormal( - viewSize: viewSize, - adjustedSize: adjustedSize, - rendererType: renderer, - ), + final normal = DisplayStateNormal( + viewSize: viewSize, + adjustedSize: adjustedSize, + isH264: isH264, + ); + emit(normal); + _initDecoderForNormalState( + normal, + isH264: isH264, ); }); } catch (exception, stacktrace) { @@ -216,20 +244,44 @@ class DisplayCubit extends Cubit with Logger { } } - Future _getRemoteDisplayState() { - return _repository.getDisplayState(); + void _initDecoderForNormalState( + DisplayStateNormal normal, { + required bool isH264, + }) { + _decoder?.dispose(); + _decoder = null; + + if (!isH264) { + _decoder = JpegDisplayDecoder( + onImage: _frameSubject.add, + onError: (error, stackTrace) => + logException(exception: error, stackTrace: stackTrace), + ); + return; + } + + _decoder = H264DisplayDecoder( + codedWidth: normal.adjustedSize.width.toInt(), + codedHeight: normal.adjustedSize.height.toInt(), + displayWidth: normal.viewSize.width.toInt(), + displayHeight: normal.viewSize.height.toInt(), + onImage: _frameSubject.add, + onError: (error, stackTrace) => + logException(exception: error, stackTrace: stackTrace), + debug: false, + ); } - DisplayRendererType _getRenderer(RemoteDisplayState remoteDisplayState) { - return remoteDisplayState.renderer; + Future _getRemoteDisplayState() { + return _repository.getDisplayState(); } Size _calculateOptimalSize( - Size viewSize, { - required DisplayResolutionModePreset resolutionPreset, - required isH264, - required bool isHeadless, - }) { + Size viewSize, { + required DisplayResolutionModePreset resolutionPreset, + required isH264, + required bool isHeadless, + }) { if (!isHeadless) { return const Size(1024, 768); } @@ -246,19 +298,14 @@ class DisplayCubit extends Cubit with Logger { adjustedResolutionPreset = resolutionPreset; } - // Pi H.264 safe “coded” limits (macroblock-aligned) const double maxW = 1920.0; const double maxH = 1088.0; const double minSide = 320.0; - // Conservative alignment for Pi H.264: - // - width multiple of 64 (helps with stride/chroma alignment) - // - height multiple of 32 (more reliable than 16 on some builds) double alignUp(double v, int m) => ((v + (m - 1)) ~/ m * m).toDouble(); final double ar = viewSize.width / viewSize.height; - // Start from the incoming view size (bounded by encoder max) double w = viewSize.width.clamp(minSide, maxW); double h = w / ar; if (h > maxH) { @@ -266,7 +313,6 @@ class DisplayCubit extends Cubit with Logger { w = h * ar; } - // Cap by preset on SHORTEST side (e.g. 480/544/640/720/832). final double maxShortest = adjustedResolutionPreset.maxHeight(); final double shortest = math.min(w, h); if (shortest > maxShortest) { @@ -275,24 +321,20 @@ class DisplayCubit extends Cubit with Logger { h *= s; } - // Align UP to safe multiples. w = alignUp(w, 64); h = alignUp(h, 32); - // For small presets, avoid exact 480 rows — use 512 (common good coded height). if (h <= 480) { h = 512; - w = alignUp(h * ar, 64); // preserve AR as best as possible + w = alignUp(h * ar, 64); } - // Enforce minimums after alignment. if (w < minSide) w = alignUp(minSide, 64); if (h < minSide) h = alignUp(minSide, 32); - // Re-apply hard caps in case alignment pushed us over. w = math.min(w, maxW); h = math.min(h, maxH); return Size(w, h); } -} +} \ No newline at end of file diff --git a/lib/feature/display/cubit/display_state.dart b/lib/feature/display/cubit/display_state.dart index 29a0141..4e4935b 100644 --- a/lib/feature/display/cubit/display_state.dart +++ b/lib/feature/display/cubit/display_state.dart @@ -13,7 +13,6 @@ class DisplayStateResizeCoolDown extends DisplayState { final Size viewSize; final Size adjustedSize; final DisplayResolutionModePreset resolutionPreset; - final DisplayRendererType rendererType; final DateTime timestamp; final bool isH264; final bool isResponsive; @@ -27,7 +26,6 @@ class DisplayStateResizeCoolDown extends DisplayState { required this.viewSize, required this.adjustedSize, required this.resolutionPreset, - required this.rendererType, required this.isH264, required this.isResponsive, required this.refreshRate, @@ -43,7 +41,6 @@ class DisplayStateResizeCoolDown extends DisplayState { adjustedSize, timestamp, resolutionPreset, - rendererType, isResponsive, isH264, refreshRate, @@ -65,14 +62,14 @@ class DisplayStateResizeInProgress extends DisplayState { class DisplayStateNormal extends DisplayState { final Size viewSize; final Size adjustedSize; - final DisplayRendererType rendererType; + final bool isH264; DisplayStateNormal({ required this.viewSize, required this.adjustedSize, - required this.rendererType, + required this.isH264, }); @override - List get props => [viewSize, adjustedSize, rendererType]; + List get props => [viewSize, adjustedSize, isH264]; } diff --git a/lib/feature/display/model/remote_display_state.dart b/lib/feature/display/model/remote_display_state.dart index fd4d3ff..4309291 100644 --- a/lib/feature/display/model/remote_display_state.dart +++ b/lib/feature/display/model/remote_display_state.dart @@ -10,8 +10,9 @@ class RemoteDisplayState extends Equatable { final int density; @JsonKey(name: "resolutionPreset") final DisplayResolutionModePreset resolutionPreset; - @JsonKey(defaultValue: DisplayRendererType.mjpeg) - final DisplayRendererType renderer; + @Deprecated("Not used, switch to isH264 instead") + @JsonKey(defaultValue: -1) + final int renderer; final int? isHeadless; @JsonKey(defaultValue: 1) final int isResponsive; @@ -31,7 +32,6 @@ class RemoteDisplayState extends Equatable { required this.height, required this.density, required this.resolutionPreset, - required this.renderer, required this.isResponsive, required this.isH264, required this.refreshRate, @@ -39,6 +39,7 @@ class RemoteDisplayState extends Equatable { required this.isRearDisplayEnabled, required this.isRearDisplayPrioritised, this.isHeadless, + this.renderer = -1, }); factory RemoteDisplayState.fromJson(Map json) => @@ -71,10 +72,9 @@ class RemoteDisplayState extends Equatable { ); } - RemoteDisplayState updateRenderer({required DisplayRendererType newType}) { + RemoteDisplayState updateRenderer({required bool isH264}) { return copyWith( - renderer: newType, - isH264: newType != DisplayRendererType.mjpeg ? 1 : 0, + isH264: isH264 ? 1 : 0, ); } @@ -94,7 +94,7 @@ class RemoteDisplayState extends Equatable { int? density, int? isHeadless, DisplayResolutionModePreset? resolutionPreset, - DisplayRendererType? renderer, + int? renderer, int? isH264, int? isResponsive, DisplayRefreshRatePreset? refreshRate, @@ -266,52 +266,4 @@ enum DisplayResolutionModePreset { return "832p"; } } -} - -enum DisplayRendererType { - @JsonValue(0) - mjpeg, - @JsonValue(1) - h264WebCodecs, - @JsonValue(2) - h264Brodway; - - String name() { - switch (index) { - case 0: - return "Motion JPEG"; - case 1: - return "h264 (WebCodecs)"; - case 2: - return "h264 (legacy)"; - default: - return "mjpeg"; - } - } - - String resourcePath() { - switch (index) { - case 0: - return "mjpeg"; - case 1: - return "h264WebCodecs"; - case 2: - return "h264Brodway"; - default: - return "mjpeg"; - } - } - - String binaryType() { - switch (index) { - case 0: - return "blob"; - case 1: - return "arraybuffer"; - case 2: - return "arraybuffer"; - default: - return "blob"; - } - } -} +} \ No newline at end of file diff --git a/lib/feature/display/model/remote_display_state.g.dart b/lib/feature/display/model/remote_display_state.g.dart index 6ab727a..ffd3a0e 100644 --- a/lib/feature/display/model/remote_display_state.g.dart +++ b/lib/feature/display/model/remote_display_state.g.dart @@ -15,9 +15,6 @@ RemoteDisplayState _$RemoteDisplayStateFromJson(Map json) => _$DisplayResolutionModePresetEnumMap, json['resolutionPreset'], ), - renderer: - $enumDecodeNullable(_$DisplayRendererTypeEnumMap, json['renderer']) ?? - DisplayRendererType.mjpeg, isResponsive: (json['isResponsive'] as num?)?.toInt() ?? 1, isH264: (json['isH264'] as num?)?.toInt() ?? 0, refreshRate: @@ -34,6 +31,7 @@ RemoteDisplayState _$RemoteDisplayStateFromJson(Map json) => isRearDisplayPrioritised: (json['isRearDisplayPrioritised'] as num?)?.toInt() ?? 0, isHeadless: (json['isHeadless'] as num?)?.toInt(), + renderer: (json['renderer'] as num?)?.toInt() ?? -1, ); Map _$RemoteDisplayStateToJson(RemoteDisplayState instance) => @@ -43,7 +41,7 @@ Map _$RemoteDisplayStateToJson(RemoteDisplayState instance) => 'density': instance.density, 'resolutionPreset': _$DisplayResolutionModePresetEnumMap[instance.resolutionPreset]!, - 'renderer': _$DisplayRendererTypeEnumMap[instance.renderer]!, + 'renderer': instance.renderer, 'isHeadless': instance.isHeadless, 'isResponsive': instance.isResponsive, 'isH264': instance.isH264, @@ -61,12 +59,6 @@ const _$DisplayResolutionModePresetEnumMap = { DisplayResolutionModePreset.res480p: 4, }; -const _$DisplayRendererTypeEnumMap = { - DisplayRendererType.mjpeg: 0, - DisplayRendererType.h264WebCodecs: 1, - DisplayRendererType.h264Brodway: 2, -}; - const _$DisplayRefreshRatePresetEnumMap = { DisplayRefreshRatePreset.refresh30hz: 30, DisplayRefreshRatePreset.refresh45hz: 45, diff --git a/lib/feature/display/transport/display_transport.dart b/lib/feature/display/transport/display_transport.dart new file mode 100644 index 0000000..eb95754 --- /dev/null +++ b/lib/feature/display/transport/display_transport.dart @@ -0,0 +1,20 @@ +import 'package:injectable/injectable.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:tesla_android/common/network/base_websocket_transport.dart'; + +@injectable +class DisplayTransport extends BaseWebsocketTransport { + final BehaviorSubject videoData = BehaviorSubject(); + + DisplayTransport() + : super( + flavorUrlKey: "displayWebSocket", + binaryType: "arraybuffer", + ); + + @override + void onMessage(event) { + videoData.add(event); + super.onMessage(event); + } +} \ No newline at end of file diff --git a/lib/feature/display/utils/display_decoder.dart b/lib/feature/display/utils/display_decoder.dart new file mode 100644 index 0000000..c285592 --- /dev/null +++ b/lib/feature/display/utils/display_decoder.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'h264_webcodecs_decoder.dart'; +import 'video_frame_to_image.dart'; + +abstract class DisplayDecoder { + FutureOr handleMessage(Uint8List frame); + void dispose(); +} + +class H264DisplayDecoder implements DisplayDecoder { + final H264WebCodecsDecoder _inner; + + H264DisplayDecoder({ + required int codedWidth, + required int codedHeight, + required int displayWidth, + required int displayHeight, + required void Function(Image image) onImage, + required void Function(Object error, StackTrace stackTrace) onError, + bool debug = false, + }) : _inner = H264WebCodecsDecoder( + codedWidth: codedWidth, + codedHeight: codedHeight, + displayWidth: displayWidth, + displayHeight: displayHeight, + debug: debug, + onFrame: (videoFrame) async { + try { + final image = await videoFrameToUiImage(videoFrame); + onImage(image); + } catch (e, st) { + onError(e, st); + } + }, + ); + + @override + void dispose() => _inner.dispose(); + + @override + void handleMessage(Uint8List frame) { + _inner.handleMessage(frame); + } +} + +/// MJPEG decoder that takes full JPEG frames as Uint8List and converts to Image. +class JpegDisplayDecoder implements DisplayDecoder { + final void Function(Image image) _onImage; + final void Function(Object error, StackTrace stackTrace) _onError; + + JpegDisplayDecoder({ + required void Function(Image image) onImage, + required void Function(Object error, StackTrace stackTrace) onError, + }) : _onImage = onImage, + _onError = onError; + + @override + void dispose() {} + + @override + Future handleMessage(Uint8List frame) async { + try { + final codec = await instantiateImageCodec(frame); + final fi = await codec.getNextFrame(); + _onImage(fi.image); + } catch (e, st) { + _onError(e, st); + } + } +} \ No newline at end of file diff --git a/lib/feature/display/utils/h264_webcodecs_decoder.dart b/lib/feature/display/utils/h264_webcodecs_decoder.dart new file mode 100644 index 0000000..58e0bfb --- /dev/null +++ b/lib/feature/display/utils/h264_webcodecs_decoder.dart @@ -0,0 +1,309 @@ +import 'dart:typed_data'; +import 'dart:js_interop'; + +import 'package:tesla_android/common/utils/logger.dart'; + +import 'webcodecs_bindings.dart'; + +typedef FrameCallback = void Function(VideoFrame frame); +typedef ErrorCallback = void Function(JSAny? error); + +class _PendingChunk { + final String type; // 'key' | 'delta' + _PendingChunk(this.type); +} + +class H264WebCodecsDecoder with Logger { + final int codedWidth; + final int codedHeight; + final int displayWidth; + final int displayHeight; + final int maxBufferSize; + final bool debug; + + final FrameCallback onFrame; + final ErrorCallback? onError; + + late final VideoDecoder _decoder; + Uint8List? _sps; + + final List<_PendingChunk> _pendingChunks = []; + + int skippedFrames = 0; + int _messageCount = 0; + int _nalSplitCount = 0; + + H264WebCodecsDecoder({ + required this.codedWidth, + required this.codedHeight, + required this.displayWidth, + required this.displayHeight, + required this.onFrame, + this.onError, + this.maxBufferSize = 60, + this.debug = false, + }) { + _log('Ctor: codedWidth=$codedWidth codedHeight=$codedHeight ' + 'displayWidth=$displayWidth displayHeight=$displayHeight ' + 'maxBufferSize=$maxBufferSize'); + + _decoder = VideoDecoder( + VideoDecoderInit( + output: _onOutputJS.toJS, + error: _onErrorJS.toJS, + ), + ); + + _log('Decoder created, initial state="${_decoder.state}"'); + } + + void _log(String msg) { + if (!debug) return; + log('[H264WebCodecsDecoder] $msg'); + } + + void handleMessage(Uint8List dat) { + _messageCount++; + _log('handleMessage #$_messageCount: len=${dat.length}, ' + 'firstBytes=${_hexPrefix(dat, 8)}'); + + if (dat.length < 5) { + _log('handleMessage: len < 5 → dropping packet'); + return; + } + + final unittype = dat[4] & 0x1f; + _log('handleMessage: unittype=$unittype'); + + if (unittype == 1 || unittype == 5) { + _log('handleMessage: slice NAL (type=$unittype) → _videoMagic'); + _videoMagic(dat); + } else { + _log('handleMessage: non-slice NAL, calling _separateNalUnits + _headerMagic'); + final nals = _separateNalUnits(dat); + _log('handleMessage: _separateNalUnits returned ${nals.length} NAL(s)'); + for (final nal in nals) { + if (nal.length < 5) { + _log('headerMagic: nal.len < 5 → skip'); + continue; + } + final t = nal[4] & 0x1f; + _log('headerMagic: processing NAL len=${nal.length}, type=$t'); + _headerMagic(nal); + } + } + + _trimPendingFramesBufferIfNeeded(); + } + + void dispose() { + _log('dispose(): clearing pending metadata and closing decoder ' + 'state="${_decoder.state}"'); + _pendingChunks.clear(); + if (_decoder.state != 'closed') { + _decoder.close(); + } + } + + void _videoMagic(Uint8List dat) { + if (dat.length < 5) { + _log('_videoMagic: dat.len < 5 → dropping'); + return; + } + final unittype = dat[4] & 0x1f; + + String? type; + Uint8List data; + + if (unittype == 1) { + type = 'delta'; + data = dat; + _log('_videoMagic: delta frame, len=${data.length}'); + } else if (unittype == 5) { + if (_sps == null) { + _log('_videoMagic: key frame but _sps == null → cannot prepend SPS, dropping'); + return; + } + type = 'key'; + data = _appendByteArray(_sps!, dat); + _log('_videoMagic: key frame, originalLen=${dat.length}, spsLen=${_sps!.length}, ' + 'combinedLen=${data.length}'); + } else { + _log('_videoMagic: unittype=$unittype not 1/5 → ignoring'); + return; + } + + final decoderState = _decoder.state; + _log('_videoMagic: decoder.state="$decoderState"'); + + if (decoderState == 'configured') { + final chunkInit = EncodedVideoChunkInit( + type: type, + timestamp: 0, + duration: 0, + data: data.toJS, + ); + final chunk = EncodedVideoChunk(chunkInit); + + _pendingChunks.add(_PendingChunk(type)); + _log('_videoMagic: added chunk type=$type, pendingChunks=${_pendingChunks.length}'); + + _trimPendingFramesBufferIfNeeded(); + + _log('_videoMagic: calling decode()'); + _decoder.decode(chunk); + } else { + _log('_videoMagic: decoder NOT configured, cannot decode'); + onError?.call('Decoder not configured when trying to decode frame'.toJS); + } + } + + void _headerMagic(Uint8List dat) { + if (dat.length < 5) { + _log('_headerMagic: len < 5 → dropping'); + return; + } + final unittype = dat[4] & 0x1f; + + if (unittype == 7) { + _log('_headerMagic: SPS NAL detected (type=7), len=${dat.length}'); + final config = _buildConfigFromSps(dat); + _sps = dat; + _log('_headerMagic: configuring decoder with codec="${config.codec}" ' + 'codedWidth=$codedWidth codedHeight=$codedHeight'); + _decoder.configure(config); + _log('_headerMagic: decoder.state after configure="${_decoder.state}"'); + } else if (unittype == 8) { + _log('_headerMagic: PPS NAL detected (type=8), len=${dat.length}'); + if (_sps != null) { + _sps = _appendByteArray(_sps!, dat); + _log('_headerMagic: appended PPS to SPS, new spsLen=${_sps!.length}'); + } else { + _sps = dat; + _log('_headerMagic: PPS without previous SPS, storing as _sps len=${_sps!.length}'); + } + } else { + _log('_headerMagic: other NAL type=$unittype → _videoMagic'); + _videoMagic(dat); + } + } + + VideoDecoderConfig _buildConfigFromSps(Uint8List dat) { + final sb = StringBuffer('avc1.'); + for (var i = 5; i < 8 && i < dat.length; i++) { + var h = dat[i].toRadixString(16); + if (h.length < 2) h = '0$h'; + sb.write(h); + } + final codec = sb.toString(); + _log('_buildConfigFromSps: built codec="$codec" from SPS'); + + return VideoDecoderConfig( + codec: codec, + codedHeight: codedHeight, + codedWidth: codedWidth, + displayAspectWidth: displayWidth, + displayAspectHeight: displayHeight, + hardwareAcceleration: 'prefer-hardware', + optimizeForLatency: true, + ); + } + + void _onOutputJS(VideoFrame frame) { + _handleOutputFrame(frame); + } + + void _onErrorJS(JSAny? error) { + _handleError(error); + } + + void _handleOutputFrame(VideoFrame frame) { + _log('_handleOutputFrame: got frame ' + 'codedWidth=${frame.codedWidth}, codedHeight=${frame.codedHeight}, ' + 'format=${frame.format}'); + onFrame(frame); + } + + void _handleError(JSAny? error) { + _log('_handleError: $error'); + onError?.call(error); + } + + void _trimPendingFramesBufferIfNeeded() { + if (_pendingChunks.length <= maxBufferSize) return; + + _log('_trimPendingFramesBufferIfNeeded: pendingChunks=${_pendingChunks.length} ' + 'maxBufferSize=$maxBufferSize → trimming'); + + var foundIframe = false; + while (!foundIframe && _pendingChunks.isNotEmpty) { + final p = _pendingChunks.removeAt(0); + + _log('_trimPendingFramesBufferIfNeeded: popped chunk type=${p.type}'); + + if (p.type == 'key') { + foundIframe = true; + _pendingChunks.insert(0, p); + _log('_trimPendingFramesBufferIfNeeded: found keyframe, putting it back at head'); + } else { + skippedFrames++; + _log('_trimPendingFramesBufferIfNeeded: dropped non-keyframe, ' + 'skippedFrames=$skippedFrames'); + } + } + + _log('_trimPendingFramesBufferIfNeeded: done, pendingChunks=${_pendingChunks.length}'); + } + + Uint8List _appendByteArray(Uint8List a, Uint8List b) { + final out = Uint8List(a.length + b.length); + out.setRange(0, a.length, a); + out.setRange(a.length, a.length + b.length, b); + return out; + } + + List _separateNalUnits(Uint8List data) { + final result = []; + int? startIndex; + + bool isStartCode(int i) { + if (i + 3 >= data.length) return false; + return data[i] == 0 && + data[i + 1] == 0 && + data[i + 2] == 0 && + data[i + 3] == 1; + } + + for (var i = 0; i < data.length; i++) { + if (isStartCode(i)) { + if (startIndex != null) { + result.add(Uint8List.sublistView(data, startIndex, i)); + _nalSplitCount++; + } + startIndex = i; + } + } + + if (startIndex != null && startIndex < data.length) { + result.add(Uint8List.sublistView(data, startIndex)); + _nalSplitCount++; + } + + _log('_separateNalUnits: dataLen=${data.length}, ' + 'foundNALs=${result.length}, totalNALsSeen=$_nalSplitCount'); + + return result; + } + + String _hexPrefix(Uint8List data, int maxBytes) { + final len = data.length < maxBytes ? data.length : maxBytes; + final sb = StringBuffer(); + for (var i = 0; i < len; i++) { + var h = data[i].toRadixString(16); + if (h.length < 2) h = '0$h'; + if (i > 0) sb.write(' '); + sb.write(h); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/lib/feature/display/utils/video_frame_to_image.dart b/lib/feature/display/utils/video_frame_to_image.dart new file mode 100644 index 0000000..80710f4 --- /dev/null +++ b/lib/feature/display/utils/video_frame_to_image.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'dart:ui_web' as ui_web; +import 'dart:js_interop'; + +import 'webcodecs_bindings.dart'; + +FutureOr videoFrameToUiImage(VideoFrame frame) { + final w = frame.codedWidth ?? 0; + final h = frame.codedHeight ?? 0; + + if (w == 0 || h == 0) { + throw StateError('VideoFrame has no coded size'); + } + + return ui_web.createImageFromTextureSource( + frame as JSAny, + width: w, + height: h, + transferOwnership: true, + ); +} \ No newline at end of file diff --git a/lib/feature/display/utils/webcodecs_bindings.dart b/lib/feature/display/utils/webcodecs_bindings.dart new file mode 100644 index 0000000..1ed2400 --- /dev/null +++ b/lib/feature/display/utils/webcodecs_bindings.dart @@ -0,0 +1,93 @@ +@JS() +library; + +import 'dart:js_interop'; + +@JS() +extension type VideoFrameCopyToOptions._(JSObject _) implements JSObject { + external factory VideoFrameCopyToOptions({ + String? format, // e.g. 'RGBA' + }); + + external String? get format; +} + +@JS('VideoFrame') +extension type VideoFrame._(JSObject _) implements JSObject { + external void close(); + + external int? get codedWidth; + external int? get codedHeight; + external String? get format; + + external JSPromise copyTo( + JSUint8Array destination, + VideoFrameCopyToOptions options, + ); +} + +@JS() +extension type EncodedVideoChunkInit._(JSObject _) implements JSObject { + external factory EncodedVideoChunkInit({ + required String type, // 'key' | 'delta' + required int timestamp, + int? duration, + required JSUint8Array data, // Uint8Array / BufferSource + }); + + external String get type; + external int get timestamp; + external int? get duration; + external JSUint8Array get data; +} + +@JS('EncodedVideoChunk') +extension type EncodedVideoChunk._(JSObject _) implements JSObject { + external factory EncodedVideoChunk(EncodedVideoChunkInit init); + + external void close(); + external String get type; +} + +@JS() +extension type VideoDecoderInit._(JSObject _) implements JSObject { + external factory VideoDecoderInit({ + required JSFunction output, + required JSFunction error, + }); + + external JSFunction get output; + external JSFunction get error; +} + +@JS() +extension type VideoDecoderConfig._(JSObject _) implements JSObject { + external factory VideoDecoderConfig({ + required String codec, // e.g. 'avc1.42e01e' + int? codedWidth, + int? codedHeight, + int? displayAspectWidth, + int? displayAspectHeight, + String? hardwareAcceleration, // 'prefer-hardware' | ... + bool? optimizeForLatency, + }); + + external String get codec; + external int? get codedWidth; + external int? get codedHeight; + external int? get displayAspectWidth; + external int? get displayAspectHeight; + external String? get hardwareAcceleration; + external bool? get optimizeForLatency; +} + +@JS('VideoDecoder') +extension type VideoDecoder._(JSObject _) implements JSObject { + external factory VideoDecoder(VideoDecoderInit init); + + external void configure(VideoDecoderConfig config); + external void decode(EncodedVideoChunk chunk); + external JSPromise flush(); + external void close(); + external String get state; // 'unconfigured' | 'configured' | 'closed' +} diff --git a/lib/feature/display/widget/display_view.dart b/lib/feature/display/widget/display_view.dart index 06e757e..5a3c6e3 100644 --- a/lib/feature/display/widget/display_view.dart +++ b/lib/feature/display/widget/display_view.dart @@ -1,30 +1,56 @@ import 'dart:convert'; import 'dart:js_interop'; -import 'dart:ui_web' as ui; import 'package:flavor/flavor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tesla_android/common/di/ta_locator.dart'; -import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/display/cubit/display_cubit.dart'; -import 'package:tesla_android/feature/display/cubit/display_state.dart'; -import 'package:tesla_android/feature/display/model/remote_display_state.dart'; +import 'package:tesla_android/feature/gps/cubit/gps_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/audio_configuration_cubit.dart'; import 'package:tesla_android/feature/settings/bloc/audio_configuration_state.dart'; -import 'package:tesla_android/feature/settings/bloc/gps_configuration_cubit.dart'; -import 'package:tesla_android/feature/settings/bloc/gps_configuration_state.dart'; import 'package:web/web.dart' as web; +import 'dart:ui' as ui hide window; -class DisplayView extends StatefulWidget { - final DisplayRendererType type; - - const DisplayView({super.key, required this.type}); +class DisplayView extends StatelessWidget { + const DisplayView({super.key}); @override - State createState() => _IframeViewState(); + Widget build(BuildContext context) { + final flavor = getIt(); + context.read().fetchConfiguration(); + + return BlocBuilder( + builder: (context, audioState) { + if (audioState is AudioConfigurationStateInitial) { + final audioCubit = context.read(); + audioCubit.fetchConfiguration(); + } + if (audioState is AudioConfigurationStateSettingsFetched) { + final config = { + 'audioWebsocketUrl': flavor.getString("audioWebSocket") ?? "", + 'isAudioEnabled': (audioState.isEnabled).toString(), + 'audioVolume': ((audioState.volume / 100)).toStringAsFixed(2), + }; + final cfgJson = jsonEncode(config).toJS; + web.window.postMessage(cfgJson, '*'.toJS); + } + return StreamBuilder( + stream: context.read().frameStream, + builder: (context, snapshot) { + final img = snapshot.data; + if (img == null) return const SizedBox.shrink(); + + return RawImage(image: img, fit: BoxFit.contain); + }, + ); + }, + ); + ; + } } +/* class _IframeViewState extends State with Logger { static const String _src = "/android.html"; @@ -126,4 +152,4 @@ class _IframeViewState extends State web.window.postMessage(cfgJson, '*'.toJS); _iframeElement.contentWindow?.postMessage(cfgJson, '*'.toJS); } -} \ No newline at end of file +}*/ diff --git a/lib/feature/gps/cubit/gps_cubit.dart b/lib/feature/gps/cubit/gps_cubit.dart new file mode 100644 index 0000000..6e559f7 --- /dev/null +++ b/lib/feature/gps/cubit/gps_cubit.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import 'package:tesla_android/common/utils/logger.dart'; +import 'package:tesla_android/feature/gps/cubit/gps_state.dart'; +import 'package:tesla_android/feature/gps/model/gps_data.dart'; +import 'package:tesla_android/feature/gps/transport/gps_transport.dart'; +import 'package:tesla_android/feature/gps/util/web_location.dart'; +import 'package:tesla_android/feature/settings/repository/system_configuration_repository.dart'; + +@injectable +class GpsCubit extends Cubit with Logger { + final WebLocation _location; + final GpsTransport _gpsTransport; + final SystemConfigurationRepository _configurationRepository; + + StreamSubscription? _locationUpdatesStreamSubscription; + + GpsCubit(this._location, this._gpsTransport, this._configurationRepository) : super(GpsStateInitial()); + + @override + Future close() { + log("close"); + _gpsTransport.disconnect(); + _locationUpdatesStreamSubscription?.cancel(); + _locationUpdatesStreamSubscription = null; + return super.close(); + } + + void fetchConfiguration() async { + final configuration = await _configurationRepository.getConfiguration(); + final isEnabled = configuration.isGPSEnabled == 1; + if (isEnabled) { + enableGps(); + } else { + emit(GpsStateManuallyDisabled()); + } + } + + Future enableGps() async { + if (state is GpsStateInitial || state is GpsStateInitialisationError) { + try { + await _checkGpsPermission().timeout(const Duration(seconds: 120)); + } catch (exception, stacktrace) { + if (!isClosed) emit(GpsStateInitialisationError()); + if (exception is TimeoutException) { + log("Timed out waiting for GPS init"); + } else { + logException(exception: exception, stackTrace: stacktrace); + } + } + } else { + log("GPS already activated"); + } + } + + Future _checkGpsPermission() async { + final gpsPermissionStatus = await _location.getPermissionStatus(); + if (gpsPermissionStatus == false) { + var gpsPermissionRequested = await _location.requestPermission(); + gpsPermissionRequested = await _location.getPermissionStatus(); + if (gpsPermissionRequested) { + _onLocationPermissionGranted(); + return; + } + if (!isClosed) emit(GpsStatePermissionNotGranted()); + return; + } + _onLocationPermissionGranted(); + } + + void _onLocationPermissionGranted() { + _gpsTransport.connect(); + _emitCurrentLocation(); + _subscribeToLocationUpdates(); + return; + } + + void _emitCurrentLocation() async { + try { + final location = await _location.getLocation(); + if (!isClosed) emit(GpsStateActive()); + _gpsTransport.sendJson(GpsData.fromLocationData(location)); + } catch (exception, stacktrace) { + logException(exception: exception, stackTrace: stacktrace); + } + } + + void _subscribeToLocationUpdates() async { + _locationUpdatesStreamSubscription = _location.locationStream.listen(( + locationData, + ) async { + if (!isClosed) emit(GpsStateActive()); + _gpsTransport.sendJson(GpsData.fromLocationData(locationData)); + }); + } +} diff --git a/lib/feature/gps/cubit/gps_state.dart b/lib/feature/gps/cubit/gps_state.dart new file mode 100644 index 0000000..698034c --- /dev/null +++ b/lib/feature/gps/cubit/gps_state.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; +import 'package:tesla_android/feature/gps/util/web_location_data.dart'; + +abstract class GpsState extends Equatable { + const GpsState(); + + @override + List get props => []; +} + +class GpsStateInitial extends GpsState {} + +class GpsStateInitialisationError extends GpsState {} + +class GpsStatePermissionNotGranted extends GpsState {} + +class GpsStateManuallyDisabled extends GpsState {} + +class GpsStateActive extends GpsState {} + +WebLocationData get initialLocationData { + return WebLocationData( + latitude: 53.4289, + longitude: 14.5530, + accuracy: 0.0, + speed: 0.0, + heading: 0.0, + time: 0, + ); +} diff --git a/lib/feature/gps/model/gps_data.dart b/lib/feature/gps/model/gps_data.dart new file mode 100644 index 0000000..b3c656d --- /dev/null +++ b/lib/feature/gps/model/gps_data.dart @@ -0,0 +1,42 @@ +import 'dart:core'; + +import 'package:json_annotation/json_annotation.dart'; +import 'package:tesla_android/feature/gps/util/web_location_data.dart'; + +part 'gps_data.g.dart'; + +@JsonSerializable() +class GpsData { + final String latitude; + final String longitude; + final String speed; + final String bearing; + @JsonKey(name: "vertical_accuracy") + final String verticalAccuracy; + final String timestamp; + + const GpsData({ + required this.latitude, + required this.longitude, + required this.speed, + required this.bearing, + required this.verticalAccuracy, + required this.timestamp, + }); + + factory GpsData.fromJson(Map json) => + _$GpsDataFromJson(json); + + factory GpsData.fromLocationData(WebLocationData locationData) { + return GpsData( + latitude: locationData.latitude.toString(), + longitude: locationData.longitude.toString(), + speed: locationData.speed.toString(), + bearing: locationData.speed.toString(), + verticalAccuracy: locationData.accuracy.toString(), + timestamp: locationData.time.toString(), + ); + } + + Map toJson() => _$GpsDataToJson(this); +} \ No newline at end of file diff --git a/lib/feature/gps/model/gps_data.g.dart b/lib/feature/gps/model/gps_data.g.dart new file mode 100644 index 0000000..c9f98bf --- /dev/null +++ b/lib/feature/gps/model/gps_data.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gps_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GpsData _$GpsDataFromJson(Map json) => GpsData( + latitude: json['latitude'] as String, + longitude: json['longitude'] as String, + speed: json['speed'] as String, + bearing: json['bearing'] as String, + verticalAccuracy: json['vertical_accuracy'] as String, + timestamp: json['timestamp'] as String, +); + +Map _$GpsDataToJson(GpsData instance) => { + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'speed': instance.speed, + 'bearing': instance.bearing, + 'vertical_accuracy': instance.verticalAccuracy, + 'timestamp': instance.timestamp, +}; diff --git a/lib/feature/gps/transport/gps_transport.dart b/lib/feature/gps/transport/gps_transport.dart new file mode 100644 index 0000000..44774eb --- /dev/null +++ b/lib/feature/gps/transport/gps_transport.dart @@ -0,0 +1,7 @@ +import 'package:injectable/injectable.dart'; +import 'package:tesla_android/common/network/base_websocket_transport.dart'; + +@injectable +class GpsTransport extends BaseWebsocketTransport { + GpsTransport() : super(flavorUrlKey: "gpsWebSocket"); +} \ No newline at end of file diff --git a/lib/feature/gps/util/web_location.dart b/lib/feature/gps/util/web_location.dart new file mode 100644 index 0000000..9c8e49c --- /dev/null +++ b/lib/feature/gps/util/web_location.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:injectable/injectable.dart'; +import 'package:tesla_android/common/utils/logger.dart'; +import 'package:tesla_android/feature/gps/util/web_location_data.dart'; +import 'package:web/web.dart'; + +@injectable +class WebLocation with Logger { + final Geolocation _geolocation; + final Permissions _permissions; + + StreamController? _locationStreamController; + Timer? _locationTimer; + + WebLocation() + : _geolocation = window.navigator.geolocation, + _permissions = window.navigator.permissions; + + Future getLocation() { + final completer = Completer(); + _geolocation.getCurrentPosition( + (GeolocationPosition result) { + completer.complete(result); + }.toJS, + () { + completer.completeError(Exception('location error')); + }.toJS, + PositionOptions(), + ); + + return completer.future.then(_toLocationData); + } + + Future getPermissionStatus() async { + PermissionStatus result = await _permissions + .query(_PermissionDescriptor(name: 'geolocation')) + .toDart; + + switch (result.state) { + case 'granted': + return true; + default: + return false; + } + } + + Future requestPermission() async { + try { + final completer = Completer(); + _geolocation.getCurrentPosition( + (GeolocationPosition result) { + completer.complete(result); + }.toJS, + () { + completer.completeError(Exception('location error')); + }.toJS, + PositionOptions(), + ); + await completer.future; + return true; + } catch (exception, stackTrace) { + logException(exception: exception, stackTrace: stackTrace); + return false; + } + } + + Stream get locationStream { + _locationStreamController ??= StreamController.broadcast( + onListen: _startLocationUpdates, + onCancel: _stopLocationUpdates, + ); + return _locationStreamController!.stream; + } + + void _startLocationUpdates() { + _locationTimer ??= Timer.periodic( + const Duration(milliseconds: 1000), + (Timer t) => getLocation() + .then((locationData) { + _locationStreamController!.add(locationData); + }) + .catchError((exception, stackTrace) { + logException(exception: exception, stackTrace: stackTrace); + }), + ); + } + + void _stopLocationUpdates() { + _locationTimer?.cancel(); + _locationTimer = null; + } + + WebLocationData _toLocationData(GeolocationPosition result) { + return WebLocationData( + latitude: result.coords.latitude, + longitude: result.coords.longitude, + accuracy: result.coords.accuracy, + speed: result.coords.speed ?? 0.0, + heading: result.coords.heading ?? 0.0, + time: result.timestamp, + ); + } +} + +// copied from https://github.com/dart-lang/web/commit/7604578eb538c471d438608673c037121d95dba5#diff-6f4c7956b6e25b547b16fc561e54d5e7d520d2c79a59ace4438c60913cc2b1a2L35-L40 +extension type _PermissionDescriptor._(JSObject _) implements JSObject { + external factory _PermissionDescriptor({required String name}); + + external set name(String value); + external String get name; +} diff --git a/lib/feature/gps/util/web_location_data.dart b/lib/feature/gps/util/web_location_data.dart new file mode 100644 index 0000000..72995bd --- /dev/null +++ b/lib/feature/gps/util/web_location_data.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +class WebLocationData extends Equatable { + const WebLocationData({ + required this.latitude, + required this.longitude, + required this.accuracy, + required this.speed, + required this.heading, + required this.time + }); + + final double latitude; + final double longitude; + final double accuracy; + final double speed; + final double heading; + final int time; + + @override + List get props => [ + latitude, + longitude, + accuracy, + speed, + heading, + time, + ]; +} \ No newline at end of file diff --git a/lib/feature/home/home_page.dart b/lib/feature/home/home_page.dart index 8f88f60..e671948 100644 --- a/lib/feature/home/home_page.dart +++ b/lib/feature/home/home_page.dart @@ -45,19 +45,13 @@ class HomePage extends StatelessWidget { builder: (context, state) { if (state is DisplayStateNormal) { return AspectRatio( - aspectRatio: state.adjustedSize.width / state.adjustedSize.height, - child: Stack( - fit: StackFit.expand, - children: [ - // Give this a stable key so Flutter keeps it - DisplayView( - key: const ValueKey('/android.html'), - type: state.rendererType, - ), - PointerInterceptor( - child: TouchScreenView(displaySize: state.adjustedSize), - ), - ], + aspectRatio: + state.adjustedSize.width / + state.adjustedSize.height, + child: TouchScreenView( + displaySize: state.adjustedSize, + child: DisplayView( + ), ), ); } else { @@ -81,7 +75,11 @@ class HomePage extends StatelessWidget { ), ), child: const Row( - children: [AudioButton(), UpdateButton(), SettingsButton()], + children: [ + AudioButton(), + UpdateButton(), + SettingsButton(), + ], ), ), ), diff --git a/lib/feature/settings/bloc/display_configuration_cubit.dart b/lib/feature/settings/bloc/display_configuration_cubit.dart index cea19d1..8f4fb8d 100644 --- a/lib/feature/settings/bloc/display_configuration_cubit.dart +++ b/lib/feature/settings/bloc/display_configuration_cubit.dart @@ -70,14 +70,14 @@ class DisplayConfigurationCubit extends Cubit } } - void setRenderer(DisplayRendererType newType) async { + void setRenderer(bool isH264) async { var config = _currentConfig; if (config != null) { - config = config.updateRenderer(newType: newType); + config = config.updateRenderer(isH264: isH264); if (!isClosed) emit(DisplayConfigurationStateSettingsUpdateInProgress()); try { await _repository.updateDisplayConfiguration(config); - _currentConfig = _currentConfig?.copyWith(renderer: newType); + _currentConfig = _currentConfig?.copyWith(isH264: isH264 ? 1 : 0); _emitCurrentConfig(); } catch (exception, stackTrace) { logException(exception: exception, stackTrace: stackTrace); @@ -128,7 +128,7 @@ class DisplayConfigurationCubit extends Cubit if (!isClosed) { emit(DisplayConfigurationStateSettingsFetched( resolutionPreset: _currentConfig!.resolutionPreset, - renderer: _currentConfig!.renderer, + isH264: _currentConfig!.isH264 == 1, isResponsive: _currentConfig!.isResponsive == 1, refreshRate: _currentConfig!.refreshRate, quality: _currentConfig!.quality, diff --git a/lib/feature/settings/bloc/display_configuration_state.dart b/lib/feature/settings/bloc/display_configuration_state.dart index 709a73a..af62254 100644 --- a/lib/feature/settings/bloc/display_configuration_state.dart +++ b/lib/feature/settings/bloc/display_configuration_state.dart @@ -9,14 +9,14 @@ class DisplayConfigurationStateLoading extends DisplayConfigurationState {} class DisplayConfigurationStateSettingsFetched extends DisplayConfigurationState { final DisplayResolutionModePreset resolutionPreset; - final DisplayRendererType renderer; + final bool isH264; final bool isResponsive; final DisplayQualityPreset quality; final DisplayRefreshRatePreset refreshRate; DisplayConfigurationStateSettingsFetched({ required this.resolutionPreset, - required this.renderer, + required this.isH264, required this.isResponsive, required this.refreshRate, required this.quality, diff --git a/lib/feature/settings/widget/display_settings.dart b/lib/feature/settings/widget/display_settings.dart index efdf525..a16b201 100644 --- a/lib/feature/settings/widget/display_settings.dart +++ b/lib/feature/settings/widget/display_settings.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tesla_android/common/di/ta_locator.dart'; import 'package:tesla_android/common/ui/constants/ta_dimens.dart'; import 'package:tesla_android/feature/display/model/remote_display_state.dart'; import 'package:tesla_android/feature/settings/bloc/display_configuration_cubit.dart'; @@ -30,7 +29,7 @@ class DisplaySettings extends SettingsSection { const Padding( padding: EdgeInsets.all(TADimens.PADDING_S_VALUE), child: Text( - 'Tesla Android supports both h264 and MJPEG display compression. MJPEG has less visible compression artifacts but needs much more bandwidth.\n\nNOTE: WebCodecs may not work if your car is running Tesla Firmware older than 2025.32.', + 'Tesla Android supports both h264 and MJPEG display compression. MJPEG has less visible compression artifacts but needs much more bandwidth.\n\nNOTE: H264 may not work if your car is running Tesla Firmware older than 2025.32.', ), ), divider, @@ -215,23 +214,23 @@ class DisplaySettings extends SettingsSection { DisplayConfigurationState state, ) { if (state is DisplayConfigurationStateSettingsFetched) { - return DropdownButton( - value: state.renderer, + return DropdownButton( + value: state.isH264, icon: const Icon(Icons.arrow_drop_down_outlined), underline: Container(height: 2, color: Theme.of(context).primaryColor), - onChanged: (DisplayRendererType? value) { + onChanged: (bool? value) { if (value != null) { cubit.setRenderer(value); _showConfigurationChangedBanner(context); } }, - items: DisplayRendererType.values - .map>(( - DisplayRendererType value, + items: [true, false] + .map>(( + bool value, ) { - return DropdownMenuItem( + return DropdownMenuItem( value: value, - child: Text(value.name()), + child: Text(value == true ? "h264" : "MJPEG"), ); }) .toList(), diff --git a/lib/feature/touchscreen/cubit/touchscreen_cubit.dart b/lib/feature/touchscreen/cubit/touchscreen_cubit.dart index a8603d1..5f8717d 100644 --- a/lib/feature/touchscreen/cubit/touchscreen_cubit.dart +++ b/lib/feature/touchscreen/cubit/touchscreen_cubit.dart @@ -6,16 +6,20 @@ import 'package:injectable/injectable.dart'; import 'package:tesla_android/common/utils/logger.dart'; import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_slot_state.dart'; import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_command.dart'; -import 'package:web/web.dart' as web; +import 'package:tesla_android/feature/touchscreen/transport/touchscreen_transport.dart'; @injectable class TouchscreenCubit extends Cubit with Logger { final List slotsState = VirtualTouchscreenSlotState.generateSlots(); - TouchscreenCubit() : super(false); + final TouchScreenTransport _transport; + TouchscreenCubit(this._transport) : super(false) { + _transport.connect(); + } @override Future close() { + _transport.disconnect(); log("close"); return super.close(); } @@ -38,7 +42,7 @@ class TouchscreenCubit extends Cubit with Logger { absMtPositionY: slot.position.dy.toInt(), synReport: true); - sendCommand(command); + _transport.sendCommand(command); } void handlePointerMoveEvent( @@ -57,7 +61,7 @@ class TouchscreenCubit extends Cubit with Logger { absMtPositionY: slot.position.dy.toInt(), synReport: true); - sendCommand(command); + _transport.sendCommand(command); } void handlePointerUpEvent(PointerEvent event, BoxConstraints constraints) { @@ -73,7 +77,7 @@ class TouchscreenCubit extends Cubit with Logger { absMtTrackingId: slot.trackingId, synReport: true); - sendCommand(command); + _transport.sendCommand(command); } VirtualTouchscreenSlotState? _getFirstUnusedSlot() { @@ -109,27 +113,4 @@ class TouchscreenCubit extends Cubit with Logger { } return scaledOffset; } - - void resetTouchScreen() { - List commands = []; - for (final slot in VirtualTouchscreenSlotState.generateSlots()) { - commands.add(VirtualTouchScreenCommand( - absMtSlot: slot.slotIndex, absMtTrackingId: slot.trackingId)); - } - sendCommands(commands: commands); - } - - void sendCommands({required List commands}) { - commands.add(VirtualTouchScreenCommand(synReport: true)); - - var message = ''; - for (final command in commands) { - message += command.build(); - } - web.window.postMessage(message.toJS, '*'.toJS); - } - - void sendCommand(VirtualTouchScreenCommand command) { - web.window.postMessage(command.build().toJS, '*'.toJS); - } } diff --git a/lib/feature/touchscreen/model/virtual_touchscreen_command.dart b/lib/feature/touchscreen/model/virtual_touchscreen_command.dart index 4cbbf1d..a3271bd 100644 --- a/lib/feature/touchscreen/model/virtual_touchscreen_command.dart +++ b/lib/feature/touchscreen/model/virtual_touchscreen_command.dart @@ -14,7 +14,7 @@ class VirtualTouchScreenCommand { }); String build() { - var command = "touchScreenCommand:"; + var command = ""; if (absMtSlot != null) command += 's $absMtSlot\n'; if (absMtTrackingId != null) { command += 'T $absMtTrackingId\n'; diff --git a/lib/feature/touchscreen/touchscreen_view.dart b/lib/feature/touchscreen/touchscreen_view.dart index f9fe0d6..85f8e6d 100644 --- a/lib/feature/touchscreen/touchscreen_view.dart +++ b/lib/feature/touchscreen/touchscreen_view.dart @@ -1,56 +1,59 @@ - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tesla_android/feature/touchscreen/cubit/touchscreen_cubit.dart'; class TouchScreenView extends StatelessWidget { final Size displaySize; + final Widget child; const TouchScreenView({ super.key, required this.displaySize, + required this.child, }); @override Widget build(BuildContext context) { final touchScreenCubit = BlocProvider.of(context); - return LayoutBuilder(builder: (context, constraints) { - return Listener( - onPointerDown: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerMove: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerCancel: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - onPointerUp: (event) { - _handlePointerEvent( - cubit: touchScreenCubit, - event: event, - constraints: constraints, - touchscreenSize: displaySize, - ); - }, - child: Container(color: Colors.transparent,), - ); - }); + return LayoutBuilder( + builder: (context, constraints) { + return Listener( + onPointerDown: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerMove: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerCancel: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + onPointerUp: (event) { + _handlePointerEvent( + cubit: touchScreenCubit, + event: event, + constraints: constraints, + touchscreenSize: displaySize, + ); + }, + child: child, + ); + }, + ); } void _handlePointerEvent({ @@ -67,4 +70,4 @@ class TouchScreenView extends StatelessWidget { cubit.handlePointerUpEvent(event, constraints); } } -} \ No newline at end of file +} diff --git a/lib/feature/touchscreen/transport/touchscreen_transport.dart b/lib/feature/touchscreen/transport/touchscreen_transport.dart new file mode 100644 index 0000000..807d9b9 --- /dev/null +++ b/lib/feature/touchscreen/transport/touchscreen_transport.dart @@ -0,0 +1,39 @@ +import 'package:injectable/injectable.dart'; +import 'package:tesla_android/common/network/base_websocket_transport.dart'; +import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_command.dart'; +import 'package:tesla_android/feature/touchscreen/model/virtual_touchscreen_slot_state.dart'; + +@injectable +class TouchScreenTransport extends BaseWebsocketTransport { + TouchScreenTransport() + : super(flavorUrlKey: "touchscreenWebSocket"); + + @override + void onOpen() { + resetTouchScreen(); + super.onOpen(); + } + + void resetTouchScreen() { + List commands = []; + for (final slot in VirtualTouchscreenSlotState.generateSlots()) { + commands.add(VirtualTouchScreenCommand( + absMtSlot: slot.slotIndex, absMtTrackingId: slot.trackingId)); + } + sendCommands(commands: commands); + } + + void sendCommands({required List commands}) { + commands.add(VirtualTouchScreenCommand(synReport: true)); + + var message = ''; + for (final command in commands) { + message += command.build(); + } + sendString(message); + } + + void sendCommand(VirtualTouchScreenCommand command) { + sendString(command.build()); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 25679d3..70848f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:tesla_android/common/di/ta_locator.dart'; import 'package:tesla_android/common/navigation/ta_page_factory.dart'; @@ -8,11 +9,46 @@ import 'package:tesla_android/common/utils/logger.dart'; Future main() async { await configureTADependencies(); + final FlutterExceptionHandler? defaultOnError = FlutterError.onError; + + FlutterError.onError = (FlutterErrorDetails details) { + if (_isHotReloadJsInteropError(details)) { + debugPrint( + '[IGNORED] Hot-reload JS Interop null error:\n' + '${details.exceptionAsString()}\n' + '${details.stack}', + ); + return; + } + + if (defaultOnError != null) { + defaultOnError(details); + } else { + FlutterError.dumpErrorToConsole(details); + } + }; + runApp( TeslaAndroid(), ); } +bool _isHotReloadJsInteropError(FlutterErrorDetails details) { + if (!kDebugMode) return false; + + final msg = details.exceptionAsString(); + final stack = details.stack?.toString() ?? ''; + + final isUnexpectedNull = + msg.contains('DartError: Unexpected null value'); + + final isFromVideoFrameHelper = + stack.contains('video_frame_to_image.dart') || + stack.contains('js_allow_interop_patch.dart'); + + return isUnexpectedNull && isFromVideoFrameHelper; +} + class TeslaAndroid extends StatelessWidget with Logger { TeslaAndroid({super.key}); diff --git a/pubspec.yaml b/pubspec.yaml index 2ca3b61..fe5c717 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Tesla Android publish_to: 'none' -version: 2025.46.1 +version: 2025.48.1 environment: flutter: "3.35.0"