diff --git a/DESIGN.md b/DESIGN.md index ba1df2ad..07a0fe02 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,11 +49,12 @@ Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widge Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. -- One language: Helix `language=` (paginates cleanly, `first=100`) -- Several languages: Helix over-fetch + client filter (still paginated) -- Tags / ascending sort: GQL with `languages: [Language!]`; page-2 often hits Twitch integrity — treated as end-of-list after the first page of 30 - -Also fixed infinite scroll: larger Helix pages, defensive cursors, scroll near-end load-more, and stop disposing shared Home scroll controllers. +- 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 ## Build diff --git a/assets/release-notes.md b/assets/release-notes.md index 26387850..6d112234 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,77 +1,28 @@ -## v5.2.1 +## v5.3.1 -September 30, 2026 - -### New Features - -- Added TwitchAdSolutions-style ad blocking for the native player (and best-effort WebView inject), with a Settings toggle. -- Added Forgejo Obtainium release builds for this fork. - ---- - -## v5.2.0 - -August 16, 2026 - -### New Features - -- Added moderator actions to delete messages, timeout, ban, and unban users from the message long-press menu and user panel (by @francislavoie on GitHub). -- Added channel bubbles in Twitch shared chat, so you can focus one participant or hide their messages. - -### Improvements - -- Improved the default video player by switching to native. It handles Picture-in-Picture, quality selection, and latency better than the legacy WebView player. -- Improved native player stability across device rotation (by @francislavoie on GitHub). -- [Android] Improved native player latency for live streams (by @francislavoie on GitHub). +October 12, 2026 ### Bug Fixes -- Fixed the video player getting stuck paused with a play button that did nothing after leaving and returning to a stream. -- Fixed long-presses missing in fast-moving chat (by @francislavoie on GitHub). +- 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.1.0 +## v5.3.0 -June 13, 2026 +October 12, 2026 ### New Features -- Added a new experimental native video player option with quality selection, audio-only playback, Picture-in-Picture support, and improved latency recovery. -- Added merged chat mode for multi-tab chat, so messages from active tabs can be viewed together. -- Added new chat settings for keeping the screen on and showing timestamps on historical messages. -- Added support for linking your Twitch web session so the native player can use authenticated playback on eligible channels. - -### Improvements - -- Improved chat send reliability, especially when chat delay is enabled. -- Improved chat delay sync so control messages, acknowledgements, and reconnect notices are not delayed with chat messages. -- Improved video stability around refreshes, stalls, high latency, Picture-in-Picture, fullscreen, and ad breaks. -- Redesigned chat tabs with unread indicators, connection state styling, and disconnect options. - -### Bug Fixes - -- Fixed recent messages failing on valueless or escaped IRC tags. -- Fixed shared chat badges and emotes lingering after leaving shared chat. -- Fixed recent searches not saving from type-to-search. -- Fixed auto-synced chat delay not applying when toggled on mid-session. -- Fixed long chat inputs overlapping the message list. -- Fixed a video refresh race that could leave a black screen with only the play button visible. -- Fixed fullscreen button and system UI state getting out of sync. -- Fixed landscape safe area and iPad gesture padding issues. -- Fixed Twitch/API auth requests that could hang instead of failing cleanly. +- Added a Follow / Unfollow button under the player (between video and chat) for live and offline channels. --- -## v5.0.5 +## v5.2.9 -January 9, 2026 +October 7, 2026 ### Bug Fixes -- Fixed lag/frame drops in overlay chat mode. -- Fixed draggable divider still interactable in fullscreen mode. -- Fixed landscape bottom padding in chat bottom bar. -- [iOS] Fixed user agent breaking login. - -#### See all release notes at [https://github.com/tommyxchow/frosty/releases](https://github.com/tommyxchow/frosty/releases) +- 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. diff --git a/lib/apis/base_api_client.dart b/lib/apis/base_api_client.dart index 4fd3745c..87163d84 100644 --- a/lib/apis/base_api_client.dart +++ b/lib/apis/base_api_client.dart @@ -44,6 +44,13 @@ class UnauthorizedException extends ApiException { : super(message, 401, DioExceptionType.badResponse); } +/// Twitch GQL rejected the request for a missing/invalid Client-Integrity token. +class IntegrityCheckException extends ApiException { + const IntegrityCheckException([ + String message = 'Twitch integrity check failed', + ]) : super(message); +} + /// Base API client to eliminate code duplication across API services abstract class BaseApiClient { final Dio _dio; diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 23cec738..f3cc787d 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -229,6 +229,25 @@ class TwitchApi extends BaseApiClient { return FollowedChannels.fromJson(data); } + /// Whether [userId] follows [broadcasterId], via Helix (read-only; create/delete + /// follows were removed from the public API). + Future isFollowingChannel({ + required String userId, + required String broadcasterId, + }) async { + final data = await get( + '/channels/followed', + queryParameters: { + 'user_id': userId, + 'broadcaster_id': broadcasterId, + 'first': '1', + }, + ); + + final list = data['data'] as List? ?? const []; + return list.isNotEmpty; + } + /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, @@ -248,29 +267,46 @@ class TwitchApi extends BaseApiClient { return StreamsTwitch.fromJson(data); } - /// Fetches Helix stream pages until [minCount] streams match [languages] - /// (or the directory is exhausted). Preserves Helix viewer order. + /// Fetches Helix directory pages until enough streams match [languages] and + /// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// - /// Used when the user selected more than one language — Helix only accepts a - /// single `language` query param. - Future getStreamsMatchingLanguages({ - required List languages, + /// 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. + /// + /// When a [freeformTag] filter yields no matches in a Helix window, this + /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so + /// callers can continue — an empty match set alone is not end-of-list. + Future getDirectoryStreams({ + List languages = const [], String? gameId, + String? freeformTag, String? cursor, - int minCount = 40, - int maxPages = 8, + int minCount = 50, + int maxPages = 12, }) async { - final wanted = languages + final wantedLangs = languages .map((l) => l.trim().toLowerCase()) .where((l) => l.isNotEmpty) .toSet(); - if (wanted.isEmpty) { - return gameId == null - ? getTopStreams(cursor: cursor) - : getStreamsUnderCategory(gameId: gameId, cursor: cursor); + final tagNeedle = freeformTag?.trim().toLowerCase(); + final hasTag = tagNeedle != null && tagNeedle.isNotEmpty; + + bool matches(StreamTwitch stream) { + if (wantedLangs.isNotEmpty && + !wantedLangs.contains(stream.language.toLowerCase())) { + return false; + } + if (hasTag) { + final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle); + if (!hit) return false; + } + return true; } - if (wanted.length == 1) { - final lang = wanted.first; + + // Fast path: no tag filter and 0–1 languages → one Helix call. + if (!hasTag && wantedLangs.length <= 1) { + final lang = wantedLangs.isEmpty ? null : wantedLangs.first; return gameId == null ? getTopStreams(cursor: cursor, language: lang) : getStreamsUnderCategory( @@ -280,23 +316,36 @@ class TwitchApi extends BaseApiClient { ); } + // Sparse tags need a deeper Helix scan per request. + final effectiveMin = hasTag ? minCount.clamp(1, 100) : minCount; + final effectiveMax = hasTag ? (maxPages < 25 ? 25 : maxPages) : maxPages; + + // When filtering by tag and exactly one language, still pass language to + // Helix so each page is denser in matches. + final helixLanguage = + wantedLangs.length == 1 ? wantedLangs.first : null; + final matched = []; String? pageCursor = cursor; String? nextCursor; var pages = 0; - while (matched.length < minCount && pages < maxPages) { + while (matched.length < effectiveMin && pages < effectiveMax) { final page = gameId == null - ? await getTopStreams(cursor: pageCursor) - : await getStreamsUnderCategory(gameId: gameId, cursor: pageCursor); + ? await getTopStreams(cursor: pageCursor, language: helixLanguage) + : await getStreamsUnderCategory( + gameId: gameId, + cursor: pageCursor, + language: helixLanguage, + ); pages++; nextCursor = page.pagination['cursor']; for (final stream in page.data) { - if (wanted.contains(stream.language.toLowerCase())) { - matched.add(stream); - } + if (matches(stream)) matched.add(stream); } - if (nextCursor == null || page.data.isEmpty) { + if (nextCursor == null || + nextCursor.isEmpty || + page.data.isEmpty) { nextCursor = null; break; } @@ -305,10 +354,28 @@ class TwitchApi extends BaseApiClient { return StreamsTwitch( matched, - nextCursor != null ? {'cursor': nextCursor} : const {}, + nextCursor != null && nextCursor.isNotEmpty + ? {'cursor': nextCursor} + : const {}, ); } + /// Deprecated name — use [getDirectoryStreams]. + Future getStreamsMatchingLanguages({ + required List languages, + String? gameId, + String? cursor, + int minCount = 40, + int maxPages = 8, + }) => + getDirectoryStreams( + languages: languages, + gameId: gameId, + cursor: cursor, + minCount: minCount, + maxPages: maxPages, + ); + /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({required String userLogin}) async { final data = await get( diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index beb88194..3923d500 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,6 +3,7 @@ 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'; @@ -31,27 +32,49 @@ class TwitchGqlApi extends BaseApiClient { 'Content-Type': 'application/json', }; - Future _gql(Map body) async { + Future _gql( + Map body, { + Map? extraHeaders, + }) async { final response = await post( '/gql', data: body, - headers: _gqlHeaders, + headers: {..._gqlHeaders, ...?extraHeaders}, ); 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 @@ -213,15 +236,18 @@ class TwitchGqlApi extends BaseApiClient { /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// - /// 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. + /// 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]. 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 @@ -231,24 +257,31 @@ 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 {}; @@ -262,6 +295,9 @@ 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 @@ -271,24 +307,31 @@ 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) { @@ -297,6 +340,176 @@ class TwitchGqlApi extends BaseApiClient { return _streamsFromConnection(game['streams'] as JsonMap? ?? const {}); } + /// Persisted hashes used by the Twitch web Follow button (same as Xtra). + static const _followUserHash = + '800e7346bdf7e5278a3c1d3f21b2b56e2639928f86815677a7126b093b2fdd08'; + static const _unfollowUserHash = + 'f7dae976ebf41c755ae2d758546bfd176b4eeb856656098bb40e0a672ca0d880'; + + /// Follows [targetId] using the web GQL session. Helix no longer supports this. + /// + /// Requires [authToken] from the Twitch web cookie (`gqlToken`). Some accounts + /// need a [integrityToken] + matching [deviceId]; callers should retry after + /// [fetchIntegrityToken] when [IntegrityCheckException] is thrown. + Future followUser({ + required String targetId, + required String authToken, + bool disableNotifications = false, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_FollowUser', + sha256Hash: _followUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': { + 'disableNotifications': disableNotifications, + 'targetID': targetId, + }, + }, + successPath: const ['followUser'], + ); + } + + /// Unfollows [targetId] using the web GQL session. + Future unfollowUser({ + required String targetId, + required String authToken, + String? integrityToken, + String? deviceId, + }) async { + await _mutateFollow( + operationName: 'FollowButton_UnfollowUser', + sha256Hash: _unfollowUserHash, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + variables: { + 'input': {'targetID': targetId}, + }, + successPath: const ['unfollowUser'], + ); + } + + /// 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. + Future<({String token, int expirationMs})> fetchIntegrityToken({ + required String authToken, + required String deviceId, + }) async { + final response = await post( + '/integrity', + data: {}, + headers: { + 'Client-ID': twitchGqlClientId, + 'Authorization': 'OAuth $authToken', + 'X-Device-Id': deviceId, + 'User-Agent': playbackUserAgent, + 'Content-Type': 'application/json', + }, + ); + + final token = response['token'] as String?; + final expiration = response['expiration']; + 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; + return (token: token, expirationMs: expirationMs); + } + + Future _mutateFollow({ + required String operationName, + required String sha256Hash, + required String authToken, + required JsonMap variables, + required List successPath, + String? integrityToken, + String? deviceId, + }) async { + final headers = { + 'Client-ID': twitchGqlClientId, + 'Content-Type': 'application/json', + 'Authorization': 'OAuth $authToken', + 'User-Agent': playbackUserAgent, + if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, + if (integrityToken != null && integrityToken.isNotEmpty) + 'Client-Integrity': integrityToken, + }; + + final response = await post( + '/gql', + data: { + 'operationName': operationName, + 'variables': variables, + 'extensions': { + 'persistedQuery': { + 'version': 1, + 'sha256Hash': sha256Hash, + }, + }, + }, + headers: headers, + ); + + final map = _gqlPayloadMap(response); + _throwIfGqlFailed(map); + + JsonMap? cursor = map['data'] as JsonMap?; + for (final key in successPath) { + final next = cursor?[key]; + if (next is JsonMap) { + cursor = next; + } else if (next == null) { + throw ApiException('Twitch $operationName returned no data'); + } else { + return; + } + } + } + + JsonMap _gqlPayloadMap(dynamic response) { + if (response is List) { + if (response.isEmpty || response.first is! Map) { + throw const ApiException('Unexpected Twitch GQL response'); + } + return Map.from(response.first as Map); + } + if (response is Map) { + return Map.from(response); + } + throw const ApiException('Unexpected Twitch GQL response'); + } + + void _throwIfGqlFailed(JsonMap map) { + final errors = map['errors']; + if (errors is! List || errors.isEmpty) return; + + final message = + (errors.first as JsonMap)['message'] as String? ?? 'Twitch GQL error'; + final lower = message.toLowerCase(); + if (lower.contains('integrity')) { + throw IntegrityCheckException(message); + } + throw ApiException(message); + } + StreamsTwitch _streamsFromConnection(JsonMap connection) { final edges = (connection['edges'] as List?) ?? const []; final pageInfo = connection['pageInfo'] as JsonMap?; @@ -340,6 +553,7 @@ class TwitchGqlApi extends BaseApiClient { // Helix-style placeholder so existing thumbnail widgets keep working. 'https://static-cdn.jtvnw.net/previews-ttv/live_user_$login-{width}x{height}.jpg', language, + const [], ); } } diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart new file mode 100644 index 00000000..efef0346 --- /dev/null +++ b/lib/apis/twitch_integrity.dart @@ -0,0 +1,81 @@ +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/main.dart b/lib/main.dart index dd221dff..b0b87d00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -167,7 +167,10 @@ void main() async { ); // Create and initialize the authentication store - final authStore = AuthStore(twitchApi: twitchApiService); + final authStore = AuthStore( + twitchApi: twitchApiService, + twitchGqlApi: twitchGqlApiService, + ); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); diff --git a/lib/models/stream.dart b/lib/models/stream.dart index 8e09ffed..34a35c60 100644 --- a/lib/models/stream.dart +++ b/lib/models/stream.dart @@ -19,6 +19,10 @@ class StreamTwitch { @JsonKey(defaultValue: '') final String language; + /// Freeform tags from Helix (e.g. `Deutsch`, `English`). + @JsonKey(defaultValue: []) + final List tags; + const StreamTwitch( this.userId, this.userLogin, @@ -30,6 +34,7 @@ class StreamTwitch { this.startedAt, this.thumbnailUrl, [ this.language = '', + this.tags = const [], ]); factory StreamTwitch.fromJson(Map json) => diff --git a/lib/models/stream.g.dart b/lib/models/stream.g.dart index 4d012271..95f9bff3 100644 --- a/lib/models/stream.g.dart +++ b/lib/models/stream.g.dart @@ -17,6 +17,7 @@ StreamTwitch _$StreamTwitchFromJson(Map json) => StreamTwitch( json['started_at'] as String, json['thumbnail_url'] as String, json['language'] as String? ?? '', + (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], ); StreamsTwitch _$StreamsTwitchFromJson(Map json) => diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index c65c0e4b..1013812b 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -6,6 +6,7 @@ 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'; @@ -746,6 +747,14 @@ 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 new file mode 100644 index 00000000..0cf77aa2 --- /dev/null +++ b/lib/screens/channel/video/channel_info_header.dart @@ -0,0 +1,63 @@ +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 1af97005..31077620 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -15,6 +15,7 @@ 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'; @@ -683,26 +684,44 @@ abstract class NativeVideoStoreBase return; } - // 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()) { + // 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, + )) { 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: $statusCode on HLS fetch, refreshing with fresh ' - 'token (attempt ${_totalRefreshAttempts + 1}/${VideoTimingConstants.maxRefreshAttempts})', + 'NativeVideoStore: player error' + '${statusCode != null ? ' (HTTP $statusCode)' : ''}' + '${errorMessage.isNotEmpty ? ' — $errorMessage' : ''}, ' + 'auto-refresh in ${delay.inMilliseconds}ms ' + '(attempt $attempt/${VideoTimingConstants.maxRefreshAttempts})', ); - _totalRefreshAttempts++; - _handleRefresh(userInitiated: false); + _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); + }); + } return; } @@ -1310,9 +1329,22 @@ abstract class NativeVideoStoreBase if (_isInPipMode) return; _highLatencyCount = 0; updateStreamInfo(forceUpdate: true); - if (!_userPaused && !_initializing && _controller != null) { - _controller!.play(); + + 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; } + + _controller!.play(); } @override diff --git a/lib/screens/channel/video/playback_error_recovery.dart b/lib/screens/channel/video/playback_error_recovery.dart new file mode 100644 index 00000000..72b83815 --- /dev/null +++ b/lib/screens/channel/video/playback_error_recovery.dart @@ -0,0 +1,27 @@ +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/search/tag_streams.dart b/lib/screens/home/search/tag_streams.dart index 52b28194..c78c9051 100644 --- a/lib/screens/home/search/tag_streams.dart +++ b/lib/screens/home/search/tag_streams.dart @@ -1,28 +1,24 @@ import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/stream_sort_button.dart'; +import 'package:provider/provider.dart'; /// Streams matching a freeform tag (search → tap tag). -class TagStreams extends StatefulWidget { +class TagStreams extends StatelessWidget { static const routeName = 'TagStreams'; final String tagName; const TagStreams({super.key, required this.tagName}); - @override - State createState() => _TagStreamsState(); -} - -class _TagStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; - @override Widget build(BuildContext context) { final theme = Theme.of(context); + final settingsStore = context.watch(); return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, @@ -31,12 +27,13 @@ class _TagStreamsState extends State { body: Stack( children: [ Positioned.fill( - child: StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.tag, - freeformTag: widget.tagName, - sortOrder: _sortOrder, - showJumpButton: true, + child: Observer( + builder: (_) => StreamsList( + listType: ListType.tag, + freeformTag: tagName, + sortOrder: settingsStore.streamViewerSort, + showJumpButton: true, + ), ), ), Positioned( @@ -61,15 +58,18 @@ class _TagStreamsState extends State { ), Expanded( child: Text( - widget.tagName, + tagName, style: theme.textTheme.titleLarge, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: settingsStore.streamViewerSort, + onChanged: (order) => + settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/home/stream_list/large_stream_card.dart b/lib/screens/home/stream_list/large_stream_card.dart index f7d9f115..ab09ce96 100644 --- a/lib/screens/home/stream_list/large_stream_card.dart +++ b/lib/screens/home/stream_list/large_stream_card.dart @@ -20,6 +20,8 @@ class LargeStreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const LargeStreamCard({ super.key, @@ -28,6 +30,8 @@ class LargeStreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -95,6 +99,8 @@ class LargeStreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/offline_channel_card.dart b/lib/screens/home/stream_list/offline_channel_card.dart index 799a0c74..2fe17d46 100644 --- a/lib/screens/home/stream_list/offline_channel_card.dart +++ b/lib/screens/home/stream_list/offline_channel_card.dart @@ -15,6 +15,8 @@ class OfflineChannelCard extends StatelessWidget { final bool showPinOption; final bool? isPinned; final bool showOfflineStatus; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const OfflineChannelCard({ super.key, @@ -22,6 +24,8 @@ class OfflineChannelCard extends StatelessWidget { required this.showPinOption, this.isPinned, this.showOfflineStatus = false, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -77,6 +81,8 @@ class OfflineChannelCard extends StatelessWidget { userId: channelInfo.broadcasterId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_card.dart b/lib/screens/home/stream_list/stream_card.dart index 6c7a10c5..0ffa914c 100644 --- a/lib/screens/home/stream_list/stream_card.dart +++ b/lib/screens/home/stream_list/stream_card.dart @@ -26,6 +26,8 @@ class StreamCard extends StatelessWidget { final bool showCategory; final bool showPinOption; final bool? isPinned; + final bool? initialIsFollowing; + final ValueChanged? onFollowChanged; const StreamCard({ super.key, @@ -34,6 +36,8 @@ class StreamCard extends StatelessWidget { this.showCategory = true, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, }); @override @@ -224,6 +228,8 @@ class StreamCard extends StatelessWidget { userId: streamInfo.userId, showPinOption: showPinOption, isPinned: isPinned, + initialIsFollowing: initialIsFollowing, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index 8dfde208..f7165dd4 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -51,6 +51,14 @@ 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; + /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -59,7 +67,8 @@ 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; + bool get hasMore => + isLoading == false && (_streamsCursor != null || _helixFill); @computed bool get isLoading => @@ -214,11 +223,18 @@ abstract class ListStoreBase with Store { _getCategoryDetails(); } - // Reload directory lists when the language filter changes. + // Reload directory lists when the language filter or sort changes. if (listType != ListType.followed) { + sortOrder = settingsStore.streamViewerSort; _languageFilterReactionDisposer = reaction( - (_) => settingsStore.streamLanguages.toList(), - (_) => refreshStreams(), + (_) => [ + ...settingsStore.streamLanguages, + settingsStore.streamViewerSort, + ], + (_) { + sortOrder = settingsStore.streamViewerSort; + refreshStreams(); + }, ); } @@ -243,6 +259,14 @@ 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 { @@ -252,99 +276,116 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; - final StreamsTwitch newStreams; - switch (listType) { - case ListType.followed: - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - break; - case ListType.top: - // Ascending sort needs GQL (Helix is always desc). - if (sortOrder == StreamViewerSort.viewersAsc && - twitchGqlApi != null) { - newStreams = await twitchGqlApi!.getStreams( - sort: sortOrder, - languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, - ); - } else { - newStreams = await twitchApi.getStreamsMatchingLanguages( - languages: languages, - cursor: _streamsCursor, - ); - } - break; - case ListType.category: - if (sortOrder == StreamViewerSort.viewersAsc && - twitchGqlApi != null) { - newStreams = await twitchGqlApi!.getGameStreams( - gameId: categoryId!, - sort: sortOrder, - languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, - ); - } else { - newStreams = await twitchApi.getStreamsMatchingLanguages( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - ); - } - break; - case ListType.tag: - newStreams = await twitchGqlApi!.getStreams( - freeformTags: [freeformTag!], - sort: sortOrder, - languages: languages.isEmpty ? null : languages, - cursor: _streamsCursor, - ); - break; + final isAsc = sortOrder == StreamViewerSort.viewersAsc; + final useGql = _preferGqlDirectory; + _lastPageWasGql = false; + + 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; + } } - // GQL page-2 often returns empty `data` under IntegrityCheckFailed - // without throwing — treat empty continuation pages as end-of-list. - final isContinuation = _streamsCursor != null; - if (isContinuation && newStreams.data.isEmpty) { + // 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; + + // 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) { + _streamsCursor = next; + _error = null; + _isAllStreamsLoading = false; + await getStreams(); + return; + } _streamsCursor = null; + _helixFill = false; _error = null; return; } - if (_streamsCursor == null) { - _allStreams = newStreams.data.asObservable(); + // 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) + ? (List.of(newStreams.data)..sort( + (a, b) => a.viewerCount.compareTo(b.viewerCount), + )) + : newStreams.data; + + if (_allStreams.isEmpty && !_helixFill) { + _allStreams = pageData.asObservable(); } else { - // Dedupe in case of overlapping over-fetch pages. final existingIds = _allStreams.map((s) => s.userId).toSet(); _allStreams.addAll( - newStreams.data.where((s) => !existingIds.contains(s.userId)), + pageData.where((s) => !existingIds.contains(s.userId)), ); } - final next = newStreams.pagination['cursor']; - _streamsCursor = (next != null && next.isNotEmpty) ? next : null; + + if (!_lastPageWasGql && 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; + } _error = null; } on SocketException { _error = 'Unable to connect to Twitch'; debugPrint('Streams SocketException: No internet connection'); - // Stop retry storms on failed continuations. if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; + _helixFill = false; } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); - if (_streamsCursor != null && _allStreams.isNotEmpty) { + if (_allStreams.isNotEmpty) { + // Mid-scroll GQL failure: fall back to Helix fill when useful. + if (!_helixFill && + (listType == ListType.tag || isAscDirectory)) { + _helixFill = true; + _streamsCursor = null; + _error = null; + _isAllStreamsLoading = false; + await getStreams(); + return; + } _streamsCursor = null; - // Don't surface integrity / page-2 failures as a full-list error. - if (_allStreams.isNotEmpty) _error = null; + _helixFill = false; + _error = null; } } catch (e) { _error = 'Something went wrong loading streams'; debugPrint('Streams error: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; + _helixFill = false; _error = null; } } finally { @@ -352,12 +393,71 @@ abstract class ListStoreBase with Store { } } - /// Changes viewer sort and reloads from the first page. + /// 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; + + /// Persists viewer sort and reloads via the settings reaction. @action Future setSortOrder(StreamViewerSort order) async { - if (sortOrder == order) return; - sortOrder = order; - await refreshStreams(); + settingsStore.streamViewerSort = order; } @action @@ -435,6 +535,8 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; + _helixFill = false; + _lastPageWasGql = false; await getStreams(); } @@ -480,5 +582,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 the list of streams matching a freeform tag (GQL). +/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). enum ListType { followed, top, category, tag } diff --git a/lib/screens/home/stream_list/streams_list.dart b/lib/screens/home/stream_list/streams_list.dart index e24e6812..46c1ffc1 100644 --- a/lib/screens/home/stream_list/streams_list.dart +++ b/lib/screens/home/stream_list/streams_list.dart @@ -260,6 +260,12 @@ class _StreamsListState extends State : _listStore.streams; final isFollowingTab = widget.listType == ListType.followed; + // Refresh following lists after unfollow so live/offline rows drop. + void onFollowChanged(bool following) { + if (isFollowingTab && !following) { + _listStore.refreshStreams(); + } + } return Stack( alignment: AlignmentDirectional.bottomCenter, @@ -320,6 +326,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ) : StreamCard( key: ValueKey(stream.userId), @@ -331,6 +340,9 @@ class _StreamsListState extends State widget.categoryId == null, showPinOption: true, isPinned: true, + initialIsFollowing: true, + onFollowChanged: + onFollowChanged, ); }, ); @@ -373,6 +385,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ) : StreamCard( key: ValueKey( @@ -384,6 +399,9 @@ class _StreamsListState extends State showCategory: widget.categoryId == null, showPinOption: isFollowingTab, isPinned: false, + initialIsFollowing: + isFollowingTab ? true : null, + onFollowChanged: onFollowChanged, ), ); }, @@ -432,6 +450,8 @@ class _StreamsListState extends State _listStore.offlineChannels[index], showPinOption: false, isPinned: false, + initialIsFollowing: true, + onFollowChanged: onFollowChanged, ), ); }, diff --git a/lib/screens/home/top/categories/category_streams.dart b/lib/screens/home/top/categories/category_streams.dart index 69d165f1..d5a1edf0 100644 --- a/lib/screens/home/top/categories/category_streams.dart +++ b/lib/screens/home/top/categories/category_streams.dart @@ -27,7 +27,7 @@ class CategoryStreams extends StatefulWidget { } class _CategoryStreamsState extends State { - var _sortOrder = StreamViewerSort.viewersDesc; + late final _settingsStore = context.read(); late final _listStore = ListStore( listType: ListType.category, @@ -35,7 +35,8 @@ class _CategoryStreamsState extends State { authStore: context.read(), twitchApi: context.read(), twitchGqlApi: context.read(), - settingsStore: context.read(), + settingsStore: _settingsStore, + sortOrder: _settingsStore.streamViewerSort, ); @override @@ -50,12 +51,13 @@ class _CategoryStreamsState extends State { children: [ // Stream list content behind the pinned elements Positioned.fill( - child: StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.category, - categoryId: widget.categoryId, - sortOrder: _sortOrder, - showJumpButton: true, + child: Observer( + builder: (_) => StreamsList( + listType: ListType.category, + categoryId: widget.categoryId, + sortOrder: _settingsStore.streamViewerSort, + showJumpButton: true, + ), ), ), // Single blurred background spanning app bar and category card @@ -84,10 +86,12 @@ class _CategoryStreamsState extends State { onPressed: Navigator.of(context).pop, ), const Spacer(), - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => - setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: _settingsStore.streamViewerSort, + onChanged: (order) => + _settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/home/top/top.dart b/lib/screens/home/top/top.dart index 467ead4d..5f4d51a1 100644 --- a/lib/screens/home/top/top.dart +++ b/lib/screens/home/top/top.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/home/home_store.dart'; import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart'; import 'package:frosty/screens/home/top/categories/categories.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/stream_sort_button.dart'; +import 'package:provider/provider.dart'; class TopSection extends StatefulWidget { final HomeStore homeStore; @@ -19,7 +21,6 @@ class TopSection extends StatefulWidget { class _TopSectionState extends State with SingleTickerProviderStateMixin { late final _tabBarController = TabController(length: 2, vsync: this); - var _sortOrder = StreamViewerSort.viewersDesc; @override void initState() { @@ -32,6 +33,7 @@ class _TopSectionState extends State @override Widget build(BuildContext context) { + final settingsStore = context.watch(); final showSort = _tabBarController.index == 0; return Stack( @@ -40,12 +42,13 @@ class _TopSectionState extends State child: TabBarView( controller: _tabBarController, children: [ - StreamsList( - key: ValueKey(_sortOrder), - listType: ListType.top, - sortOrder: _sortOrder, - scrollController: - widget.homeStore.topSectionScrollControllers[0], + Observer( + builder: (_) => StreamsList( + listType: ListType.top, + sortOrder: settingsStore.streamViewerSort, + scrollController: + widget.homeStore.topSectionScrollControllers[0], + ), ), Categories( scrollController: @@ -78,9 +81,12 @@ class _TopSectionState extends State ), ), if (showSort) - StreamSortButton( - sortOrder: _sortOrder, - onChanged: (order) => setState(() => _sortOrder = order), + Observer( + builder: (_) => StreamSortButton( + sortOrder: settingsStore.streamViewerSort, + onChanged: (order) => + settingsStore.streamViewerSort = order, + ), ), ], ), diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index d0be4504..d4581732 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.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/constants.dart'; import 'package:frosty/main.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; @@ -41,6 +42,9 @@ abstract class AuthBase with Store { /// The Twitch API service for making requests. final TwitchApi twitchApi; + /// Web GQL client (follow/unfollow + playback tokens). + final TwitchGqlApi twitchGqlApi; + /// Timer used to retry authentication when offline or on transient failures. Timer? _reconnectTimer; @@ -259,9 +263,13 @@ abstract class AuthBase with Store { ); } - AuthBase({required this.twitchApi}) { + AuthBase({ + required this.twitchApi, + required this.twitchGqlApi, + }) { user = UserStore( twitchApi: twitchApi, + twitchGqlApi: twitchGqlApi, hasModeratorScopes: () => hasModeratorScopes, ); } diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 3c2a3583..031b2c53 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -1,3 +1,4 @@ +import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:mobx/mobx.dart'; @@ -60,6 +61,16 @@ abstract class _SettingsStoreBase with Store { @observable var streamLanguages = defaultStreamLanguages; + /// Persistent viewer sort for Top / category / tag directories. + static const defaultStreamViewerSort = StreamViewerSort.viewersDesc; + + @JsonKey( + defaultValue: defaultStreamViewerSort, + unknownEnumValue: StreamViewerSort.viewersDesc, + ) + @observable + var streamViewerSort = defaultStreamViewerSort; + @action void resetGeneralSettings() { themeType = defaultThemeType; @@ -70,6 +81,7 @@ abstract class _SettingsStoreBase with Store { launchUrlExternal = defaultLaunchUrlExternal; streamLanguages = defaultStreamLanguages; + streamViewerSort = defaultStreamViewerSort; } // * Video Settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index be512a98..797941cf 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -25,6 +25,13 @@ SettingsStore _$SettingsStoreFromJson( ?.map((e) => e as String) .toList() ?? ['de'] + ..streamViewerSort = + $enumDecodeNullable( + _$StreamViewerSortEnumMap, + json['streamViewerSort'], + unknownValue: StreamViewerSort.viewersDesc, + ) ?? + StreamViewerSort.viewersDesc ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false ..useTextureRendering = json['useTextureRendering'] as bool? ?? true @@ -110,6 +117,7 @@ Map _$SettingsStoreToJson( 'largeStreamCard': instance.largeStreamCard, 'launchUrlExternal': instance.launchUrlExternal, 'streamLanguages': instance.streamLanguages, + 'streamViewerSort': _$StreamViewerSortEnumMap[instance.streamViewerSort]!, 'showVideo': instance.showVideo, 'defaultToHighestQuality': instance.defaultToHighestQuality, 'useTextureRendering': instance.useTextureRendering, @@ -165,6 +173,11 @@ const _$ThemeTypeEnumMap = { ThemeType.dark: 'dark', }; +const _$StreamViewerSortEnumMap = { + StreamViewerSort.viewersDesc: 'viewersDesc', + StreamViewerSort.viewersAsc: 'viewersAsc', +}; + const _$TimestampTypeEnumMap = { TimestampType.disabled: 'disabled', TimestampType.twelve: 'twelve', @@ -302,6 +315,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$streamViewerSortAtom = Atom( + name: '_SettingsStoreBase.streamViewerSort', + context: context, + ); + + @override + StreamViewerSort get streamViewerSort { + _$streamViewerSortAtom.reportRead(); + return super.streamViewerSort; + } + + @override + set streamViewerSort(StreamViewerSort value) { + _$streamViewerSortAtom.reportWrite(value, super.streamViewerSort, () { + super.streamViewerSort = value; + }); + } + late final _$showVideoAtom = Atom( name: '_SettingsStoreBase.showVideo', context: context, @@ -1286,6 +1317,7 @@ showThumbnails: ${showThumbnails}, largeStreamCard: ${largeStreamCard}, launchUrlExternal: ${launchUrlExternal}, streamLanguages: ${streamLanguages}, +streamViewerSort: ${streamViewerSort}, showVideo: ${showVideo}, defaultToHighestQuality: ${defaultToHighestQuality}, useTextureRendering: ${useTextureRendering}, diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 60cd7dd7..de29e1da 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -1,6 +1,12 @@ +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'; part 'user_store.g.dart'; @@ -8,6 +14,7 @@ class UserStore = UserStoreBase with _$UserStore; abstract class UserStoreBase with Store { final TwitchApi twitchApi; + final TwitchGqlApi twitchGqlApi; /// Whether the active auth token includes Frosty's moderator OAuth scopes. final bool Function() hasModeratorScopes; @@ -24,10 +31,20 @@ abstract class UserStoreBase with Store { @readonly var _moderatedChannels = ObservableList(); + /// Cached follow state for channels queried this session (id → following). + final _followCache = ObservableMap(); + + static const _deviceIdKey = 'twitch_gql_device_id'; + + String? _deviceId; + String? _integrityToken; + int _integrityExpirationMs = 0; + ReactionDisposer? _disposeReaction; UserStoreBase({ required this.twitchApi, + required this.twitchGqlApi, required this.hasModeratorScopes, }); @@ -61,6 +78,172 @@ abstract class UserStoreBase with Store { ); } + /// Whether we already know follow state for [channelId] this session. + bool? cachedIsFollowing(String channelId) => _followCache[channelId]; + + @action + Future isFollowingChannel(String channelId) async { + final cached = _followCache[channelId]; + if (cached != null) return cached; + + final userId = _details?.id; + if (userId == null) return false; + + final following = await twitchApi.isFollowingChannel( + userId: userId, + broadcasterId: channelId, + ); + _followCache[channelId] = following; + return following; + } + + /// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token. + @action + Future follow({ + required String targetId, + required String gqlToken, + }) async { + await _followMutation( + targetId: targetId, + gqlToken: gqlToken, + follow: true, + ); + _followCache[targetId] = true; + } + + /// Unfollows [targetId] via web GQL. + @action + Future unfollow({ + required String targetId, + required String gqlToken, + }) async { + await _followMutation( + targetId: targetId, + gqlToken: gqlToken, + follow: false, + ); + _followCache[targetId] = false; + } + + Future _followMutation({ + required String targetId, + 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( + targetId: targetId, + authToken: authToken!, + 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', + ); + } + _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; + } + + String? _validIntegrityToken() { + if (_integrityToken == null) return null; + if (TwitchIntegrity.isBadBot(_integrityToken!)) { + clearIntegrity(); + return null; + } + if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { + return null; + } + return _integrityToken; + } + + Future _ensureDeviceId() async { + if (_deviceId != null) return _deviceId!; + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_deviceIdKey); + if (id == null || id.isEmpty) { + final random = Random.secure(); + id = List.generate( + 32, + (_) => random.nextInt(16).toRadixString(16), + ).join(); + await prefs.setString(_deviceIdKey, id); + } + _deviceId = id; + return id; + } + @action Future block({ required String targetId, @@ -161,6 +344,8 @@ abstract class UserStoreBase with Store { _details = null; _blockedUsers.clear(); _moderatedChannels.clear(); + _followCache.clear(); + clearIntegrity(); _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/screens/settings/stores/user_store.g.dart b/lib/screens/settings/stores/user_store.g.dart index 084a0bda..e138aa77 100644 --- a/lib/screens/settings/stores/user_store.g.dart +++ b/lib/screens/settings/stores/user_store.g.dart @@ -79,6 +79,42 @@ mixin _$UserStore on UserStoreBase, Store { return _$initAsyncAction.run(() => super.init()); } + late final _$isFollowingChannelAsyncAction = AsyncAction( + 'UserStoreBase.isFollowingChannel', + context: context, + ); + + @override + Future isFollowingChannel(String channelId) { + return _$isFollowingChannelAsyncAction.run( + () => super.isFollowingChannel(channelId), + ); + } + + late final _$followAsyncAction = AsyncAction( + 'UserStoreBase.follow', + context: context, + ); + + @override + Future follow({required String targetId, required String gqlToken}) { + return _$followAsyncAction.run( + () => super.follow(targetId: targetId, gqlToken: gqlToken), + ); + } + + late final _$unfollowAsyncAction = AsyncAction( + 'UserStoreBase.unfollow', + context: context, + ); + + @override + Future unfollow({required String targetId, required String gqlToken}) { + return _$unfollowAsyncAction.run( + () => super.unfollow(targetId: targetId, gqlToken: gqlToken), + ); + } + late final _$blockAsyncAction = AsyncAction( 'UserStoreBase.block', context: context, diff --git a/lib/widgets/follow_button.dart b/lib/widgets/follow_button.dart new file mode 100644 index 00000000..c90b8c5a --- /dev/null +++ b/lib/widgets/follow_button.dart @@ -0,0 +1,206 @@ +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 new file mode 100644 index 00000000..46c45ff2 --- /dev/null +++ b/lib/widgets/follow_channel_controller.dart @@ -0,0 +1,150 @@ +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 new file mode 100644 index 00000000..9d9d9054 --- /dev/null +++ b/lib/widgets/integrity_webview_dialog.dart @@ -0,0 +1,232 @@ +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 d86c75ae..19bc1f90 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/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'; -class UserActionsModal extends StatelessWidget { +class UserActionsModal extends StatefulWidget { final AuthStore authStore; final String name; final String userLogin; @@ -14,6 +20,12 @@ class UserActionsModal extends StatelessWidget { final bool showPinOption; final bool? isPinned; + /// When set (e.g. Following tab), skip the Helix lookup and show this state. + final bool? initialIsFollowing; + + /// Called after a successful follow (`true`) or unfollow (`false`). + final ValueChanged? onFollowChanged; + /// Optional moderation actions (shown at the top) for chat contexts. Kept as /// an injected widget so this generic modal stays chat-agnostic. final Widget? moderationActions; @@ -26,51 +38,224 @@ class UserActionsModal extends StatelessWidget { required this.userId, this.showPinOption = false, this.isPinned, + this.initialIsFollowing, + this.onFollowChanged, this.moderationActions, }); + @override + State createState() => _UserActionsModalState(); +} + +class _UserActionsModalState extends State { + late final FollowChannelController _follow = FollowChannelController( + authStore: widget.authStore, + userId: widget.userId, + initialIsFollowing: widget.initialIsFollowing, + onChanged: () { + if (mounted) setState(() {}); + }, + ); + + @override + void initState() { + super.initState(); + _follow.init(); + } + + Future _toggleFollow() async { + if (!_follow.canFollow || + _follow.isFollowing == null || + _follow.actionInProgress) { + 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; + } + + try { + final nowFollowing = await _follow.retryAfterIntegrity( + wasFollowing: wasFollowing, + credentials: credentials, + ); + if (!mounted) return; + widget.onFollowChanged?.call(nowFollowing); + _popWithSnack( + nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + ); + } 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}', + ); + } + } + + 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, + 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'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { + final showFollowTile = _follow.shouldShow; + return ListView( primary: false, shrinkWrap: true, children: [ - ?moderationActions, - if (showPinOption) + ?widget.moderationActions, + if (widget.showPinOption) ListTile( leading: const Icon(Icons.push_pin_outlined), - title: Text('${isPinned == true ? 'Unpin' : 'Pin'} $name'), + title: Text( + '${widget.isPinned == true ? 'Unpin' : 'Pin'} ${widget.name}', + ), onTap: () { - if (isPinned == true) { + if (widget.isPinned == true) { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds - ..remove(userId), + ..remove(widget.userId), ]; } else { context.read().pinnedChannelIds = [ ...context.read().pinnedChannelIds, - userId, + widget.userId, ]; } Navigator.pop(context); }, ), - if (authStore.isLoggedIn) + if (showFollowTile) + ListTile( + leading: _follow.loading || _follow.actionInProgress + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _follow.isFollowing == true + ? Icons.heart_broken_rounded + : Icons.favorite_rounded, + ), + title: Text( + _follow.isFollowing == true + ? 'Unfollow ${widget.name}' + : 'Follow ${widget.name}', + ), + enabled: !_follow.loading && !_follow.actionInProgress, + onTap: _toggleFollow, + ), + if (widget.authStore.isLoggedIn) ListTile( leading: const Icon(Icons.block_rounded), - onTap: () => authStore + onTap: () => widget.authStore .showBlockDialog( context, - targetUser: name, - targetUserId: userId, + targetUser: widget.name, + targetUserId: widget.userId, ) .then((_) { if (context.mounted) { Navigator.pop(context); } }), - title: Text('Block $name'), + title: Text('Block ${widget.name}'), ), ListTile( leading: const Icon(Icons.outlined_flag_rounded), @@ -101,7 +286,7 @@ class UserActionsModal extends StatelessWidget { icon: Icon(Icons.adaptive.arrow_back_rounded), onPressed: Navigator.of(context).pop, ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), body: Stack( children: [ @@ -118,7 +303,7 @@ class UserActionsModal extends StatelessWidget { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest( Uri.parse( - 'https://www.twitch.tv/$userLogin/report', + 'https://www.twitch.tv/${widget.userLogin}/report', ), ) ..setNavigationDelegate( @@ -154,7 +339,7 @@ class UserActionsModal extends StatelessWidget { }, ), ), - title: Text('Report $name'), + title: Text('Report ${widget.name}'), ), ], ); diff --git a/pubspec.yaml b/pubspec.yaml index 75525dc5..1899d872 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.2.4+74 +version: 5.3.1+81 environment: sdk: ^3.9.0 diff --git a/test/apis/twitch_api_test.dart b/test/apis/twitch_api_test.dart index 7727e23f..4180a464 100644 --- a/test/apis/twitch_api_test.dart +++ b/test/apis/twitch_api_test.dart @@ -587,4 +587,55 @@ void main() { ); }); }); + + group('isFollowingChannel', () { + test('returns true when Helix lists the broadcaster', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 1, + 'data': [ + { + 'broadcaster_id': '999', + 'broadcaster_login': 'streamer', + 'broadcaster_name': 'Streamer', + 'followed_at': '2024-01-01T00:00:00Z', + }, + ], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isTrue, + ); + }); + + test('returns false when not following', () async { + dioAdapter.onGet( + 'https://api.twitch.tv/helix/channels/followed', + (server) => server.reply(200, { + 'total': 0, + 'data': [], + 'pagination': {}, + }), + queryParameters: { + 'user_id': '12345', + 'broadcaster_id': '999', + 'first': '1', + }, + ); + + expect( + await api.isFollowingChannel(userId: '12345', broadcasterId: '999'), + isFalse, + ); + }); + }); } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart new file mode 100644 index 00000000..00efa120 --- /dev/null +++ b/test/apis/twitch_gql_follow_test.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +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); + }); + + group('followUser', () { + test('posts FollowButton_FollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'followUser': { + 'follow': { + 'user': {'id': '999', 'displayName': 'Streamer'}, + }, + }, + }, + }), + data: Matchers.any, + ); + + await api.followUser(targetId: '999', authToken: 'web-token'); + }); + + test('throws IntegrityCheckException on integrity errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'failed integrity check'}, + ], + }), + data: Matchers.any, + ); + + await expectLater( + api.followUser(targetId: '999', authToken: 'web-token'), + throwsA(isA()), + ); + }); + }); + + group('unfollowUser', () { + test('posts FollowButton_UnfollowUser persisted mutation', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'unfollowUser': { + 'follow': null, + }, + }, + }), + data: Matchers.any, + ); + + 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 new file mode 100644 index 00000000..00888e9f --- /dev/null +++ b/test/apis/twitch_gql_streams_test.dart @@ -0,0 +1,129 @@ +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 new file mode 100644 index 00000000..a07ec031 --- /dev/null +++ b/test/apis/twitch_integrity_test.dart @@ -0,0 +1,42 @@ +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 new file mode 100644 index 00000000..adc80903 --- /dev/null +++ b/test/screens/channel/video/playback_error_recovery_test.dart @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..1a066c66 --- /dev/null +++ b/test/screens/home/stream_list/stream_list_store_test.dart @@ -0,0 +1,203 @@ +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 4affd3d7..4897d3ad 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,16 +1,27 @@ +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'; +import 'package:shared_preferences/shared_preferences.dart'; class MockTwitchApi extends Mock implements TwitchApi {} +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + void main() { late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; setUp(() { + SharedPreferences.setMockInitialValues({}); api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); when(() => api.getUserInfo()).thenAnswer( (_) async => const UserTwitch( '12345', @@ -27,42 +38,39 @@ void main() { ); }); + UserStore makeStore({required bool hasModeratorScopes}) => UserStore( + twitchApi: api, + twitchGqlApi: gqlApi, + hasModeratorScopes: () => hasModeratorScopes, + ); + group('UserStore moderated channels gating', () { test('init skips moderated-channels without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); + final userStore = makeStore(hasModeratorScopes: false); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); - expect(store.moderatedChannels, isEmpty); + expect(userStore.moderatedChannels, isEmpty); }); test('init fetches moderated-channels when moderator scopes exist', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => true, - ); + final userStore = makeStore(hasModeratorScopes: true); - await store.init(); + await userStore.init(); await Future.delayed(Duration.zero); verify(() => api.getModeratedChannels(id: '12345')).called(1); - expect(store.moderatedChannels, ['999']); + expect(userStore.moderatedChannels, ['999']); }); test('refreshModeratedChannels no-ops without moderator scopes', () async { - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); clearInteractions(api); - await store.refreshModeratedChannels(); + await userStore.refreshModeratedChannels(); verifyNever(() => api.getModeratedChannels(id: any(named: 'id'))); }); @@ -75,13 +83,10 @@ void main() { ), ).thenAnswer((_) async => true); - final store = UserStore( - twitchApi: api, - hasModeratorScopes: () => false, - ); - await store.init(); + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); - final result = await store.deleteMessage( + final result = await userStore.deleteMessage( broadcasterId: '12345', messageId: 'msg', ); @@ -96,4 +101,168 @@ void main() { ); }); }); + + group('UserStore follow', () { + test('isFollowingChannel caches Helix result', () async { + when( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).thenAnswer((_) async => true); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + + expect(await userStore.isFollowingChannel('999'), isTrue); + expect(await userStore.isFollowingChannel('999'), isTrue); + verify( + () => api.isFollowingChannel( + userId: '12345', + broadcasterId: '999', + ), + ).called(1); + }); + + test('follow retries after integrity failure', () async { + var calls = 0; + when( + () => gqlApi.followUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async { + calls++; + if (calls == 1) { + throw const IntegrityCheckException('failed integrity check'); + } + }); + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer( + (_) async => ( + token: 'integrity', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.follow(targetId: '999', gqlToken: 'web-token'); + + expect(userStore.cachedIsFollowing('999'), isTrue); + expect(calls, 2); + verify( + () => gqlApi.fetchIntegrityToken( + authToken: 'web-token', + deviceId: any(named: 'deviceId'), + ), + ).called(1); + }); + + test('unfollow updates cache', () async { + when( + () => gqlApi.unfollowUser( + targetId: any(named: 'targetId'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((_) async {}); + + final userStore = makeStore(hasModeratorScopes: false); + await userStore.init(); + await userStore.unfollow(targetId: '999', gqlToken: 'web-token'); + + 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 new file mode 100644 index 00000000..bdc88570 --- /dev/null +++ b/test/widgets/follow_channel_controller_test.dart @@ -0,0 +1,254 @@ +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); + }); + }); +}