diff --git a/DESIGN.md b/DESIGN.md index 07a0fe02..3ad3817c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,10 +51,9 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter - Viewer sort is persisted (`streamViewerSort`) -- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session. -- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`. -- **Top / category most viewers (desc):** Helix directory order (paginates) -- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback +- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match) +- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails) +- Tag directories never rely on GQL cursors for load-more ## Build diff --git a/assets/release-notes.md b/assets/release-notes.md index 6d112234..d25f1cef 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,28 +1,27 @@ -## v5.3.1 - -October 12, 2026 - -### Bug Fixes - -- Fixed the player getting stuck on a Source error after the screen turns off or the network switches — it now reloads automatically instead of waiting for a manual refresh. - ---- - -## v5.3.0 - -October 12, 2026 - -### New Features - -- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. - ---- - -## v5.2.9 +## v5.2.8 October 7, 2026 +### New Features + +- Added follow and unfollow for live and offline channels from the long-press actions sheet (needs a linked Twitch web session). + +--- + +## v5.2.7 + +October 1, 2026 + ### Bug Fixes -- Fixed tag stream lists reshuffling when loading more pages by paginating with Twitch’s sorted tag directory (GQL) instead of scanning Helix. -- Fixed follow/unfollow failing with an integrity check error while logged in by confirming via a short Twitch WebView when needed. +- Fixed tag / ASC directories stopping after one page: GQL page-2 fails integrity, so load-more continues via Helix tag filter (deduped) after the true-fewest first page. + +--- + +## v5.2.6 + +October 1, 2026 + +### Bug Fixes + +- Fixed “fewest viewers” showing high-viewer streams by sorting via Twitch GQL `VIEWER_COUNT_ASC` at request time instead of reversing Helix pages locally. diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index f3cc787d..ca9f49f8 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -271,8 +271,8 @@ class TwitchApi extends BaseApiClient { /// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// /// Helix is always viewer-desc and supports at most one `language` query - /// param; tags are filtered client-side. Used as fallback when GQL directory - /// pagination lacks a Client-Integrity token. + /// param; tags are filtered client-side. Prefer this over GQL for pagination + /// (GQL page-2 requires a non-bot Client-Integrity token). /// /// When a [freeformTag] filter yields no matches in a Helix window, this /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 3923d500..0fce183a 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; @@ -32,49 +31,27 @@ class TwitchGqlApi extends BaseApiClient { 'Content-Type': 'application/json', }; - Future _gql( - Map body, { - Map? extraHeaders, - }) async { + Future _gql(Map body) async { final response = await post( '/gql', data: body, - headers: {..._gqlHeaders, ...?extraHeaders}, + headers: _gqlHeaders, ); final errors = response['errors']; if (errors is List && errors.isNotEmpty) { - final message = (errors.first as JsonMap)['message'] as String? ?? - 'Twitch GQL error'; - if (message.toLowerCase().contains('integrity')) { - throw IntegrityCheckException(message); - } final data = response['data']; final dataEmpty = data == null || (data is Map && data.values.every((value) => value == null)); if (dataEmpty) { + final message = (errors.first as JsonMap)['message'] as String? ?? + 'Twitch GQL error'; throw ApiException(message); } } return response; } - /// Optional web-session headers for directory pagination (page-2+). - Map _directoryHeaders({ - String? authToken, - String? integrityToken, - String? deviceId, - }) { - return { - 'User-Agent': playbackUserAgent, - if (authToken != null && authToken.isNotEmpty) - 'Authorization': 'OAuth $authToken', - if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, - if (integrityToken != null && integrityToken.isNotEmpty) - 'Client-Integrity': integrityToken, - }; - } - /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -236,18 +213,15 @@ class TwitchGqlApi extends BaseApiClient { /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// - /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a - /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without - /// them page-2 throws [IntegrityCheckException]. + /// Twitch caps `first` at 30 for this connection. Cursor pagination often + /// fails with `IntegrityCheckFailed` without a web integrity token — callers + /// should treat a failed page-2 as end-of-list. Future getStreams({ List? freeformTags, List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, - String? authToken, - String? integrityToken, - String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -257,31 +231,24 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql( - { - 'operationName': 'FrostyTopStreams', - 'query': - r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' - r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } }', - 'variables': { - 'first': cappedFirst, - 'after': cursor, - 'tags': freeformTags, - 'sort': sort.gqlValue, - 'langs': gqlLangs, - }, + final response = await _gql({ + 'operationName': 'FrostyTopStreams', + 'query': + r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' + r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } }', + 'variables': { + 'first': cappedFirst, + 'after': cursor, + 'tags': freeformTags, + 'sort': sort.gqlValue, + 'langs': gqlLangs, }, - extraHeaders: _directoryHeaders( - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ), - ); + }); final connection = (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; @@ -295,9 +262,6 @@ class TwitchGqlApi extends BaseApiClient { StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, - String? authToken, - String? integrityToken, - String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -307,31 +271,24 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql( - { - 'operationName': 'FrostyGameStreams', - 'query': - r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' - r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } } }', - 'variables': { - 'id': gameId, - 'first': cappedFirst, - 'after': cursor, - 'sort': sort.gqlValue, - 'langs': gqlLangs, - }, + final response = await _gql({ + 'operationName': 'FrostyGameStreams', + 'query': + r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' + r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } } }', + 'variables': { + 'id': gameId, + 'first': cappedFirst, + 'after': cursor, + 'sort': sort.gqlValue, + 'langs': gqlLangs, }, - extraHeaders: _directoryHeaders( - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ), - ); + }); final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; if (game == null) { @@ -397,9 +354,8 @@ class TwitchGqlApi extends BaseApiClient { /// Mints a Client-Integrity token for authenticated GQL mutations. /// /// Bound to [deviceId] and [authToken]; send the same values on follow calls. - /// Tokens minted outside a real browser are usually flagged `is_bad_bot` — - /// those throw [IntegrityCheckException] so callers can fall back to a - /// WebView capture. + /// Twitch may still reject bot-flagged tokens — then the mutation fails with + /// [IntegrityCheckException]. Future<({String token, int expirationMs})> fetchIntegrityToken({ required String authToken, required String deviceId, @@ -421,16 +377,9 @@ class TwitchGqlApi extends BaseApiClient { if (token == null || token.isEmpty) { throw const ApiException('Twitch integrity token missing from response'); } - if (TwitchIntegrity.isBadBot(token)) { - throw const IntegrityCheckException( - 'Twitch integrity token flagged as bad bot', - ); - } final expirationMs = expiration is int ? expiration - : int.tryParse('$expiration') ?? - TwitchIntegrity.expirationMs(token) ?? - 0; + : int.tryParse('$expiration') ?? 0; return (token: token, expirationMs: expirationMs); } diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart deleted file mode 100644 index efef0346..00000000 --- a/lib/apis/twitch_integrity.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:convert'; - -/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`). -class TwitchIntegrity { - const TwitchIntegrity._(); - - /// Decodes the JSON payload embedded in an integrity token, if present. - static Map? decodePayload(String token) { - final parts = token.split('.'); - if (parts.length < 2) return null; - - for (final part in parts.skip(1)) { - if (part.isEmpty) continue; - try { - final normalized = base64Url.normalize(part); - final decoded = utf8.decode(base64Url.decode(normalized)); - final dynamic json = jsonDecode(decoded); - if (json is Map && - (json.containsKey('is_bad_bot') || - json.containsKey('client_id') || - json.containsKey('device_id'))) { - return Map.from(json); - } - } catch (_) { - // Try the next segment — token layout varies by version. - } - } - return null; - } - - /// Dio/`POST /integrity` tokens from non-browser clients are usually flagged. - static bool isBadBot(String token) { - final payload = decodePayload(token); - if (payload == null) return false; - final flag = payload['is_bad_bot']; - if (flag is bool) return flag; - if (flag is String) return flag.toLowerCase() == 'true'; - return false; - } - - /// Expiration in epoch millis from the payload, if present. - static int? expirationMs(String token) { - final payload = decodePayload(token); - if (payload == null) return null; - - final exp = payload['exp']; - if (exp is int) { - // Already millis if large; seconds if typical JWT. - return exp > 100000000000 ? exp : exp * 1000; - } - if (exp is String) { - final asInt = int.tryParse(exp); - if (asInt != null) { - return asInt > 100000000000 ? asInt : asInt * 1000; - } - final dt = DateTime.tryParse(exp); - if (dt != null) return dt.millisecondsSinceEpoch; - } - return null; - } - - /// Device id the token was minted for (`X-Device-Id` must match). - static String? deviceId(String token) { - final payload = decodePayload(token); - final id = payload?['device_id']; - return id is String && id.isNotEmpty ? id : null; - } -} - -/// Credentials captured from a browser/WebView GQL request. -class GqlIntegrityCredentials { - final String integrityToken; - final String deviceId; - final int expirationMs; - - const GqlIntegrityCredentials({ - required this.integrityToken, - required this.deviceId, - required this.expirationMs, - }); -} diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 1013812b..c65c0e4b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,7 +6,6 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart'; -import 'package:frosty/screens/channel/video/channel_info_header.dart'; import 'package:frosty/screens/channel/video/native_video.dart'; import 'package:frosty/screens/channel/video/native_video_store.dart'; import 'package:frosty/screens/channel/video/stream_info_bar.dart'; @@ -747,14 +746,6 @@ class _VideoChatState extends State aspectRatio: 16 / 9, child: SizedBox(), ), - ChannelInfoHeader( - videoStore: _videoStore, - authStore: context.authStore, - userId: widget.userId, - displayName: widget.userName, - isInSharedChatMode: - _chatStore.isInSharedChatMode, - ), ], Expanded(child: chat), ], diff --git a/lib/screens/channel/video/channel_info_header.dart b/lib/screens/channel/video/channel_info_header.dart deleted file mode 100644 index 0cf77aa2..00000000 --- a/lib/screens/channel/video/channel_info_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:frosty/screens/channel/video/stream_info_bar.dart'; -import 'package:frosty/screens/channel/video/video_player_interface.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/follow_button.dart'; - -/// Channel metadata + follow control shown under the player (portrait). -class ChannelInfoHeader extends StatelessWidget { - final VideoPlayerInterface videoStore; - final AuthStore authStore; - final String userId; - final String displayName; - final bool isInSharedChatMode; - - const ChannelInfoHeader({ - super.key, - required this.videoStore, - required this.authStore, - required this.userId, - required this.displayName, - this.isInSharedChatMode = false, - }); - - @override - Widget build(BuildContext context) { - return Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Observer( - builder: (context) { - final streamInfo = videoStore.streamInfo; - final offline = streamInfo == null; - - return Row( - spacing: 8, - children: [ - Expanded( - child: StreamInfoBar( - streamInfo: streamInfo, - offlineChannelInfo: videoStore.offlineChannelInfo, - displayName: displayName, - isCompact: true, - isOffline: offline, - isInSharedChatMode: isInSharedChatMode, - showTextShadows: false, - tooltipTriggerMode: TooltipTriggerMode.longPress, - ), - ), - FollowButton( - authStore: authStore, - userId: userId, - displayName: displayName, - ), - ], - ); - }, - ), - ), - ); - } -} diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index 31077620..1af97005 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -15,7 +15,6 @@ import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/screens/channel/video/chat_latency_sync.dart'; import 'package:frosty/screens/channel/video/native_video_player_interface.dart'; -import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; import 'package:frosty/screens/channel/video/stream_info_poller.dart'; import 'package:frosty/screens/channel/video/video_timing_constants.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; @@ -684,44 +683,26 @@ abstract class NativeVideoStoreBase return; } - // Mid-stream failures (expired HLS session, ExoPlayer "Source error" - // after screen-off / network switch, CDN blips). A fresh token + reload - // usually fixes these — only pin the Reload overlay once retries are - // spent. 4xx refreshes immediately; network/source waits briefly so the - // interface can come back. - if (shouldAutoRecoverFromPlayerError( - refreshCapReached: _refreshCapReached(), - errorCode: errorCode, - )) { + // 4xx on the HLS fetch means the current playback session is gone — a + // fresh GQL token + new query params can land on a different CDN edge + // with a working manifest. Skip the user-facing error and retry + // immediately (handleRefresh updates _lastRefreshTime itself). + if (statusCode != null && + statusCode >= 400 && + statusCode < 500 && + !_refreshCapReached()) { if (_userPaused) { // Don't yank a user-paused stream back to life — the refresh would // reload with autoplay. Defer until they resume. _pendingRefreshOnResume = true; return; } - final attempt = _totalRefreshAttempts + 1; - final delay = playerErrorRetryDelay(statusCode: statusCode); debugPrint( - 'NativeVideoStore: player error' - '${statusCode != null ? ' (HTTP $statusCode)' : ''}' - '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' - 'auto-refresh in ${delay.inMilliseconds}ms ' - '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', + 'NativeVideoStore: $statusCode on HLS fetch, refreshing with fresh ' + 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', ); - _totalRefreshAttempts = attempt; - _loading = true; - _error = null; - _initRetryTimer?.cancel(); - final generation = _initGeneration; - if (delay == Duration.zero) { - _handleRefresh(userInitiated: false); - } else { - _initRetryTimer = Timer(delay, () { - if (_disposed || generation != _initGeneration) return; - // _handleRefresh parks behind _pendingRefreshOnResume when paused. - _handleRefresh(userInitiated: false); - }); - } + _totalRefreshAttempts++; + _handleRefresh(userInitiated: false); return; } @@ -1329,22 +1310,9 @@ abstract class NativeVideoStoreBase if (_isInPipMode) return; _highLatencyCount = 0; updateStreamInfo(forceUpdate: true); - - if (_userPaused || _initializing) return; - - // After screen-off / a network drop the native player is often in an - // error state (ExoPlayer "Source error"). play() alone cannot revive a - // dead session — refresh like the Reload button would. - if (_error != null || - _pendingRefreshOnResume || - _controller == null || - !_controllerInitialized) { - _pendingRefreshOnResume = false; - unawaited(_handleRefresh(userInitiated: false)); - return; + if (!_userPaused && !_initializing && _controller != null) { + _controller!.play(); } - - _controller!.play(); } @override diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart deleted file mode 100644 index 72b83815..00000000 --- a/lib/screens/channel/video/playback_error_recovery.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -/// Whether [NativeVideoStore] should auto-refresh after a mid-stream player -/// error instead of pinning the Reload overlay. -/// -/// ExoPlayer's "Source error" (screen-off / network switch) arrives without a -/// status code, so callers must not rely on 4xx alone. [mediaServicesWereReset] -/// has its own dispose+rebuild path and is excluded here. -bool shouldAutoRecoverFromPlayerError({ - required bool refreshCapReached, - String? errorCode, -}) { - if (refreshCapReached) return false; - if (errorCode == 'mediaServicesWereReset') return false; - return true; -} - -/// Delay before an automatic refresh for [statusCode]. -/// -/// Token/CDN 4xx can refresh immediately; network/source failures get a short -/// pause so the interface can come back after a switch or screen wake. -Duration playerErrorRetryDelay({int? statusCode}) { - if (statusCode != null && statusCode >= 400 && statusCode < 500) { - return Duration.zero; - } - return VideoTimingConstants.initRetryDelay; -} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index f7165dd4..f8e97b69 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -51,13 +51,9 @@ abstract class ListStoreBase with Store { /// The pagination cursor for the streams. String? _streamsCursor; - /// When true, pages come from Helix (GQL cursor/integrity unavailable). - /// ASC Helix fill starts from the top; duplicates are skipped and the list - /// is re-sorted client-side. - var _helixFill = false; - - /// Last page was served by GQL with server-side sort — append in API order. - var _lastPageWasGql = false; + /// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity + /// fails). Helix starts from the top; duplicates are skipped. + var _ascHelixFill = false; /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -68,7 +64,7 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed bool get hasMore => - isLoading == false && (_streamsCursor != null || _helixFill); + isLoading == false && (_streamsCursor != null || _ascHelixFill); @computed bool get isLoading => @@ -259,14 +255,6 @@ abstract class ListStoreBase with Store { } } - /// Prefer GQL for tag directories (both sorts) and for ascending Top/category. - bool get _preferGqlDirectory { - if (twitchGqlApi == null || _helixFill) return false; - if (listType == ListType.tag) return true; - return listType != ListType.followed && - sortOrder == StreamViewerSort.viewersAsc; - } - /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { @@ -277,41 +265,84 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; final isAsc = sortOrder == StreamViewerSort.viewersAsc; - final useGql = _preferGqlDirectory; - _lastPageWasGql = false; + // GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed. + // First empty list → GQL ASC once; further pages → Helix fill + dedupe. + final gqlAscFirstPage = isAsc && + twitchGqlApi != null && + !_ascHelixFill && + _allStreams.isEmpty && + listType != ListType.followed; - StreamsTwitch? newStreams; - if (listType == ListType.followed) { - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - } else if (useGql) { - newStreams = await _fetchGqlDirectory(languages: languages); - if (newStreams != null) { - _lastPageWasGql = true; - } + final StreamsTwitch newStreams; + switch (listType) { + case ListType.followed: + newStreams = await twitchApi.getFollowedStreams( + id: authStore.user.details!.id, + cursor: _streamsCursor, + ); + break; + case ListType.top: + if (gqlAscFirstPage) { + newStreams = await twitchGqlApi!.getStreams( + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + ); + } else { + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + cursor: _streamsCursor, + minCount: isAsc ? 30 : 50, + maxPages: isAsc ? 20 : 12, + ); + } + break; + case ListType.category: + if (gqlAscFirstPage) { + newStreams = await twitchGqlApi!.getGameStreams( + gameId: categoryId!, + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + ); + } else { + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + gameId: categoryId!, + cursor: _streamsCursor, + minCount: isAsc ? 30 : 50, + maxPages: isAsc ? 20 : 12, + ); + } + break; + case ListType.tag: + if (gqlAscFirstPage) { + newStreams = await twitchGqlApi!.getStreams( + freeformTags: [freeformTag!], + sort: StreamViewerSort.viewersAsc, + languages: languages.isEmpty ? null : languages, + ); + } else { + // Desc, and ASC after GQL: Helix + tag filter (paginates). + newStreams = await twitchApi.getDirectoryStreams( + languages: languages, + freeformTag: freeformTag, + cursor: _streamsCursor, + minCount: 40, + maxPages: 25, + ); + } + break; } - // Helix: Top/category DESC, or GQL unavailable / integrity fallback. - newStreams ??= await twitchApi.getDirectoryStreams( - languages: languages, - gameId: listType == ListType.category ? categoryId : null, - freeformTag: listType == ListType.tag ? freeformTag : null, - cursor: _streamsCursor, - minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50), - maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12), - ); - final next = newStreams.pagination['cursor']; - final hasCursor = next != null && next.isNotEmpty; + final hasHelixCursor = next != null && next.isNotEmpty; // Empty Helix window with cursor left ⇒ keep scanning (sparse tags). - final isHelixContinuation = !_lastPageWasGql && - (_streamsCursor != null || - (_helixFill && _allStreams.isNotEmpty)); - if (isHelixContinuation && newStreams.data.isEmpty) { - if (hasCursor) { + final isHelixContinuation = + _streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty); + if (isHelixContinuation && + newStreams.data.isEmpty && + !gqlAscFirstPage) { + if (hasHelixCursor) { _streamsCursor = next; _error = null; _isAllStreamsLoading = false; @@ -319,20 +350,20 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _helixFill = false; + _ascHelixFill = false; _error = null; return; } - // GQL pages are already server-sorted — keep API order. - // Helix ASC fill: sort page + full list so late matches land correctly. - final pageData = (!_lastPageWasGql && isAsc) + // Keep API order for desc. ASC: sanitize page, then full-list sort after + // Helix fill so newly found streams land in fewest-first order. + final pageData = isAsc ? (List.of(newStreams.data)..sort( (a, b) => a.viewerCount.compareTo(b.viewerCount), )) : newStreams.data; - if (_allStreams.isEmpty && !_helixFill) { + if (_allStreams.isEmpty && !_ascHelixFill) { _allStreams = pageData.asObservable(); } else { final existingIds = _allStreams.map((s) => s.userId).toSet(); @@ -341,17 +372,23 @@ abstract class ListStoreBase with Store { ); } - if (!_lastPageWasGql && isAsc && _allStreams.length > 1) { + if (isAsc && _allStreams.length > 1) { final sorted = _allStreams.toList() ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); _allStreams = sorted.asObservable(); } - _streamsCursor = hasCursor ? next : null; - if (_streamsCursor == null) { - _helixFill = false; - } else if (!_lastPageWasGql && isAsc) { - _helixFill = true; + if (gqlAscFirstPage) { + // Ignore GQL cursor — page-2 integrity fails. Fill via Helix next. + _ascHelixFill = true; + _streamsCursor = null; + } else { + _streamsCursor = hasHelixCursor ? next : null; + if (_streamsCursor == null) { + _ascHelixFill = false; + } else if (isAsc) { + _ascHelixFill = true; + } } _error = null; @@ -360,16 +397,15 @@ abstract class ListStoreBase with Store { debugPrint('Streams SocketException: No internet connection'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _helixFill = false; + _ascHelixFill = false; } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); if (_allStreams.isNotEmpty) { - // Mid-scroll GQL failure: fall back to Helix fill when useful. - if (!_helixFill && - (listType == ListType.tag || isAscDirectory)) { - _helixFill = true; + // GQL page-2 integrity or mid-scroll error: fall back to Helix fill. + if (isAscDirectory && !_ascHelixFill) { + _ascHelixFill = true; _streamsCursor = null; _error = null; _isAllStreamsLoading = false; @@ -377,7 +413,7 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _helixFill = false; + _ascHelixFill = false; _error = null; } } catch (e) { @@ -385,7 +421,7 @@ abstract class ListStoreBase with Store { debugPrint('Streams error: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _helixFill = false; + _ascHelixFill = false; _error = null; } } finally { @@ -393,63 +429,6 @@ abstract class ListStoreBase with Store { } } - /// GQL directory page with integrity retry. Returns null to trigger Helix. - Future _fetchGqlDirectory({ - required List languages, - }) async { - final gql = twitchGqlApi; - if (gql == null) return null; - - final langs = languages.isEmpty ? null : languages; - - try { - return await authStore.user.runWithGqlIntegrity( - gqlToken: authStore.gqlToken, - action: ({authToken, integrityToken, deviceId}) { - switch (listType) { - case ListType.tag: - return gql.getStreams( - freeformTags: [freeformTag!], - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.category: - return gql.getGameStreams( - gameId: categoryId!, - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.top: - return gql.getStreams( - sort: sortOrder, - languages: langs, - cursor: _streamsCursor, - authToken: authToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - case ListType.followed: - throw StateError('GQL directory not used for followed'); - } - }, - ); - } on IntegrityCheckException catch (e) { - debugPrint('Streams GQL integrity: $e — falling back to Helix'); - _helixFill = true; - // Keep cursor null so Helix starts from the top (dedupe handles overlap). - _streamsCursor = null; - return null; - } - } - bool get isAscDirectory => sortOrder == StreamViewerSort.viewersAsc && listType != ListType.followed; @@ -535,8 +514,7 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; - _helixFill = false; - _lastPageWasGql = false; + _ascHelixFill = false; await getStreams(); } @@ -582,5 +560,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). +/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page). enum ListType { followed, top, category, tag } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index de29e1da..058ed886 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -130,98 +129,40 @@ abstract class UserStoreBase with Store { required String gqlToken, required bool follow, }) async { - await runWithGqlIntegrity( - gqlToken: gqlToken, - action: ({authToken, integrityToken, deviceId}) { - if (follow) { - return twitchGqlApi.followUser( - targetId: targetId, - authToken: authToken!, - integrityToken: integrityToken, - deviceId: deviceId, - ); - } - return twitchGqlApi.unfollowUser( + final deviceId = await _ensureDeviceId(); + + Future run({String? integrityToken}) { + if (follow) { + return twitchGqlApi.followUser( targetId: targetId, - authToken: authToken!, + authToken: gqlToken, integrityToken: integrityToken, deviceId: deviceId, ); - }, - ); - } - - /// Runs a GQL call with optional Client-Integrity; refreshes once on - /// [IntegrityCheckException] when a web [gqlToken] is available. - /// - /// Dio-minted tokens that are `is_bad_bot` are discarded; callers should - /// then capture credentials via a WebView and [applyWebIntegrity]. - Future runWithGqlIntegrity({ - required String? gqlToken, - required Future Function({ - required String? authToken, - required String? integrityToken, - required String? deviceId, - }) action, - }) async { - final deviceId = await _ensureDeviceId(); - final authToken = - gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null; - - Future run(String? integrityToken) => action( - authToken: authToken, - integrityToken: integrityToken, - deviceId: _deviceId ?? deviceId, - ); - - try { - return await run(_validIntegrityToken()); - } on IntegrityCheckException { - if (authToken == null) { - clearIntegrity(); - rethrow; } - try { - final integrity = await twitchGqlApi.fetchIntegrityToken( - authToken: authToken, - deviceId: _deviceId ?? deviceId, - ); - _integrityToken = integrity.token; - _integrityExpirationMs = integrity.expirationMs; - return await run(integrity.token); - } on IntegrityCheckException { - clearIntegrity(); - rethrow; - } - } - } - - /// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]). - Future applyWebIntegrity(GqlIntegrityCredentials credentials) async { - if (TwitchIntegrity.isBadBot(credentials.integrityToken)) { - clearIntegrity(); - throw const IntegrityCheckException( - 'Twitch integrity token flagged as bad bot', + return twitchGqlApi.unfollowUser( + targetId: targetId, + authToken: gqlToken, + integrityToken: integrityToken, + deviceId: deviceId, ); } - _integrityToken = credentials.integrityToken; - _integrityExpirationMs = credentials.expirationMs; - _deviceId = credentials.deviceId; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_deviceIdKey, credentials.deviceId); - } - void clearIntegrity() { - _integrityToken = null; - _integrityExpirationMs = 0; + try { + await run(integrityToken: _validIntegrityToken()); + } on IntegrityCheckException { + final integrity = await twitchGqlApi.fetchIntegrityToken( + authToken: gqlToken, + deviceId: deviceId, + ); + _integrityToken = integrity.token; + _integrityExpirationMs = integrity.expirationMs; + await run(integrityToken: integrity.token); + } } String? _validIntegrityToken() { if (_integrityToken == null) return null; - if (TwitchIntegrity.isBadBot(_integrityToken!)) { - clearIntegrity(); - return null; - } if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { return null; } @@ -345,7 +286,8 @@ abstract class UserStoreBase with Store { _blockedUsers.clear(); _moderatedChannels.clear(); _followCache.clear(); - clearIntegrity(); + _integrityToken = null; + _integrityExpirationMs = 0; _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart deleted file mode 100644 index c90b8c5a..00000000 --- a/lib/widgets/follow_button.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/screens/onboarding/login_webview.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/widgets/alert_message.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; -import 'package:frosty/widgets/frosty_dialog.dart'; -import 'package:frosty/widgets/integrity_webview_dialog.dart'; - -/// Compact Follow / Unfollow control for channel headers. -class FollowButton extends StatefulWidget { - final AuthStore authStore; - final String userId; - final String displayName; - final bool? initialIsFollowing; - final ValueChanged? onFollowChanged; - - const FollowButton({ - super.key, - required this.authStore, - required this.userId, - required this.displayName, - this.initialIsFollowing, - this.onFollowChanged, - }); - - @override - State createState() => _FollowButtonState(); -} - -class _FollowButtonState extends State { - late final FollowChannelController _controller = FollowChannelController( - authStore: widget.authStore, - userId: widget.userId, - initialIsFollowing: widget.initialIsFollowing, - onChanged: () { - if (mounted) setState(() {}); - }, - ); - - @override - void initState() { - super.initState(); - _controller.init(); - } - - Future _onPressed() async { - if (!_controller.canFollow || - _controller.isFollowing == null || - _controller.actionInProgress) { - return; - } - - final wasFollowing = _controller.isFollowing!; - - try { - final nowFollowing = await _controller.toggle(); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _showResultSnack(nowFollowing); - } on FollowNeedsWebSessionException { - if (!mounted) return; - await _promptLinkWebSession(); - } on IntegrityCheckException { - if (!mounted) return; - await _handleIntegrity(wasFollowing: wasFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.displayName}' - : 'Could not follow ${widget.displayName}', - ); - } - } - - Future _handleIntegrity({required bool wasFollowing}) async { - final gqlToken = widget.authStore.gqlToken; - if (gqlToken == null) { - _controller.clearActionInProgress(); - await _promptLinkWebSession(); - return; - } - - final credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; - - if (credentials == null) { - _controller.clearActionInProgress(); - _showErrorSnack('Follow cancelled — Twitch verification required'); - return; - } - - try { - final nowFollowing = await _controller.retryAfterIntegrity( - wasFollowing: wasFollowing, - credentials: credentials, - ); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _showResultSnack(nowFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.displayName}' - : 'Could not follow ${widget.displayName}', - ); - } - } - - Future _promptLinkWebSession() { - return showDialog( - context: context, - builder: (dialogContext) => FrostyDialog( - title: 'Web session required', - message: - 'Following channels needs your Twitch web session linked. Log in again to link it, then try follow/unfollow.', - actions: [ - TextButton( - onPressed: Navigator.of(dialogContext).pop, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => LoginWebView()), - ); - }, - child: const Text('Log in'), - ), - ], - ), - ); - } - - void _showResultSnack(bool nowFollowing) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage( - message: nowFollowing - ? 'Followed ${widget.displayName}' - : 'Unfollowed ${widget.displayName}', - centered: false, - ), - ), - ); - } - - void _showErrorSnack(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (!_controller.shouldShow) { - return const SizedBox.shrink(); - } - - final following = _controller.isFollowing == true; - final busy = _controller.loading || _controller.actionInProgress; - - return FilledButton.tonal( - onPressed: busy ? null : _onPressed, - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Row( - mainAxisSize: MainAxisSize.min, - spacing: 6, - children: [ - Icon( - following - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - size: 16, - ), - Text(following ? 'Unfollow' : 'Follow'), - ], - ), - ); - } -} diff --git a/lib/widgets/follow_channel_controller.dart b/lib/widgets/follow_channel_controller.dart deleted file mode 100644 index 46c45ff2..00000000 --- a/lib/widgets/follow_channel_controller.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; - -/// Shared follow/unfollow state + GQL mutations for channel UI. -/// -/// Owns loading/action flags and the session cache via [AuthStore.user]. -/// Callers handle UI (integrity WebView, login prompt, snackbars). -class FollowChannelController { - final AuthStore authStore; - final String userId; - final bool? initialIsFollowing; - final void Function() onChanged; - - bool? isFollowing; - bool loading = false; - bool actionInProgress = false; - - FollowChannelController({ - required this.authStore, - required this.userId, - this.initialIsFollowing, - required this.onChanged, - }); - - bool get isSelf => - authStore.user.details?.id != null && - authStore.user.details!.id == userId; - - bool get canFollow => authStore.isLoggedIn && !isSelf; - - /// Whether the follow control should be visible (loading or known state). - bool get shouldShow => canFollow && (loading || isFollowing != null); - - void init() { - if (!canFollow) return; - if (initialIsFollowing != null) { - isFollowing = initialIsFollowing; - onChanged(); - return; - } - loadFollowState(); - } - - Future loadFollowState() async { - final cached = authStore.user.cachedIsFollowing(userId); - if (cached != null) { - isFollowing = cached; - onChanged(); - return; - } - - loading = true; - onChanged(); - try { - final following = await authStore.user.isFollowingChannel(userId); - isFollowing = following; - } catch (_) { - // Leave isFollowing null so the control stays hidden. - } finally { - loading = false; - onChanged(); - } - } - - /// Toggles follow. Returns the new following state on success. - /// - /// Throws [IntegrityCheckException] when a WebView integrity mint is needed, - /// [ApiException] for API errors, or [StateError] when not ready / no token. - Future toggle() async { - if (actionInProgress || isFollowing == null) { - throw StateError('Follow toggle not ready'); - } - - final gqlToken = authStore.gqlToken; - if (gqlToken == null) { - throw const FollowNeedsWebSessionException(); - } - - final following = isFollowing!; - actionInProgress = true; - onChanged(); - - try { - await _runMutation(following: following, gqlToken: gqlToken); - } catch (_) { - actionInProgress = false; - onChanged(); - rethrow; - } - - final nowFollowing = !following; - isFollowing = nowFollowing; - actionInProgress = false; - onChanged(); - return nowFollowing; - } - - /// After a WebView integrity mint, apply credentials and retry [toggle]'s - /// intended mutation (still based on the pre-toggle [wasFollowing] state). - Future retryAfterIntegrity({ - required bool wasFollowing, - required GqlIntegrityCredentials credentials, - }) async { - final gqlToken = authStore.gqlToken; - if (gqlToken == null) { - throw const FollowNeedsWebSessionException(); - } - - actionInProgress = true; - onChanged(); - - try { - await authStore.user.applyWebIntegrity(credentials); - await _runMutation(following: wasFollowing, gqlToken: gqlToken); - } catch (_) { - actionInProgress = false; - onChanged(); - rethrow; - } - - final nowFollowing = !wasFollowing; - isFollowing = nowFollowing; - actionInProgress = false; - onChanged(); - return nowFollowing; - } - - Future _runMutation({ - required bool following, - required String gqlToken, - }) async { - if (following) { - await authStore.user.unfollow(targetId: userId, gqlToken: gqlToken); - } else { - await authStore.user.follow(targetId: userId, gqlToken: gqlToken); - } - } - - void clearActionInProgress() { - if (!actionInProgress) return; - actionInProgress = false; - onChanged(); - } -} - -/// Thrown when follow needs a linked Twitch web (GQL) session. -class FollowNeedsWebSessionException implements Exception { - const FollowNeedsWebSessionException(); -} diff --git a/lib/widgets/integrity_webview_dialog.dart b/lib/widgets/integrity_webview_dialog.dart deleted file mode 100644 index 9d9d9054..00000000 --- a/lib/widgets/integrity_webview_dialog.dart +++ /dev/null @@ -1,232 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a -/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`). -/// -/// Returns credentials, or null if the user cancels / capture times out. -Future showIntegrityWebViewDialog({ - required BuildContext context, - required String gqlToken, -}) { - return showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken), - ); -} - -class IntegrityWebViewDialog extends StatefulWidget { - final String gqlToken; - - const IntegrityWebViewDialog({super.key, required this.gqlToken}); - - @override - State createState() => _IntegrityWebViewDialogState(); -} - -class _IntegrityWebViewDialogState extends State { - static const _captureTimeout = Duration(seconds: 45); - - late final WebViewController _controller; - Timer? _timeout; - var _captured = false; - var _status = 'Verifying with Twitch…'; - - @override - void initState() { - super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setUserAgent( - Platform.isIOS - ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' - 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 ' - 'Mobile/15E148 Safari/604.1' - : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' - '(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', - ) - ..addJavaScriptChannel( - 'FrostyIntegrity', - onMessageReceived: _onIntegrityMessage, - ) - ..setNavigationDelegate( - NavigationDelegate( - onPageStarted: (_) => _injectHooks(), - onPageFinished: (_) => _injectHooks(), - ), - ); - - _prepareAndLoad(); - _timeout = Timer(_captureTimeout, () { - if (!mounted || _captured) return; - setState(() => _status = 'Timed out waiting for Twitch. Try again.'); - }); - } - - Future _prepareAndLoad() async { - try { - final cookies = WebViewCookieManager(); - await cookies.setCookie( - WebViewCookie( - name: 'auth-token', - value: widget.gqlToken, - domain: '.twitch.tv', - ), - ); - } catch (e) { - debugPrint('IntegrityWebView: cookie set failed: $e'); - } - - // Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL - // requests that carry a browser-valid Client-Integrity header. - await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login')); - } - - Future _injectHooks() async { - // Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic. - const script = r''' -(function () { - if (window.__frostyIntegrityHooked) return; - window.__frostyIntegrityHooked = true; - - function report(headers) { - if (!headers || !window.FrostyIntegrity) return; - var integrity = headers['Client-Integrity'] || headers['client-integrity']; - if (!integrity) return; - var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || ''; - var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || ''; - window.FrostyIntegrity.postMessage(JSON.stringify({ - integrity: integrity, - deviceId: deviceId, - clientId: clientId - })); - } - - function headersToObject(headers) { - var out = {}; - if (!headers) return out; - if (typeof Headers !== 'undefined' && headers instanceof Headers) { - headers.forEach(function (value, key) { out[key] = value; }); - return out; - } - if (typeof headers === 'object') { - Object.keys(headers).forEach(function (key) { out[key] = headers[key]; }); - } - return out; - } - - var originalFetch = window.fetch; - window.fetch = function (input, init) { - try { - report(headersToObject(init && init.headers)); - } catch (e) {} - return originalFetch.apply(this, arguments); - }; - - var originalOpen = XMLHttpRequest.prototype.open; - var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader; - var originalSend = XMLHttpRequest.prototype.send; - XMLHttpRequest.prototype.open = function () { - this.__frostyHeaders = {}; - return originalOpen.apply(this, arguments); - }; - XMLHttpRequest.prototype.setRequestHeader = function (key, value) { - try { this.__frostyHeaders[key] = value; } catch (e) {} - return originalSetHeader.apply(this, arguments); - }; - XMLHttpRequest.prototype.send = function () { - try { report(this.__frostyHeaders || {}); } catch (e) {} - return originalSend.apply(this, arguments); - }; -})(); -'''; - - try { - await _controller.runJavaScript(script); - } catch (e) { - debugPrint('IntegrityWebView: hook inject failed: $e'); - } - } - - void _onIntegrityMessage(JavaScriptMessage message) { - if (_captured || !mounted) return; - - try { - final map = jsonDecode(message.message) as Map; - final integrity = map['integrity'] as String?; - if (integrity == null || integrity.isEmpty) return; - if (TwitchIntegrity.isBadBot(integrity)) { - debugPrint('IntegrityWebView: ignoring bad_bot token'); - return; - } - - final fromHeader = map['deviceId'] as String?; - final fromToken = TwitchIntegrity.deviceId(integrity); - final deviceId = (fromHeader != null && fromHeader.isNotEmpty) - ? fromHeader - : (fromToken ?? ''); - if (deviceId.isEmpty) { - debugPrint('IntegrityWebView: missing device id'); - return; - } - - final expiration = TwitchIntegrity.expirationMs(integrity) ?? - DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds; - - _captured = true; - _timeout?.cancel(); - Navigator.of(context).pop( - GqlIntegrityCredentials( - integrityToken: integrity, - deviceId: deviceId, - expirationMs: expiration, - ), - ); - } catch (e) { - debugPrint('IntegrityWebView: parse failed: $e'); - } - } - - @override - void dispose() { - _timeout?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final height = MediaQuery.sizeOf(context).height * 0.55; - return AlertDialog( - title: const Text('Confirm with Twitch'), - content: SizedBox( - width: double.maxFinite, - height: height.clamp(280.0, 520.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(_status), - const SizedBox(height: 12), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: WebViewWidget(controller: _controller), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ], - ); - } -} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index 19bc1f90..f1d1c8fa 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -6,9 +6,7 @@ import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; -import 'package:frosty/widgets/integrity_webview_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -48,121 +46,126 @@ class UserActionsModal extends StatefulWidget { } class _UserActionsModalState extends State { - late final FollowChannelController _follow = FollowChannelController( - authStore: widget.authStore, - userId: widget.userId, - initialIsFollowing: widget.initialIsFollowing, - onChanged: () { - if (mounted) setState(() {}); - }, - ); + bool? _isFollowing; + bool _followLoading = false; + bool _followActionInProgress = false; + + bool get _isSelf => + widget.authStore.user.details?.id != null && + widget.authStore.user.details!.id == widget.userId; + + bool get _canFollow => + widget.authStore.isLoggedIn && !_isSelf; @override void initState() { super.initState(); - _follow.init(); + if (!_canFollow) return; + if (widget.initialIsFollowing != null) { + _isFollowing = widget.initialIsFollowing; + } else { + _loadFollowState(); + } + } + + Future _loadFollowState() async { + final cached = widget.authStore.user.cachedIsFollowing(widget.userId); + if (cached != null) { + setState(() => _isFollowing = cached); + return; + } + + setState(() => _followLoading = true); + try { + final following = await widget.authStore.user.isFollowingChannel( + widget.userId, + ); + if (mounted) { + setState(() { + _isFollowing = following; + _followLoading = false; + }); + } + } catch (_) { + if (mounted) { + setState(() => _followLoading = false); + } + } } Future _toggleFollow() async { - if (!_follow.canFollow || - _follow.isFollowing == null || - _follow.actionInProgress) { - return; - } + if (_followActionInProgress || _isFollowing == null) return; - final wasFollowing = _follow.isFollowing!; - - try { - final nowFollowing = await _follow.toggle(); - if (!mounted) return; - widget.onFollowChanged?.call(nowFollowing); - _popWithSnack( - nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - ); - } on FollowNeedsWebSessionException { - if (!mounted) return; - await _promptLinkWebSession(); - } on IntegrityCheckException { - if (!mounted) return; - await _handleIntegrity(wasFollowing: wasFollowing); - } on ApiException catch (e) { - if (!mounted) return; - _showErrorSnack(e.message); - } catch (_) { - if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', - ); - } - } - - Future _handleIntegrity({required bool wasFollowing}) async { final gqlToken = widget.authStore.gqlToken; if (gqlToken == null) { - _follow.clearActionInProgress(); await _promptLinkWebSession(); return; } - final credentials = await showIntegrityWebViewDialog( - context: context, - gqlToken: gqlToken, - ); - if (!mounted) return; - - if (credentials == null) { - _follow.clearActionInProgress(); - _showErrorSnack('Follow cancelled — Twitch verification required'); - return; - } + final following = _isFollowing!; + setState(() => _followActionInProgress = true); try { - final nowFollowing = await _follow.retryAfterIntegrity( - wasFollowing: wasFollowing, - credentials: credentials, - ); + if (following) { + await widget.authStore.user.unfollow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } else { + await widget.authStore.user.follow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); widget.onFollowChanged?.call(nowFollowing); - _popWithSnack( - nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', + + final messenger = ScaffoldMessenger.maybeOf(context); + Navigator.pop(context); + messenger?.showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), + ), ); } on ApiException catch (e) { if (!mounted) return; - _showErrorSnack(e.message); + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: e.message, + centered: false, + ), + ), + ); } catch (_) { if (!mounted) return; - _showErrorSnack( - wasFollowing - ? 'Could not unfollow ${widget.name}' - : 'Could not follow ${widget.name}', + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: following + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + centered: false, + ), + ), ); } } - void _popWithSnack(String message) { - final messenger = ScaffoldMessenger.maybeOf(context); - Navigator.pop(context); - messenger?.showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - - void _showErrorSnack(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: AlertMessage(message: message, centered: false), - ), - ); - } - Future _promptLinkWebSession() { return showDialog( context: context, @@ -191,7 +194,7 @@ class _UserActionsModalState extends State { @override Widget build(BuildContext context) { - final showFollowTile = _follow.shouldShow; + final showFollowTile = _canFollow && (_followLoading || _isFollowing != null); return ListView( primary: false, @@ -222,23 +225,23 @@ class _UserActionsModalState extends State { ), if (showFollowTile) ListTile( - leading: _follow.loading || _follow.actionInProgress + leading: _followLoading || _followActionInProgress ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( - _follow.isFollowing == true + _isFollowing == true ? Icons.heart_broken_rounded : Icons.favorite_rounded, ), title: Text( - _follow.isFollowing == true + _isFollowing == true ? 'Unfollow ${widget.name}' : 'Follow ${widget.name}', ), - enabled: !_follow.loading && !_follow.actionInProgress, + enabled: !_followLoading && !_followActionInProgress, onTap: _toggleFollow, ), if (widget.authStore.isLoggedIn) diff --git a/pubspec.yaml b/pubspec.yaml index 1899d872..559d5f51 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.3.1+81 +version: 5.2.8+78 environment: sdk: ^3.9.0 diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart index 00efa120..0e4bb171 100644 --- a/test/apis/twitch_gql_follow_test.dart +++ b/test/apis/twitch_gql_follow_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; @@ -71,32 +69,4 @@ void main() { await api.unfollowUser(targetId: '999', authToken: 'web-token'); }); }); - - group('fetchIntegrityToken', () { - test('rejects bad_bot tokens', () async { - final payload = base64Url.encode( - utf8.encode( - jsonEncode({ - 'is_bad_bot': 'true', - 'device_id': 'dev', - 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', - }), - ), - ); - - dioAdapter.onPost( - 'https://gql.twitch.tv/integrity', - (server) => server.reply(200, { - 'token': 'v4.public.$payload.sig', - 'expiration': DateTime.now().millisecondsSinceEpoch + 600000, - }), - data: Matchers.any, - ); - - await expectLater( - api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'), - throwsA(isA()), - ); - }); - }); } diff --git a/test/apis/twitch_gql_streams_test.dart b/test/apis/twitch_gql_streams_test.dart deleted file mode 100644 index 00888e9f..00000000 --- a/test/apis/twitch_gql_streams_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:http_mock_adapter/http_mock_adapter.dart'; - -void main() { - late Dio dio; - late DioAdapter dioAdapter; - late TwitchGqlApi api; - - setUp(() { - dio = Dio(BaseOptions()); - dioAdapter = DioAdapter(dio: dio); - api = TwitchGqlApi(dio); - }); - - Map streamEdge({ - required String id, - required String login, - required int viewers, - String? cursor, - }) { - return { - 'cursor': cursor ?? 'cursor-$id', - 'node': { - 'id': id, - 'title': '$login live', - 'viewersCount': viewers, - 'createdAt': '2026-01-01T00:00:00Z', - 'language': 'en', - 'game': {'id': '1', 'name': 'Game'}, - 'broadcaster': { - 'id': id, - 'login': login, - 'displayName': login, - }, - }, - }; - } - - group('getStreams', () { - test('parses freeform tag page and cursor', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'streams': { - 'edges': [ - streamEdge(id: '1', login: 'a', viewers: 10), - streamEdge(id: '2', login: 'b', viewers: 5), - ], - 'pageInfo': {'hasNextPage': true}, - }, - }, - }), - data: Matchers.any, - ); - - final page = await api.getStreams( - freeformTags: ['Deutsch'], - sort: StreamViewerSort.viewersAsc, - ); - - expect(page.data.map((s) => s.userLogin), ['a', 'b']); - expect(page.data.map((s) => s.viewerCount), [10, 5]); - expect(page.pagination['cursor'], 'cursor-2'); - }); - - test('throws IntegrityCheckException on integrity errors', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'errors': [ - {'message': 'failed integrity check'}, - ], - 'data': {'streams': null}, - }), - data: Matchers.any, - ); - - await expectLater( - api.getStreams( - freeformTags: ['Deutsch'], - cursor: 'page-2', - authToken: 'web', - integrityToken: 'stale', - deviceId: 'device', - ), - throwsA(isA()), - ); - }); - - test('sends Client-Integrity and X-Device-Id when provided', () async { - dioAdapter.onPost( - 'https://gql.twitch.tv/gql', - (server) => server.reply(200, { - 'data': { - 'streams': { - 'edges': [streamEdge(id: '1', login: 'a', viewers: 1)], - 'pageInfo': {'hasNextPage': false}, - }, - }, - }), - data: Matchers.any, - headers: { - 'Client-ID': Matchers.any, - 'Content-Type': Matchers.any, - 'User-Agent': Matchers.any, - 'Authorization': 'OAuth web-token', - 'X-Device-Id': 'dev-123', - 'Client-Integrity': 'integrity-token', - }, - ); - - final page = await api.getStreams( - freeformTags: ['English'], - sort: StreamViewerSort.viewersDesc, - cursor: 'after-1', - authToken: 'web-token', - integrityToken: 'integrity-token', - deviceId: 'dev-123', - ); - - expect(page.data, hasLength(1)); - expect(page.pagination, isEmpty); - }); - }); -} diff --git a/test/apis/twitch_integrity_test.dart b/test/apis/twitch_integrity_test.dart deleted file mode 100644 index a07ec031..00000000 --- a/test/apis/twitch_integrity_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; - -String _tokenFor(Map payload) { - final encoded = base64Url.encode(utf8.encode(jsonEncode(payload))); - return 'v4.public.$encoded.fakesig'; -} - -void main() { - group('TwitchIntegrity', () { - test('detects is_bad_bot string flag', () { - final token = _tokenFor({ - 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', - 'device_id': 'abc123', - 'is_bad_bot': 'true', - 'exp': '2099-01-01T00:00:00Z', - }); - - expect(TwitchIntegrity.isBadBot(token), isTrue); - expect(TwitchIntegrity.deviceId(token), 'abc123'); - expect(TwitchIntegrity.expirationMs(token), isNotNull); - }); - - test('detects is_bad_bot bool flag', () { - final token = _tokenFor({ - 'is_bad_bot': true, - 'device_id': 'dev', - }); - expect(TwitchIntegrity.isBadBot(token), isTrue); - }); - - test('returns false when not flagged', () { - final token = _tokenFor({ - 'is_bad_bot': 'false', - 'device_id': 'dev', - }); - expect(TwitchIntegrity.isBadBot(token), isFalse); - }); - }); -} diff --git a/test/screens/channel/video/playback_error_recovery_test.dart b/test/screens/channel/video/playback_error_recovery_test.dart deleted file mode 100644 index adc80903..00000000 --- a/test/screens/channel/video/playback_error_recovery_test.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/screens/channel/video/playback_error_recovery.dart'; -import 'package:frosty/screens/channel/video/video_timing_constants.dart'; - -void main() { - group('shouldAutoRecoverFromPlayerError', () { - test('recovers Source error / generic failures under the refresh cap', () { - expect( - shouldAutoRecoverFromPlayerError(refreshCapReached: false), - isTrue, - ); - expect( - shouldAutoRecoverFromPlayerError( - refreshCapReached: false, - errorCode: null, - ), - isTrue, - ); - }); - - test('stops once the refresh cap is reached', () { - expect( - shouldAutoRecoverFromPlayerError(refreshCapReached: true), - isFalse, - ); - }); - - test('defers mediaServicesWereReset to its dedicated rebuild path', () { - expect( - shouldAutoRecoverFromPlayerError( - refreshCapReached: false, - errorCode: 'mediaServicesWereReset', - ), - isFalse, - ); - }); - }); - - group('playerErrorRetryDelay', () { - test('refreshes 4xx token failures immediately', () { - expect(playerErrorRetryDelay(statusCode: 403), Duration.zero); - expect(playerErrorRetryDelay(statusCode: 404), Duration.zero); - }); - - test('waits briefly for network / Source errors without status', () { - expect( - playerErrorRetryDelay(), - VideoTimingConstants.initRetryDelay, - ); - expect( - playerErrorRetryDelay(statusCode: 500), - VideoTimingConstants.initRetryDelay, - ); - }); - }); -} diff --git a/test/screens/home/stream_list/stream_list_store_test.dart b/test/screens/home/stream_list/stream_list_store_test.dart deleted file mode 100644 index 1a066c66..00000000 --- a/test/screens/home/stream_list/stream_list_store_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/models/stream.dart'; -import 'package:frosty/models/user.dart'; -import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/settings_store.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class MockTwitchApi extends Mock implements TwitchApi {} - -class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} - -void main() { - setUpAll(() { - registerFallbackValue(StreamViewerSort.viewersDesc); - registerFallbackValue([]); - }); - - late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; - late AuthStore authStore; - late SettingsStore settingsStore; - - StreamTwitch stream(String id, int viewers) => StreamTwitch( - id, - 'user$id', - 'User$id', - 'game', - 'Game', - 'title', - viewers, - '2026-01-01T00:00:00Z', - 'https://cdn/live_user_$id-{width}x{height}.jpg', - ); - - Future waitUntilIdle(ListStore store) async { - await Future.delayed(Duration.zero); - for (var i = 0; i < 50 && store.isLoading; i++) { - await Future.delayed(const Duration(milliseconds: 5)); - } - } - - setUp(() { - SharedPreferences.setMockInitialValues({}); - api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); - authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi); - settingsStore = SettingsStore()..streamLanguages = []; - - when(() => api.getUserInfo()).thenAnswer( - (_) async => const UserTwitch( - '123', - 'me', - 'Me', - 'https://cdn/me.png', - ), - ); - when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( - (_) async => [], - ); - }); - - group('ListStore tag GQL pagination', () { - test('keeps server ASC order across GQL pages', () async { - when( - () => gqlApi.getStreams( - freeformTags: any(named: 'freeformTags'), - languages: any(named: 'languages'), - sort: any(named: 'sort'), - cursor: any(named: 'cursor'), - first: any(named: 'first'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((invocation) async { - final cursor = invocation.namedArguments[#cursor] as String?; - if (cursor == null) { - return StreamsTwitch( - [stream('1', 3), stream('2', 7)], - const {'cursor': 'c1'}, - ); - } - expect(cursor, 'c1'); - return StreamsTwitch( - [stream('3', 12), stream('4', 20)], - const {}, - ); - }); - - settingsStore.streamViewerSort = StreamViewerSort.viewersAsc; - - final store = ListStore( - authStore: authStore, - settingsStore: settingsStore, - twitchApi: api, - twitchGqlApi: gqlApi, - listType: ListType.tag, - freeformTag: 'Deutsch', - ); - - await waitUntilIdle(store); - - expect(store.streams.map((s) => s.userId), ['1', '2']); - expect(store.hasMore, isTrue); - - await store.getStreams(); - await waitUntilIdle(store); - - expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']); - expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]); - expect(store.hasMore, isFalse); - verifyNever( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ); - - store.dispose(); - }); - - test('falls back to Helix when GQL page-2 integrity fails', () async { - var gqlCalls = 0; - when( - () => gqlApi.getStreams( - freeformTags: any(named: 'freeformTags'), - languages: any(named: 'languages'), - sort: any(named: 'sort'), - cursor: any(named: 'cursor'), - first: any(named: 'first'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((invocation) async { - gqlCalls++; - final cursor = invocation.namedArguments[#cursor] as String?; - if (cursor == null) { - return StreamsTwitch( - [stream('1', 100)], - const {'cursor': 'c1'}, - ); - } - throw const IntegrityCheckException('failed integrity check'); - }); - - when( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ).thenAnswer( - (_) async => StreamsTwitch( - [stream('1', 100), stream('9', 50)], - const {}, - ), - ); - - final store = ListStore( - authStore: authStore, - settingsStore: settingsStore, - twitchApi: api, - twitchGqlApi: gqlApi, - listType: ListType.tag, - freeformTag: 'Deutsch', - ); - - await waitUntilIdle(store); - expect(store.streams.map((s) => s.userId), ['1']); - - await store.getStreams(); - await waitUntilIdle(store); - - expect(gqlCalls, greaterThanOrEqualTo(2)); - expect(store.streams.map((s) => s.userId), ['1', '9']); - verify( - () => api.getDirectoryStreams( - languages: any(named: 'languages'), - gameId: any(named: 'gameId'), - freeformTag: any(named: 'freeformTag'), - cursor: any(named: 'cursor'), - minCount: any(named: 'minCount'), - maxPages: any(named: 'maxPages'), - ), - ).called(1); - - store.dispose(); - }); - }); -} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 4897d3ad..9d11c922 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,10 +1,7 @@ -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:mocktail/mocktail.dart'; @@ -181,88 +178,5 @@ void main() { expect(userStore.cachedIsFollowing('999'), isFalse); }); - - test('runWithGqlIntegrity refreshes token and retries', () async { - var calls = 0; - when( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer( - (_) async => ( - token: 'fresh-integrity', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - final userStore = makeStore(hasModeratorScopes: false); - final result = await userStore.runWithGqlIntegrity( - gqlToken: 'web-token', - action: ({authToken, integrityToken, deviceId}) async { - calls++; - expect(authToken, 'web-token'); - expect(deviceId, isNotEmpty); - if (calls == 1) { - throw const IntegrityCheckException('failed integrity check'); - } - expect(integrityToken, 'fresh-integrity'); - return 'ok'; - }, - ); - - expect(result, 'ok'); - expect(calls, 2); - verify( - () => gqlApi.fetchIntegrityToken( - authToken: 'web-token', - deviceId: any(named: 'deviceId'), - ), - ).called(1); - }); - - test('runWithGqlIntegrity rethrows when no gqlToken', () async { - final userStore = makeStore(hasModeratorScopes: false); - - await expectLater( - userStore.runWithGqlIntegrity( - gqlToken: null, - action: ({authToken, integrityToken, deviceId}) async { - throw const IntegrityCheckException('failed integrity check'); - }, - ), - throwsA(isA()), - ); - verifyNever( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ); - }); - - test('applyWebIntegrity stores device id for later GQL calls', () async { - final userStore = makeStore(hasModeratorScopes: false); - await userStore.applyWebIntegrity( - GqlIntegrityCredentials( - integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'false', - 'device_id': 'webview-device', - })))}.sig', - deviceId: 'webview-device', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - final deviceId = await userStore.runWithGqlIntegrity( - gqlToken: 'web-token', - action: ({authToken, integrityToken, deviceId}) async { - expect(deviceId, 'webview-device'); - expect(integrityToken, isNotNull); - return deviceId!; - }, - ); - expect(deviceId, 'webview-device'); - }); }); } diff --git a/test/widgets/follow_channel_controller_test.dart b/test/widgets/follow_channel_controller_test.dart deleted file mode 100644 index bdc88570..00000000 --- a/test/widgets/follow_channel_controller_test.dart +++ /dev/null @@ -1,254 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:frosty/apis/base_api_client.dart'; -import 'package:frosty/apis/twitch_api.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; -import 'package:frosty/apis/twitch_integrity.dart'; -import 'package:frosty/models/user.dart'; -import 'package:frosty/screens/settings/stores/auth_store.dart'; -import 'package:frosty/screens/settings/stores/user_store.dart'; -import 'package:frosty/widgets/follow_channel_controller.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class MockAuthStore extends Mock implements AuthStore {} - -class MockTwitchApi extends Mock implements TwitchApi {} - -class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} - -void main() { - late MockAuthStore authStore; - late MockTwitchApi api; - late MockTwitchGqlApi gqlApi; - late UserStore userStore; - var notifyCount = 0; - - setUp(() async { - SharedPreferences.setMockInitialValues({}); - authStore = MockAuthStore(); - api = MockTwitchApi(); - gqlApi = MockTwitchGqlApi(); - notifyCount = 0; - - when(() => api.getUserInfo()).thenAnswer( - (_) async => const UserTwitch( - '12345', - 'testuser', - 'TestUser', - 'https://cdn/profile.png', - ), - ); - when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( - (_) async => [], - ); - - userStore = UserStore( - twitchApi: api, - twitchGqlApi: gqlApi, - hasModeratorScopes: () => false, - ); - await userStore.init(); - - when(() => authStore.user).thenReturn(userStore); - when(() => authStore.isLoggedIn).thenReturn(true); - when(() => authStore.gqlToken).thenReturn('web-token'); - }); - - FollowChannelController makeController({ - String userId = '999', - bool? initialIsFollowing, - }) { - return FollowChannelController( - authStore: authStore, - userId: userId, - initialIsFollowing: initialIsFollowing, - onChanged: () => notifyCount++, - ); - } - - group('FollowChannelController', () { - test('hides when logged out', () { - when(() => authStore.isLoggedIn).thenReturn(false); - final controller = makeController(initialIsFollowing: false); - controller.init(); - - expect(controller.canFollow, isFalse); - expect(controller.shouldShow, isFalse); - }); - - test('hides for own channel', () { - final controller = makeController(userId: '12345'); - controller.init(); - - expect(controller.isSelf, isTrue); - expect(controller.canFollow, isFalse); - expect(controller.shouldShow, isFalse); - }); - - test('uses initialIsFollowing without Helix lookup', () { - final controller = makeController(initialIsFollowing: true); - controller.init(); - - expect(controller.isFollowing, isTrue); - expect(controller.shouldShow, isTrue); - verifyNever( - () => api.isFollowingChannel( - userId: any(named: 'userId'), - broadcasterId: any(named: 'broadcasterId'), - ), - ); - }); - - test('loads follow state from Helix and caches', () async { - when( - () => api.isFollowingChannel( - userId: '12345', - broadcasterId: '999', - ), - ).thenAnswer((_) async => false); - - final controller = makeController(); - controller.init(); - await Future.delayed(Duration.zero); - - expect(controller.isFollowing, isFalse); - expect(controller.loading, isFalse); - expect(controller.shouldShow, isTrue); - expect(userStore.cachedIsFollowing('999'), isFalse); - }); - - test('toggle follows then unfollows', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - when( - () => gqlApi.unfollowUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - expect(await controller.toggle(), isTrue); - expect(controller.isFollowing, isTrue); - expect(userStore.cachedIsFollowing('999'), isTrue); - - expect(await controller.toggle(), isFalse); - expect(controller.isFollowing, isFalse); - expect(userStore.cachedIsFollowing('999'), isFalse); - }); - - test('toggle throws when web session missing', () async { - when(() => authStore.gqlToken).thenReturn(null); - final controller = makeController(initialIsFollowing: false); - controller.init(); - - await expectLater( - controller.toggle(), - throwsA(isA()), - ); - }); - - test('toggle rethrows integrity and clears action flag', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenThrow(const IntegrityCheckException('failed integrity check')); - when( - () => gqlApi.fetchIntegrityToken( - authToken: any(named: 'authToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenThrow(const IntegrityCheckException('bad bot')); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - await expectLater( - controller.toggle(), - throwsA(isA()), - ); - expect(controller.actionInProgress, isFalse); - expect(controller.isFollowing, isFalse); - }); - - test('retryAfterIntegrity applies credentials and follows', () async { - when( - () => gqlApi.followUser( - targetId: any(named: 'targetId'), - authToken: any(named: 'authToken'), - integrityToken: any(named: 'integrityToken'), - deviceId: any(named: 'deviceId'), - ), - ).thenAnswer((_) async {}); - - final controller = makeController(initialIsFollowing: false); - controller.init(); - - final okToken = - 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'false', - 'device_id': 'device-abc', - })))}.sig'; - - final nowFollowing = await controller.retryAfterIntegrity( - wasFollowing: false, - credentials: GqlIntegrityCredentials( - integrityToken: okToken, - deviceId: 'device-abc', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ); - - expect(nowFollowing, isTrue); - expect(controller.isFollowing, isTrue); - verify( - () => gqlApi.followUser( - targetId: '999', - authToken: 'web-token', - integrityToken: okToken, - deviceId: 'device-abc', - ), - ).called(1); - }); - - test('retryAfterIntegrity rejects bad_bot integrity', () async { - final controller = makeController(initialIsFollowing: false); - controller.init(); - - final badToken = - 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ - 'is_bad_bot': 'true', - 'device_id': 'device-abc', - })))}.sig'; - - await expectLater( - controller.retryAfterIntegrity( - wasFollowing: false, - credentials: GqlIntegrityCredentials( - integrityToken: badToken, - deviceId: 'device-abc', - expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, - ), - ), - throwsA(isA()), - ); - expect(controller.actionInProgress, isFalse); - }); - }); -}