From 68d35142b5d7c5eab3d6313d1d89e014ab630dcc Mon Sep 17 00:00:00 2001 From: pugal Date: Tue, 18 Aug 2026 12:35:19 +0530 Subject: [PATCH 1/2] feat: Integrate Microsoft Teams for video conference lessons - Add support to identify MS Teams as a live stream provider - Add dedicated TeamsVideoConferenceScreen with WebView joining flow - Request camera/mic permissions before WebView loads - Add localized strings for Teams meeting join flows and errors --- .../support-ms-teams-lessons/.openspec.yaml | 2 + .../support-ms-teams-lessons/design.md | 25 ++ .../support-ms-teams-lessons/proposal.md | 21 + .../specs/ms-teams-webview-join/spec.md | 23 ++ .../changes/support-ms-teams-lessons/tasks.md | 14 + .../lib/generated/l10n/app_localizations.dart | 12 + .../generated/l10n/app_localizations_ar.dart | 7 + .../generated/l10n/app_localizations_en.dart | 7 + .../generated/l10n/app_localizations_ml.dart | 7 + .../generated/l10n/app_localizations_ta.dart | 7 + packages/core/lib/l10n/app_ar.arb | 4 +- packages/core/lib/l10n/app_en.arb | 4 +- packages/core/lib/l10n/app_ml.arb | 4 +- packages/core/lib/l10n/app_ta.arb | 4 +- .../courses/lib/models/course_content.dart | 4 + .../screens/lesson_detail_orchestrator.dart | 4 +- .../widgets/lesson_detail/teams_web_view.dart | 378 ++++++++++++++++++ .../video_conference_viewer.dart | 13 + 18 files changed, 535 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/support-ms-teams-lessons/.openspec.yaml create mode 100644 openspec/changes/support-ms-teams-lessons/design.md create mode 100644 openspec/changes/support-ms-teams-lessons/proposal.md create mode 100644 openspec/changes/support-ms-teams-lessons/specs/ms-teams-webview-join/spec.md create mode 100644 openspec/changes/support-ms-teams-lessons/tasks.md create mode 100644 packages/courses/lib/widgets/lesson_detail/teams_web_view.dart diff --git a/openspec/changes/support-ms-teams-lessons/.openspec.yaml b/openspec/changes/support-ms-teams-lessons/.openspec.yaml new file mode 100644 index 000000000..4af864176 --- /dev/null +++ b/openspec/changes/support-ms-teams-lessons/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-14 diff --git a/openspec/changes/support-ms-teams-lessons/design.md b/openspec/changes/support-ms-teams-lessons/design.md new file mode 100644 index 000000000..4498b1e21 --- /dev/null +++ b/openspec/changes/support-ms-teams-lessons/design.md @@ -0,0 +1,25 @@ +## Context + +Users need the ability to join MS Teams video conference lessons directly from the application. We want to enable joining Teams lessons using an in-app WebView, while providing a seamless, auto-join experience that skips Microsoft's "Download App" gate pages. + +## Goals / Non-Goals + +**Goals:** +- Enable students to join MS Teams lessons via the "Attend Class" button. +- Provide a seamless experience by automatically bypassing the Teams "How do you want to join?" gate page. +- Ensure camera and microphone permissions are requested prior to joining. + +**Non-Goals:** +- Reusing `LessonWebView` (it is designed for static content and lacks the necessary JS/Navigation flexibility for the Teams flow). + +## Decisions + +- **Dedicated WebView**: Created `TeamsVideoConferenceScreen` instead of reusing `LessonWebView`. This allows for unrestricted JavaScript execution and custom navigation handling. +- **Desktop User-Agent Spoofing**: We force a desktop Chrome User-Agent in the WebView. If we don't, Teams detects a mobile device and forces a "Download the Teams App" screen that cannot be bypassed. +- **JavaScript Auto-Join**: We use `TeamsAutoJoinHandler` to inject JavaScript on page load. The JS automatically finds and clicks the "Continue on this browser" button. +- **Loading Overlay**: To hide the intermediate gate page from the user, a loading overlay covers the WebView. It naturally drops via the `onPageFinished` navigation delegate only after the gate has been bypassed and the final lobby page has loaded. + +## Risks / Trade-offs + +- [Risk] MS Teams DOM changes. The JavaScript relies on specific CSS selectors (e.g., `[data-tid="joinOnWeb"]`). If Microsoft changes these, the auto-join will timeout. → Mitigation: We implemented text-based fallback selectors and a robust timeout mechanism that drops the loader and shows an error toast if it fails. +- [Risk] iOS vs Android Permission discrepancies. → Mitigation: We use `permission_handler` at the OS level before the WebView initializes, guaranteeing both platforms have hardware access before loading Teams. diff --git a/openspec/changes/support-ms-teams-lessons/proposal.md b/openspec/changes/support-ms-teams-lessons/proposal.md new file mode 100644 index 000000000..a38465980 --- /dev/null +++ b/openspec/changes/support-ms-teams-lessons/proposal.md @@ -0,0 +1,21 @@ +## Why + +Users need the ability to join MS Teams video conference lessons directly from the application. We want to enable joining Teams lessons using an in-app WebView, while providing a seamless, auto-join experience that skips Microsoft's "Download App" gate pages. + +## What Changes + +- Create a dedicated `TeamsVideoConferenceScreen` with a custom WebView to handle the complex auto-join flow. +- Inject Javascript via a `TeamsAutoJoinHandler` to seamlessly bypass the Microsoft Teams "Download App" interstitial gate. +- Update `VideoConferenceViewer` to route Teams meetings to this new dedicated screen. + +## Capabilities + +### New Capabilities +- `ms-teams-webview-join`: Seamless auto-join of MS Teams meetings via an in-app WebView without native SDK dependencies. + +### Modified Capabilities +- (None) + +## Impact + +* **`packages/courses`**: Modifies `_joinMeeting` flow in `video_conference_viewer.dart` to push a new `TeamsVideoConferenceScreen` (`teams_web_view.dart`) instead of a generic browser. diff --git a/openspec/changes/support-ms-teams-lessons/specs/ms-teams-webview-join/spec.md b/openspec/changes/support-ms-teams-lessons/specs/ms-teams-webview-join/spec.md new file mode 100644 index 000000000..291df3333 --- /dev/null +++ b/openspec/changes/support-ms-teams-lessons/specs/ms-teams-webview-join/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: MS Teams WebView Join +The system SHALL allow users to join MS Teams lessons using an in-app web view when tapping the "Attend Class" button. + +#### Scenario: User clicks Attend Class on a Teams lesson +- **WHEN** the user taps "Attend Class" on a Video Conference lesson with the "teams" provider +- **THEN** the app requests Camera and Microphone permissions +- **AND** if granted, pushes a full-screen WebView loading the lesson's join URL wrapped in the standard `LessonDetailShell` +- **AND** the WebView permits necessary navigation redirects required by the MS Teams join flow + +#### Scenario: User denies camera/microphone permissions +- **WHEN** the user denies permissions (or they are permanently denied) +- **THEN** the app displays a localized error toast ("Camera and microphone access is required. Enable them in Settings to join.") +- **AND** the app automatically pops the screen, returning the user to the syllabus + +#### Scenario: User needs to exit the meeting +- **WHEN** the user is inside the Teams WebView (pre-join lobby or active call) +- **THEN** they can tap the native back button provided by the `LessonDetailShell` header to reliably exit the screen at any time + +#### Scenario: Auto-join script fails +- **WHEN** the injected JavaScript for bypassing the Teams lobby fails +- **THEN** the error is silently reported to Sentry for tracking without disrupting the user's manual fallback flow diff --git a/openspec/changes/support-ms-teams-lessons/tasks.md b/openspec/changes/support-ms-teams-lessons/tasks.md new file mode 100644 index 000000000..cbaeddddc --- /dev/null +++ b/openspec/changes/support-ms-teams-lessons/tasks.md @@ -0,0 +1,14 @@ +## 1. Dedicated Teams WebView + +- [x] 1.1 Create `TeamsVideoConferenceScreen` in `teams_web_view.dart` to handle the specific requirements of MS Teams (desktop user agent, custom navigation delegates). +- [x] 1.2 Implement `TeamsAutoJoinHandler` to inject JavaScript into the Teams gate page to automatically click "Continue on this browser". +- [x] 1.3 Add `TeamsPermissionHandler` to request camera/microphone permissions before launching the WebView. +- [x] 1.4 Add loading overlay logic that naturally drops on `onPageFinished` after successful bypass. + +## 2. Integration + +- [x] 2.1 Update `_joinMeeting` in `VideoConferenceViewer` (`packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart`) to intercept Teams lessons and push the new `TeamsVideoConferenceScreen`. + +## 3. Verification + +- [x] 3.1 Run `flutter analyze` and confirm no compile or lint issues exist. diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index a896708f3..6eee542b4 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -5710,6 +5710,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed to join the meeting. Please try again.'** String get liveStreamJoinFailed; + + /// No description provided for @teamsPermissionRequired. + /// + /// In en, this message translates to: + /// **'Camera and microphone access is required. Enable them in Settings to join.'** + String get teamsPermissionRequired; + + /// No description provided for @teamsJoinMeetingLoading. + /// + /// In en, this message translates to: + /// **'Joining meeting...'** + String get teamsJoinMeetingLoading; } class _AppLocalizationsDelegate diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 8862697fa..5eae90c9e 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -3158,4 +3158,11 @@ class AppLocalizationsAr extends AppLocalizations { @override String get liveStreamJoinFailed => 'فشل الانضمام إلى الاجتماع. يُرجى المحاولة مرة أخرى.'; + + @override + String get teamsPermissionRequired => + 'يلزم الوصول إلى الكاميرا والميكروفون. قم بتمكينها في الإعدادات للانضمام.'; + + @override + String get teamsJoinMeetingLoading => 'جارِ الانضمام إلى الاجتماع...'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index fad7501d4..117f46bc5 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -3158,4 +3158,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get liveStreamJoinFailed => 'Failed to join the meeting. Please try again.'; + + @override + String get teamsPermissionRequired => + 'Camera and microphone access is required. Enable them in Settings to join.'; + + @override + String get teamsJoinMeetingLoading => 'Joining meeting...'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index 7d394b4d9..488aa0838 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -3212,4 +3212,11 @@ class AppLocalizationsMl extends AppLocalizations { @override String get liveStreamJoinFailed => 'മീറ്റിംഗിൽ ചേരുന്നതിൽ പരാജയപ്പെട്ടു. വീണ്ടും ശ്രമിക്കുക.'; + + @override + String get teamsPermissionRequired => + 'ക്യാമറ, മൈക്രോഫോൺ ആക്സസ് ആവശ്യമാണ്. ചേരുന്നതിന് ക്രമീകരണങ്ങളിൽ അവ പ്രവർത്തനക്ഷമമാക്കുക.'; + + @override + String get teamsJoinMeetingLoading => 'മീറ്റിംഗിൽ ചേരുന്നു...'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index 966d66344..c4153995c 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -3208,4 +3208,11 @@ class AppLocalizationsTa extends AppLocalizations { @override String get liveStreamJoinFailed => 'கூட்டத்தில் சேர முடியவில்லை. மீண்டும் முயற்சிக்கவும்.'; + + @override + String get teamsPermissionRequired => + 'சேர, கேமரா மற்றும் மைக்ரோஃபோன் அணுகல் தேவை. அமைப்புகளில் அவற்றை இயக்கவும்.'; + + @override + String get teamsJoinMeetingLoading => 'கூட்டத்தில் சேர்கிறோம்...'; } diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 2605281ab..8e24a55ad 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -1203,5 +1203,7 @@ "liveStreamSessionDetails": "تفاصيل الجلسة", "liveStreamStatus": "الحالة", "liveStreamStatusLive": "مباشر", - "liveStreamJoinFailed": "فشل الانضمام إلى الاجتماع. يُرجى المحاولة مرة أخرى." + "liveStreamJoinFailed": "فشل الانضمام إلى الاجتماع. يُرجى المحاولة مرة أخرى.", + "teamsPermissionRequired": "يلزم الوصول إلى الكاميرا والميكروفون. قم بتمكينها في الإعدادات للانضمام.", + "teamsJoinMeetingLoading": "جارِ الانضمام إلى الاجتماع..." } diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index d40bcb37c..feeeaea42 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -1563,5 +1563,7 @@ "liveStreamSessionDetails": "Session Details", "liveStreamStatus": "Status", "liveStreamStatusLive": "Live", - "liveStreamJoinFailed": "Failed to join the meeting. Please try again." + "liveStreamJoinFailed": "Failed to join the meeting. Please try again.", + "teamsPermissionRequired": "Camera and microphone access is required. Enable them in Settings to join.", + "teamsJoinMeetingLoading": "Joining meeting..." } diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index cc7fbbda0..e99214700 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -1203,5 +1203,7 @@ "liveStreamSessionDetails": "സെഷൻ വിവരങ്ങൾ", "liveStreamStatus": "നില", "liveStreamStatusLive": "ലൈവ്", - "liveStreamJoinFailed": "മീറ്റിംഗിൽ ചേരുന്നതിൽ പരാജയപ്പെട്ടു. വീണ്ടും ശ്രമിക്കുക." + "liveStreamJoinFailed": "മീറ്റിംഗിൽ ചേരുന്നതിൽ പരാജയപ്പെട്ടു. വീണ്ടും ശ്രമിക്കുക.", + "teamsPermissionRequired": "ക്യാമറ, മൈക്രോഫോൺ ആക്സസ് ആവശ്യമാണ്. ചേരുന്നതിന് ക്രമീകരണങ്ങളിൽ അവ പ്രവർത്തനക്ഷമമാക്കുക.", + "teamsJoinMeetingLoading": "മീറ്റിംഗിൽ ചേരുന്നു..." } diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index 99a9d749f..319fb14ab 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -1437,5 +1437,7 @@ "liveStreamSessionDetails": "அமர்வு விவரங்கள்", "liveStreamStatus": "நிலை", "liveStreamStatusLive": "நேரலை", - "liveStreamJoinFailed": "கூட்டத்தில் சேர முடியவில்லை. மீண்டும் முயற்சிக்கவும்." + "liveStreamJoinFailed": "கூட்டத்தில் சேர முடியவில்லை. மீண்டும் முயற்சிக்கவும்.", + "teamsPermissionRequired": "சேர, கேமரா மற்றும் மைக்ரோஃபோன் அணுகல் தேவை. அமைப்புகளில் அவற்றை இயக்கவும்.", + "teamsJoinMeetingLoading": "கூட்டத்தில் சேர்கிறோம்..." } diff --git a/packages/courses/lib/models/course_content.dart b/packages/courses/lib/models/course_content.dart index 33e527f59..4e3aaba9b 100644 --- a/packages/courses/lib/models/course_content.dart +++ b/packages/courses/lib/models/course_content.dart @@ -122,6 +122,10 @@ class Lesson { bool get isZoom => liveStreamProvider?.toLowerCase().contains('zoom') ?? false; + /// Whether this live stream or conference uses MS Teams. + bool get isTeams => + liveStreamProvider?.toLowerCase().contains('teams') ?? false; + /// Checks if the lesson has enough metadata to be rendered without a specialized loader. bool get isComplete { if (isDetailFetched) return true; diff --git a/packages/courses/lib/screens/lesson_detail_orchestrator.dart b/packages/courses/lib/screens/lesson_detail_orchestrator.dart index 66df58513..9e86db64a 100644 --- a/packages/courses/lib/screens/lesson_detail_orchestrator.dart +++ b/packages/courses/lib/screens/lesson_detail_orchestrator.dart @@ -331,7 +331,9 @@ class _LessonDetailOrchestratorState case LessonType.videoConference: final isEnded = lesson.streamStatus?.toLowerCase() == 'completed' || lesson.streamStatus?.toLowerCase() == 'ended'; - if (isEnded && lesson.isZoom && lesson.showRecordedVideo) { + if (isEnded && + (lesson.isZoom || lesson.isTeams) && + lesson.showRecordedVideo) { return VideoLessonViewer( lesson: lesson, onComplete: _markAsCompleted, diff --git a/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart b/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart new file mode 100644 index 000000000..04301f628 --- /dev/null +++ b/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart @@ -0,0 +1,378 @@ +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:core/core.dart'; + +class TeamsPermissionHandler { + static Future checkAndRequestPermissions() async { + final permissions = [ + Permission.camera, + Permission.microphone, + ]; + + final statuses = await permissions.request(); + + bool allGranted = true; + for (var status in statuses.values) { + if (!status.isGranted) { + allGranted = false; + break; + } + } + + return allGranted; + } +} + +class TeamsWebViewConfig { + static const String desktopUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"; + + static Future setupWebViewSettings( + WebViewController controller, BuildContext context) async { + final design = Design.of(context); + await Future.wait([ + controller.setUserAgent(desktopUserAgent), + controller.setBackgroundColor(design.colors.canvas), + ]); + + if (await controller.supportsSetScrollBarsEnabled()) { + await Future.wait([ + controller.setVerticalScrollBarEnabled(false), + controller.setHorizontalScrollBarEnabled(false), + ]); + } + } +} + +class TeamsAutoJoinHandler { + late WebViewController _webViewController; + bool _hasCompletedAutoJoin = false; + bool _isDisposed = false; + + final Function()? onJoinSuccess; + final Function()? onJoinTimeout; + final Function()? onLoadingStart; + final Function()? onLoadingEnd; + final Function(Object error, StackTrace stackTrace)? onError; + + TeamsAutoJoinHandler({ + this.onJoinSuccess, + this.onJoinTimeout, + this.onLoadingStart, + this.onLoadingEnd, + this.onError, + }); + + void attach(WebViewController controller) { + _webViewController = controller; + + _webViewController.addJavaScriptChannel( + 'TeamsAutoJoin', + onMessageReceived: (JavaScriptMessage message) { + _handleJavaScriptMessage(message.message); + }, + ); + } + + void onPageStarted(String url) { + // Only show loader if we haven't completed the join flow yet + if (!_hasCompletedAutoJoin) { + onLoadingStart?.call(); + } + } + + void onPageFinished(String url) { + _hideScrollbars(); + + if (!_hasCompletedAutoJoin && _isTeamsJoinGateUrl(url)) { + _autoJoinTeamsMeetingWeb(); + } else { + onLoadingEnd?.call(); + if (_hasCompletedAutoJoin) { + onJoinSuccess?.call(); + } + } + } + + Future _hideScrollbars() async { + const script = ''' + (function () { + try { + var style = document.getElementById('tp-hide-scrollbars'); + if (!style) { + style = document.createElement('style'); + style.id = 'tp-hide-scrollbars'; + style.innerHTML = '*::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; background: transparent !important; } * { scrollbar-width: none !important; -ms-overflow-style: none !important; }'; + document.head.appendChild(style); + } + } catch (e) {} + })(); + '''; + + try { + await _webViewController.runJavaScript(script); + } catch (e, st) { + onError?.call(e, st); + } + } + + bool _isTeamsJoinGateUrl(String? url) { + if (url == null) return false; + + final normalizedUrl = url.toLowerCase(); + return normalizedUrl.contains('teams.microsoft.com') && + (normalizedUrl.contains('launcher') || + normalizedUrl.contains('join') || + normalizedUrl.contains('pre-join') || + normalizedUrl.contains('meetup-join')); + } + + Future _autoJoinTeamsMeetingWeb() async { + const String javascript = ''' + (function() { + var maxRetries = 20; + var retries = 0; + + var checkExist = setInterval(function() { + retries++; + console.log('Auto-join attempt: ' + retries + '/' + maxRetries); + + // PHASE 0: Check if we bypassed the gate and are already in the lobby + var lobbyBtn = document.querySelector('[data-tid="prejoin-join-button"]'); + var nameInput = document.querySelector('[data-tid="prejoin-display-name-input"]'); + if ((lobbyBtn && lobbyBtn.offsetParent !== null) || (nameInput && nameInput.offsetParent !== null)) { + console.log('Lobby detected. Dropping overlay.'); + clearInterval(checkExist); + window.TeamsAutoJoin.postMessage('onLobbyDetected'); + return; + } + + // PHASE 1: Try data-tid selectors + var selectors = [ + '[data-tid="joinOnWeb"]', + '[data-tid="joinBrowserButton"]', + 'button[data-tid*="web"]' + ]; + for (var j = 0; j < selectors.length; j++) { + var btn = document.querySelector(selectors[j]); + if (btn && btn.offsetParent !== null) { + btn.click(); + clearInterval(checkExist); + window.TeamsAutoJoin.postMessage('onButtonClicked'); + return; + } + } + + // PHASE 2: Text content fallback + var allButtons = document.querySelectorAll('button'); + for (var i = 0; i < allButtons.length; i++) { + var text = (allButtons[i].textContent || allButtons[i].innerText || "").toLowerCase(); + if (text.indexOf('continue on this browser') > -1 || + text.indexOf('join on the web') > -1) { + allButtons[i].click(); + clearInterval(checkExist); + window.TeamsAutoJoin.postMessage('onButtonClicked'); + return; + } + } + + // PHASE 3: Timeout + if (retries >= maxRetries) { + console.log('Auto-join timeout after ' + maxRetries + ' attempts'); + clearInterval(checkExist); + window.TeamsAutoJoin.postMessage('onAutoJoinTimeout'); + } + }, 500); + })(); + '''; + + try { + await _webViewController.runJavaScript(javascript); + } catch (e, st) { + onError?.call(e, st); + onJoinTimeout?.call(); + } + } + + void _handleJavaScriptMessage(String message) { + if (_isDisposed) return; + + if (message == 'onButtonClicked') { + // Gate button clicked. Don't drop loader. Wait for next page to load. + _hasCompletedAutoJoin = true; + } else if (message == 'onLobbyDetected') { + // Already in lobby (bypassed gate). Drop loader immediately. + _hasCompletedAutoJoin = true; + onLoadingEnd?.call(); + onJoinSuccess?.call(); + } else if (message == 'onAutoJoinTimeout') { + onJoinTimeout?.call(); + } + } + + void dispose() { + _isDisposed = true; + _hasCompletedAutoJoin = false; + } +} + +class TeamsVideoConferenceScreen extends StatefulWidget { + final String joinUrl; + final String title; + + const TeamsVideoConferenceScreen({ + super.key, + required this.joinUrl, + required this.title, + }); + + @override + State createState() => + _TeamsVideoConferenceScreenState(); +} + +class _TeamsVideoConferenceScreenState + extends State { + late WebViewController _webViewController; + late TeamsAutoJoinHandler _autoJoinHandler; + bool _isLoading = false; + bool _permissionsGranted = false; + + @override + void initState() { + super.initState(); + _initializeHandler(); + _checkPermissionsAndLoad(); + } + + void _initializeHandler() { + _autoJoinHandler = TeamsAutoJoinHandler( + onLoadingStart: () { + setState(() => _isLoading = true); + }, + onLoadingEnd: () { + setState(() => _isLoading = false); + }, + onJoinSuccess: () { + setState(() => _isLoading = false); + }, + onJoinTimeout: () { + setState(() => _isLoading = false); + AppToast.show( + context, + message: L10n.of(context).liveStreamJoinFailed, + isError: true, + ); + }, + onError: (error, stackTrace) { + SentryService().captureException(error, stackTrace: stackTrace); + }, + ); + } + + Future _checkPermissionsAndLoad() async { + // Note: On Android, permissions are gated by both the OS and the WebView's + // onPermissionRequest callback. On iOS, permission_handler requests the + // OS-level permission, and the WebView's onPermissionRequest is essentially + // a no-op that relies on the OS-level prompt. + final permissionsGranted = + await TeamsPermissionHandler.checkAndRequestPermissions(); + + if (permissionsGranted && mounted) { + setState(() => _permissionsGranted = true); + await _setupWebView(); + } else if (mounted) { + AppToast.show( + context, + message: L10n.of(context).teamsPermissionRequired, + isError: true, + ); + Navigator.of(context).pop(); + } + } + + Future _setupWebView() async { + _webViewController = WebViewController( + onPermissionRequest: (WebViewPermissionRequest request) { + request.grant(); + }, + ) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..enableZoom(false) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (String url) { + _autoJoinHandler.onPageStarted(url); + }, + onPageFinished: (String url) { + _autoJoinHandler.onPageFinished(url); + }, + onNavigationRequest: (request) { + final uri = Uri.tryParse(request.url); + // Prevent non-HTTP deep links (e.g. msteams://) from crashing the WebView. + if (uri != null && !['http', 'https'].contains(uri.scheme)) { + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + onWebResourceError: (WebResourceError error) { + // Ignored Teams background resource error + }, + ), + ); + + _autoJoinHandler.attach(_webViewController); + await TeamsWebViewConfig.setupWebViewSettings(_webViewController, context); + + _webViewController.loadRequest(Uri.parse(widget.joinUrl)); + } + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + + return LessonDetailShell( + title: widget.title, + onBack: () => Navigator.of(context).pop(), + backgroundColor: design.colors.canvas, + child: SafeArea( + top: false, // LessonDetailShell handles top safe area + child: Stack( + children: [ + if (_permissionsGranted) + WebViewWidget(controller: _webViewController), + if (_isLoading) + AppSemantics.container( + label: L10n.of(context).teamsJoinMeetingLoading, + child: Container( + color: design.colors.canvas, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppLoadingIndicator( + color: design.colors.primary, + ), + SizedBox(height: design.spacing.sm), + AppText.label( + L10n.of(context).teamsJoinMeetingLoading, + color: design.colors.textPrimary, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _autoJoinHandler.dispose(); + super.dispose(); + } +} diff --git a/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart b/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart index 947d11b1d..bc2b1fc9d 100644 --- a/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart +++ b/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; import '../../models/course_content.dart'; +import 'teams_web_view.dart'; final _meetingJoiningProvider = StateProvider.autoDispose((ref) => false); @@ -35,6 +36,18 @@ class VideoConferenceViewer extends ConsumerWidget { return; } + if (lesson.isTeams && lesson.contentUrl != null) { + Navigator.of(context).push( + AppRoute( + page: TeamsVideoConferenceScreen( + joinUrl: lesson.contentUrl!, + title: lesson.title, + ), + ), + ); + return; + } + final meetingService = ref.read(meetingServiceProvider); if (meetingService != null) { final user = ref.read(userProvider).valueOrNull; From 7eb4f005ac223e66aedd5d93978ba382e079c086 Mon Sep 17 00:00:00 2001 From: pugal Date: Tue, 18 Aug 2026 13:31:50 +0530 Subject: [PATCH 2/2] Add Teams auto-join tests and missing URL UI state - Add unit tests for Teams auto-join gate detection and JS messaging - Show a localized warning row instead of the "Attend Class" button when a Teams lesson has no URL --- .../lib/generated/l10n/app_localizations.dart | 6 + .../generated/l10n/app_localizations_ar.dart | 3 + .../generated/l10n/app_localizations_en.dart | 3 + .../generated/l10n/app_localizations_ml.dart | 3 + .../generated/l10n/app_localizations_ta.dart | 3 + packages/core/lib/l10n/app_ar.arb | 3 +- packages/core/lib/l10n/app_en.arb | 3 +- packages/core/lib/l10n/app_ml.arb | 3 +- packages/core/lib/l10n/app_ta.arb | 3 +- .../widgets/lesson_detail/teams_web_view.dart | 4 + .../video_conference_viewer.dart | 10 +- .../widgets/teams_auto_join_handler_test.dart | 117 ++++++++++++++++++ 12 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 packages/courses/test/widgets/teams_auto_join_handler_test.dart diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 6eee542b4..3c7a9c93e 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -5722,6 +5722,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Joining meeting...'** String get teamsJoinMeetingLoading; + + /// No description provided for @teamsMissingJoinLink. + /// + /// In en, this message translates to: + /// **'Meeting link is missing'** + String get teamsMissingJoinLink; } class _AppLocalizationsDelegate diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 5eae90c9e..5d770f988 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -3165,4 +3165,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get teamsJoinMeetingLoading => 'جارِ الانضمام إلى الاجتماع...'; + + @override + String get teamsMissingJoinLink => 'رابط الاجتماع مفقود'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 117f46bc5..170e6947c 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -3165,4 +3165,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get teamsJoinMeetingLoading => 'Joining meeting...'; + + @override + String get teamsMissingJoinLink => 'Meeting link is missing'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index 488aa0838..9a5944273 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -3219,4 +3219,7 @@ class AppLocalizationsMl extends AppLocalizations { @override String get teamsJoinMeetingLoading => 'മീറ്റിംഗിൽ ചേരുന്നു...'; + + @override + String get teamsMissingJoinLink => 'മീറ്റിംഗ് ലിങ്ക് ലഭ്യമല്ല'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index c4153995c..3adfb16b6 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -3215,4 +3215,7 @@ class AppLocalizationsTa extends AppLocalizations { @override String get teamsJoinMeetingLoading => 'கூட்டத்தில் சேர்கிறோம்...'; + + @override + String get teamsMissingJoinLink => 'சந்திப்பு இணைப்பு காணவில்லை'; } diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 8e24a55ad..0bb1f2939 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -1205,5 +1205,6 @@ "liveStreamStatusLive": "مباشر", "liveStreamJoinFailed": "فشل الانضمام إلى الاجتماع. يُرجى المحاولة مرة أخرى.", "teamsPermissionRequired": "يلزم الوصول إلى الكاميرا والميكروفون. قم بتمكينها في الإعدادات للانضمام.", - "teamsJoinMeetingLoading": "جارِ الانضمام إلى الاجتماع..." + "teamsJoinMeetingLoading": "جارِ الانضمام إلى الاجتماع...", + "teamsMissingJoinLink": "رابط الاجتماع مفقود" } diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index feeeaea42..b83fb7723 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -1565,5 +1565,6 @@ "liveStreamStatusLive": "Live", "liveStreamJoinFailed": "Failed to join the meeting. Please try again.", "teamsPermissionRequired": "Camera and microphone access is required. Enable them in Settings to join.", - "teamsJoinMeetingLoading": "Joining meeting..." + "teamsJoinMeetingLoading": "Joining meeting...", + "teamsMissingJoinLink": "Meeting link is missing" } diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index e99214700..3c8aba578 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -1205,5 +1205,6 @@ "liveStreamStatusLive": "ലൈവ്", "liveStreamJoinFailed": "മീറ്റിംഗിൽ ചേരുന്നതിൽ പരാജയപ്പെട്ടു. വീണ്ടും ശ്രമിക്കുക.", "teamsPermissionRequired": "ക്യാമറ, മൈക്രോഫോൺ ആക്സസ് ആവശ്യമാണ്. ചേരുന്നതിന് ക്രമീകരണങ്ങളിൽ അവ പ്രവർത്തനക്ഷമമാക്കുക.", - "teamsJoinMeetingLoading": "മീറ്റിംഗിൽ ചേരുന്നു..." + "teamsJoinMeetingLoading": "മീറ്റിംഗിൽ ചേരുന്നു...", + "teamsMissingJoinLink": "മീറ്റിംഗ് ലിങ്ക് ലഭ്യമല്ല" } diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index 319fb14ab..8afa1b987 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -1439,5 +1439,6 @@ "liveStreamStatusLive": "நேரலை", "liveStreamJoinFailed": "கூட்டத்தில் சேர முடியவில்லை. மீண்டும் முயற்சிக்கவும்.", "teamsPermissionRequired": "சேர, கேமரா மற்றும் மைக்ரோஃபோன் அணுகல் தேவை. அமைப்புகளில் அவற்றை இயக்கவும்.", - "teamsJoinMeetingLoading": "கூட்டத்தில் சேர்கிறோம்..." + "teamsJoinMeetingLoading": "கூட்டத்தில் சேர்கிறோம்...", + "teamsMissingJoinLink": "சந்திப்பு இணைப்பு காணவில்லை" } diff --git a/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart b/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart index 04301f628..244cffcf3 100644 --- a/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart +++ b/packages/courses/lib/widgets/lesson_detail/teams_web_view.dart @@ -211,6 +211,10 @@ class TeamsAutoJoinHandler { } } + /// Test-only entry point to simulate a JavaScript channel message. + @visibleForTesting + void simulateJsMessage(String message) => _handleJavaScriptMessage(message); + void dispose() { _isDisposed = true; _hasCompletedAutoJoin = false; diff --git a/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart b/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart index bc2b1fc9d..b4a2cc975 100644 --- a/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart +++ b/packages/courses/lib/widgets/lesson_detail/video_conference_viewer.dart @@ -243,7 +243,15 @@ class VideoConferenceLobbyView extends StatelessWidget { title: L10n.of(context).liveStreamStartTime, subtitle: formattedStart!, ), - if (_isLive) ...[ + if (_isLive && lesson.isTeams && lesson.contentUrl == null) ...[ + const ConferenceDivider(), + ConferenceInfoRow( + icon: LucideIcons.alertCircle, + iconColor: design.colors.warning, + title: L10n.of(context).liveStreamJoinFailed, + subtitle: L10n.of(context).teamsMissingJoinLink, + ), + ] else if (_isLive) ...[ SizedBox(height: design.spacing.lg), AppSemantics.button( label: L10n.of(context).liveStreamAttendClass, diff --git a/packages/courses/test/widgets/teams_auto_join_handler_test.dart b/packages/courses/test/widgets/teams_auto_join_handler_test.dart new file mode 100644 index 000000000..b16d07bb9 --- /dev/null +++ b/packages/courses/test/widgets/teams_auto_join_handler_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:courses/widgets/lesson_detail/teams_web_view.dart'; + +TeamsAutoJoinHandler _buildHandler({ + void Function()? onJoinSuccess, + void Function()? onJoinTimeout, + void Function()? onLoadingStart, + void Function()? onLoadingEnd, +}) { + return TeamsAutoJoinHandler( + onJoinSuccess: onJoinSuccess, + onJoinTimeout: onJoinTimeout, + onLoadingStart: onLoadingStart, + onLoadingEnd: onLoadingEnd, + ); +} + +void main() { + group('TeamsAutoJoinHandler — URL gate detection', () { + const gateUrls = [ + 'https://teams.microsoft.com/l/meetup-join/19%3A/thread', + 'https://teams.microsoft.com/v2/?launcher=false', + 'https://teams.microsoft.com/meet/join/abc123', + 'https://TEAMS.MICROSOFT.COM/L/PRE-JOIN/abc', + ]; + + const nonGateUrls = [ + 'https://teams.microsoft.com/meet/lobby', + 'https://teams.microsoft.com/v2/#/meeting', + 'https://example.com/join', + 'https://zoom.us/j/123456', + '', + ]; + + for (final url in gateUrls) { + test('onPageStarted triggers loading for gate URL: $url', () { + bool loadingStarted = false; + final handler = + _buildHandler(onLoadingStart: () => loadingStarted = true); + handler.onPageStarted(url); + expect(loadingStarted, isTrue); + }); + } + + test('onPageStarted does NOT trigger loading after join is complete', () { + bool loadingStarted = false; + final handler = + _buildHandler(onLoadingStart: () => loadingStarted = true); + handler.simulateJsMessage('onButtonClicked'); + loadingStarted = false; + handler.onPageStarted(gateUrls.first); + expect(loadingStarted, isFalse); + }); + + for (final url in nonGateUrls) { + test('onPageFinished calls onLoadingEnd for non-gate URL: "$url"', () { + bool loadingEnded = false; + final handler = _buildHandler(onLoadingEnd: () => loadingEnded = true); + handler.onPageFinished(url); + expect(loadingEnded, isTrue); + }); + } + }); + + group('TeamsAutoJoinHandler — JS message handling', () { + test('onButtonClicked: loader stays up until next page finishes', () { + bool loadingEnded = false; + bool joinSucceeded = false; + final handler = _buildHandler( + onLoadingEnd: () => loadingEnded = true, + onJoinSuccess: () => joinSucceeded = true, + ); + + handler.simulateJsMessage('onButtonClicked'); + + // Loader must NOT drop immediately after button click + expect(loadingEnded, isFalse); + expect(joinSucceeded, isFalse); + + // Simulate lobby page finishing load + handler.onPageFinished('https://teams.microsoft.com/meet/lobby'); + + expect(loadingEnded, isTrue); + expect(joinSucceeded, isTrue); + }); + + test('onLobbyDetected: drops loader and fires onJoinSuccess immediately', + () { + bool loadingEnded = false; + bool joinSucceeded = false; + final handler = _buildHandler( + onLoadingEnd: () => loadingEnded = true, + onJoinSuccess: () => joinSucceeded = true, + ); + + handler.simulateJsMessage('onLobbyDetected'); + + expect(loadingEnded, isTrue); + expect(joinSucceeded, isTrue); + }); + + test('onAutoJoinTimeout fires onJoinTimeout', () { + bool timedOut = false; + final handler = _buildHandler(onJoinTimeout: () => timedOut = true); + handler.simulateJsMessage('onAutoJoinTimeout'); + expect(timedOut, isTrue); + }); + + test('messages after dispose() are silently ignored', () { + bool joinSucceeded = false; + final handler = _buildHandler(onJoinSuccess: () => joinSucceeded = true); + handler.dispose(); + handler.simulateJsMessage('onLobbyDetected'); + expect(joinSucceeded, isFalse); + }); + }); +}