diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 00000000..4b6fcbcc --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@../AGENTS.md diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000..0127976e --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, + "next-devtools": { + "command": "node", + "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4d4439a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLIENT_ID= +SECRET= diff --git a/.forgejo/workflows/actions-probe.yml b/.forgejo/workflows/actions-probe.yml new file mode 100644 index 00000000..a9e9414d --- /dev/null +++ b/.forgejo/workflows/actions-probe.yml @@ -0,0 +1,14 @@ +# Temporary probe: does this repo schedule Actions at all? +name: Actions probe + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + probe: + runs-on: desktop-release + steps: + - name: ping + run: echo "frosty actions probe ok" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 00000000..8e8ac8fd --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,304 @@ +# Builds a signed Frosty release APK and publishes a Forgejo release for Obtainium. +# Pattern: finamp-fork / jellyfin-android-fork on host runner `desktop-release`. +# +# Required repository secrets: +# RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD +# CLIENT_ID, SECRET (Twitch Helix app credentials — --dart-define) +# +# Obtainium: +# Source: Forgejo +# URL: https://forgejo.fsociety00.cc/api/v1/repos/copilot/frosty/releases +# APK filter: frosty-arm64-v8a-v.*\.apk +# Trim version: v?([0-9]+\.[0-9]+\.[0-9]+) +# Match group: 1 +# Private repo → Forgejo API token in Obtainium. + +name: Release APK + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: frosty-release + cancel-in-progress: true + +jobs: + build-and-release: + name: Build & publish release + runs-on: desktop-release + + steps: + - name: Clone repository + shell: bash + env: + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + rm -rf .git + git init . + origin_url="${GITHUB_SERVER_URL#https://}" + origin_url="${origin_url#http://}" + git remote add origin "https://copilot:${FORGEJO_TOKEN}@${origin_url}/${GITHUB_REPOSITORY}.git" + git -c http.sslVerify=false fetch --depth=1 origin "${GITHUB_SHA}" + git checkout --force FETCH_HEAD + test -f pubspec.yaml + test -f android/app/build.gradle.kts + echo "Checked out ${GITHUB_REPOSITORY}@${GITHUB_SHA}" + + - name: Set up JDK + shell: bash + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else "$@"; fi + } + if ! command -v java >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y java-21-openjdk-devel + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y openjdk-21-jdk + elif command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache openjdk21 + fi + fi + java -version + if command -v javac >/dev/null 2>&1; then + java_home="$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" + echo "JAVA_HOME=$java_home" >> "$GITHUB_ENV" + fi + + - name: Set up Android SDK + shell: bash + run: | + set -euo pipefail + sdk_candidates=( + "${ANDROID_HOME:-}" + "${ANDROID_SDK_ROOT:-}" + "/home/yuna/Android/Sdk" + "${HOME}/Android/Sdk" + "/opt/android-sdk-linux" + "/root/Android/Sdk" + ) + sdk_dir="" + for candidate in "${sdk_candidates[@]}"; do + if [ -n "$candidate" ] && [ -d "$candidate/platforms" ] && [ -d "$candidate/build-tools" ]; then + sdk_dir="$candidate" + break + fi + done + if [ -z "$sdk_dir" ]; then + echo "Android SDK not found on desktop-release host." + exit 1 + fi + echo "ANDROID_HOME=$sdk_dir" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$sdk_dir" >> "$GITHUB_ENV" + printf 'sdk.dir=%s\n' "$sdk_dir" > android/local.properties + echo "Using Android SDK: $sdk_dir" + + - name: Set up Flutter + shell: bash + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else "$@"; fi + } + if ! command -v curl >/dev/null 2>&1; then + if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache curl bash git unzip xz + elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y curl git unzip + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y curl git unzip + fi + fi + # Prefer pinned SDK already on the desktop-release host. + flutter_version="$(awk '/^[[:space:]]*flutter:/{print $2; exit}' pubspec.yaml)" + flutter_version="${flutter_version:-3.47.0}" + candidates=( + "/home/yuna/.local/flutter-${flutter_version}" + "${HOME}/.cache/frosty-ci-flutter-${flutter_version}" + "/home/yuna/.local/flutter" + ) + flutter_home="" + for c in "${candidates[@]}"; do + if [ -x "${c}/bin/flutter" ]; then + flutter_home="$c" + break + fi + done + if [ -z "$flutter_home" ]; then + flutter_home="${HOME}/.cache/frosty-ci-flutter-${flutter_version}" + rm -rf "${flutter_home}" + git clone --depth 1 --branch "${flutter_version}" https://github.com/flutter/flutter.git "${flutter_home}" + fi + echo "${flutter_home}/bin" >> "$GITHUB_PATH" + echo "PATH=${flutter_home}/bin:${PATH}" >> "$GITHUB_ENV" + export PATH="${flutter_home}/bin:${PATH}" + flutter --version + flutter precache --android + + - name: Decode keystore + shell: bash + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${RELEASE_KEYSTORE_BASE64:-}" ] || [ -z "${STORE_PASSWORD:-}" ] || [ -z "${KEY_ALIAS:-}" ] || [ -z "${KEY_PASSWORD:-}" ]; then + echo "Missing signing secrets: RELEASE_KEYSTORE_BASE64, STORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD" + exit 1 + fi + printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > android/upload-keystore.jks + test -s android/upload-keystore.jks + { + echo "storePassword=${STORE_PASSWORD}" + echo "keyPassword=${KEY_PASSWORD}" + echo "keyAlias=${KEY_ALIAS}" + echo "storeFile=${GITHUB_WORKSPACE}/android/upload-keystore.jks" + } > android/key.properties + echo "Keystore ready ($(wc -c < android/upload-keystore.jks) bytes)." + + - name: Resolve version metadata + id: version + shell: bash + run: | + set -euo pipefail + raw="$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}')" + VERSION_NAME="${raw%%+*}" + VERSION_CODE="${raw##*+}" + [ "$VERSION_CODE" != "$raw" ] || VERSION_CODE=1 + SHORT_SHA="${GITHUB_SHA::7}" + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + else + TAG="build-${VERSION_NAME}-${VERSION_CODE}-${SHORT_SHA}" + fi + echo "version_name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" + echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG} version=${VERSION_NAME}+${VERSION_CODE}" + + - name: Build signed release APK + id: build + shell: bash + env: + CLIENT_ID: ${{ secrets.CLIENT_ID }} + SECRET: ${{ secrets.SECRET }} + VERSION_NAME: ${{ steps.version.outputs.version_name }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + run: | + set -euo pipefail + if [ -z "${CLIENT_ID:-}" ] || [ -z "${SECRET:-}" ]; then + echo "Missing Twitch secrets CLIENT_ID / SECRET (Helix OAuth app)." + exit 1 + fi + flutter pub get + flutter build apk --release \ + --build-name="${VERSION_NAME}" \ + --build-number="${VERSION_CODE}" \ + --dart-define="CLIENT_ID=${CLIENT_ID}" \ + --dart-define="SECRET=${SECRET}" + mkdir -p dist + cp build/app/outputs/flutter-apk/app-release.apk \ + "dist/frosty-v${VERSION_NAME}.apk" + flutter build apk --release --split-per-abi \ + --build-name="${VERSION_NAME}" \ + --build-number="${VERSION_CODE}" \ + --dart-define="CLIENT_ID=${CLIENT_ID}" \ + --dart-define="SECRET=${SECRET}" || true + for abi in arm64-v8a armeabi-v7a x86_64; do + src="build/app/outputs/flutter-apk/app-${abi}-release.apk" + if [ -f "$src" ]; then + cp "$src" "dist/frosty-${abi}-v${VERSION_NAME}.apk" + fi + done + ls -lh dist/ + + - name: Publish Forgejo release + shell: bash + env: + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + VERSION_NAME: ${{ steps.version.outputs.version_name }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + SHORT_SHA: ${{ steps.version.outputs.short_sha }} + run: | + set -euo pipefail + pkg_install() { + if [ "$(id -u)" -eq 0 ]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else return 1; fi + } + for pkg in curl jq; do + command -v "$pkg" >/dev/null 2>&1 && continue + if command -v apk >/dev/null 2>&1; then pkg_install apk add --no-cache "$pkg" + elif command -v dnf >/dev/null 2>&1; then pkg_install dnf install -y "$pkg" + elif command -v apt-get >/dev/null 2>&1; then + pkg_install apt-get update + pkg_install apt-get install -y "$pkg" + fi + done + + API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}/repos/${GITHUB_REPOSITORY}" + AUTH="Authorization: token ${FORGEJO_TOKEN}" + + BODY_FILE="$(mktemp)" + cat > "${BODY_FILE}" </dev/null + fi + test -n "$release_id" && test "$release_id" != "null" + + for apk in dist/*.apk; do + [ -f "$apk" ] || continue + name="$(basename "$apk")" + echo "Uploading ${name}…" + curl -sS -X POST -H "$AUTH" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${apk}" \ + "${API}/releases/${release_id}/assets?name=${name}" \ + | jq -r '.name // .message' + done + echo "Published ${TAG} as release ${release_id}" + rm -f "$BODY_FILE" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e82f4c98..0ce3b2a1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ github: tommyxchow -custom: ["buymeacoffee.com/tommychow", "paypal.com/donate/?business=NF33JDG6KBU6W"] +buy_me_a_coffee: tommychow +custom: https://www.tesla.com/referral/tommy414363 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..9d449f02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + # Keep GitHub Actions up to date + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" + groups: + actions: + patterns: + - "*" + + # Keep Ruby gems (fastlane) up to date + - package-ecosystem: "bundler" + directory: "/android" + schedule: + interval: "monthly" + + - package-ecosystem: "bundler" + directory: "/ios" + schedule: + interval: "monthly" + + # Keep Dart/Flutter packages up to date + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54414437..d6c5c96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,18 +3,57 @@ on: push: branches: [main] jobs: - test: - name: Check and test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # Linux runner: analyze + test once (both are OS-independent) and compile a + # release APK to catch release-only breakage. Debug-signed via the build.gradle + # fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed. + android: + name: Analyze, test, and build Android + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so this compile check + # faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: + flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get + # Fail if committed generated files (*.g.dart) are stale — CI never runs + # build_runner otherwise, so forgetting to regenerate after editing a MobX + # store or @JsonSerializable model slips past analyze/test/build. + - run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated files are up to date + run: | + if [ -n "$(git status --porcelain)" ]; then + echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' + git status --porcelain + git diff + exit 1 + fi - run: flutter analyze - run: flutter test + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' + # macOS runner: release-compile iOS (no codesign, no secrets) — the only thing + # that must run on macOS. analyze/test already covered on the Linux job. + ios: + name: Build iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + # Match release workflows and avoid GitHub's lagging default Xcode. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - uses: subosito/flutter-action@v2 + with: + flutter-version-file: pubspec.yaml + cache: true + - run: flutter pub get + - run: flutter build ios --release --no-codesign diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index 95816783..a0d23fd7 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -9,6 +9,7 @@ jobs: android: name: Draft release and attach APK runs-on: ubuntu-latest + environment: release-android env: UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} @@ -18,19 +19,20 @@ jobs: SECRET: ${{ secrets.SECRET }} steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 + uses: actions/checkout@v6 # Setup Java so that we can build the APK. - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: - distribution: zulu - java-version: 11 + distribution: temurin + java-version: 21 # Setup and cache Flutter to install packages and build. - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version-file: pubspec.yaml cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -50,14 +52,54 @@ jobs: run: | flutter build apk --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET mv build/app/outputs/flutter-apk/app-release.apk frosty-${{ github.ref_name }}.apk - + + # Extract release notes from assets/release-notes.md for the current version. + - name: Extract release notes + id: release_notes + run: | + VERSION="${{ github.ref_name }}" + RELEASE_NOTES_FILE="assets/release-notes.md" + + # Extract the section for this version using awk + # Starts at "## vX.X.X", skips date line, stops at next "---" or "## v" header + NOTES=$(awk -v version="$VERSION" ' + BEGIN { found=0; output="" } + $0 ~ "^## " version "$" { found=1; next } + found && /^[A-Z][a-z]+ [0-9]+, [0-9]+$/ { next } + found && /^---$/ { exit } + found && /^## v[0-9]+\.[0-9]+\.[0-9]+$/ { exit } + found { output = output $0 "\n" } + END { print output } + ' "$RELEASE_NOTES_FILE") + + # Clean up "on GitHub" suffix (redundant since we're on GitHub) + NOTES=$(echo "$NOTES" | sed 's/ on GitHub)/)/') + + # Trim leading/trailing whitespace + NOTES=$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + if [ -n "$NOTES" ]; then + # Get the previous tag for the Full Changelog link + PREV_TAG=$(git describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo "") + + echo "found=true" >> $GITHUB_OUTPUT + echo "body<> $GITHUB_OUTPUT + echo "$NOTES" >> $GITHUB_OUTPUT + if [ -n "$PREV_TAG" ]; then + echo "" >> $GITHUB_OUTPUT + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> $GITHUB_OUTPUT + fi + echo "EOF" >> $GITHUB_OUTPUT + else + echo "found=false" >> $GITHUB_OUTPUT + echo "::warning::No release notes found for $VERSION in $RELEASE_NOTES_FILE" + fi + # Create a draft release on GitHub. - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: draft: true - generate_release_notes: true + generate_release_notes: ${{ steps.release_notes.outputs.found != 'true' }} + body: ${{ steps.release_notes.outputs.found == 'true' && steps.release_notes.outputs.body || '' }} files: frosty-${{ github.ref_name }}.apk - - - diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index cdd59011..1c3e9897 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -7,11 +7,37 @@ jobs: name: Check and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so the release-mode APK + # check below faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: + flutter-version-file: pubspec.yaml cache: true - run: flutter doctor -v - run: flutter pub get + # Fail if committed generated files (*.g.dart) are stale — CI never runs + # build_runner otherwise, so forgetting to regenerate after editing a MobX + # store or @JsonSerializable model slips past analyze/test/build. + - run: dart run build_runner build --delete-conflicting-outputs + - name: Verify generated files are up to date + run: | + if [ -n "$(git status --porcelain)" ]; then + echo '::error::Generated files are out of date. Run `dart run build_runner build --delete-conflicting-outputs` and commit the result.' + git status --porcelain + git diff + exit 1 + fi - run: flutter analyze - run: flutter test + # Release-mode compile check (no signing secrets needed — Android falls back + # to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only + # breakage that debug builds and tests miss (AOT compile, icon tree-shaking, + # plugin/Gradle release incompatibility). + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml new file mode 100644 index 00000000..0b905936 --- /dev/null +++ b/.github/workflows/release-dryrun.yml @@ -0,0 +1,126 @@ +# Dry-runs the full store release pipeline without publishing, to catch +# credential, signing, dependency, and store-validation breakage before an +# actual release. Mirrors store-release.yml but stops short of publishing: +# Android validates the AAB against Google Play (validate_only); iOS builds a +# fully signed archive via match (readonly) and skips the App Store upload. +name: Release dry-run +on: + # Run on demand before cutting a release. + workflow_dispatch: + # Monthly heartbeat so cert expiry, secret rotation, and dependency drift + # surface on their own between releases. + schedule: + - cron: '0 9 1 * *' # 1st of the month, 09:00 UTC +# Setup environment variables for use in all jobs. +env: + # Gemfile path used for caching dependencies. + BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile + CLIENT_ID: ${{ secrets.CLIENT_ID }} + SECRET: ${{ secrets.SECRET }} + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 +jobs: + android: + name: Dry-run Android release + runs-on: ubuntu-latest + environment: release-android + env: + UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_FILE_PATH: android/key.properties + PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }} + + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + + # Setup Java so that we can build the appbundle. + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version-file: pubspec.yaml + cache: true + + # Decode and create the upload-keystore.jks file, used for signing the app. + - name: Decode and create upload-keystore.jks + run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks + + # Create the key.properties file, used for signing the app. + - name: Create key.properties + run: | + echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH + echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH + echo keyAlias=upload >> $KEY_FILE_PATH + echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH + + # Build the Android app bundle (AAB) and pass the environment variables. + # + # The version code is overridden because Play rejects one it has already + # seen, and pubspec's code is by definition already published between + # releases — so the dry-run would fail on every scheduled run and never + # reach the validation it exists to perform. Epoch seconds is always + # unused, always increasing, and well under Play's 2100000000 cap. The + # bundle is only ever validated, never published, so the value is thrown + # away. versionName still comes from pubspec. + - name: Build the Android AAB + run: flutter build appbundle --build-number=$(date +%s) --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Validate the AAB and credentials against Google Play without publishing. + - name: Validate release with fastlane (no publish) + run: cd android && bundle exec fastlane deploy dry_run:true + ios: + name: Dry-run iOS release + runs-on: macos-latest + environment: release-ios + env: + APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} + APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }} + APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Always pick the newest production Xcode on the runner. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3 + bundler-cache: true + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version-file: pubspec.yaml + cache: true + + # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). + - name: Config the iOS build + run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Authenticate ASC API key, resolve signing via match (readonly), and build a + # fully signed archive without uploading to the App Store. + - name: Sign and build with fastlane (no publish) + run: cd ios && bundle exec fastlane release dry_run:true diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index f6c0f61c..53365a9e 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -13,10 +13,13 @@ env: BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile CLIENT_ID: ${{ secrets.CLIENT_ID }} SECRET: ${{ secrets.SECRET }} + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 jobs: android: name: Build and release Frosty on Android runs-on: ubuntu-latest + environment: release-android env: UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} @@ -26,32 +29,33 @@ jobs: steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 - + uses: actions/checkout@v6 + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.1 + ruby-version: 3.3 bundler-cache: true - + # Setup Java so that we can build the appbundle. - name: Set up Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: - distribution: zulu - java-version: 11 - + distribution: temurin + java-version: 21 + # Setup and cache Flutter to install packages and build. - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version-file: pubspec.yaml cache: true - + # Decode and create the upload-keystore.jks file, used for signing the app. - name: Decode and create upload-keystore.jks run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks - + # Create the key.properties file, used for signing the app. - name: Create key.properties run: | @@ -59,17 +63,18 @@ jobs: echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH echo keyAlias=upload >> $KEY_FILE_PATH echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH - + # Build the Android app bundle (AAB) and pass the environment variables. - name: Build the Android AAB run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET - + # Deploy to the play store with fastlane. - name: Deploy with fastlane run: cd android && bundle exec fastlane deploy ios: name: Build and release Frosty on iOS runs-on: macos-latest + environment: release-ios env: APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }} @@ -78,19 +83,25 @@ jobs: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} steps: - name: Checkout Frosty repo - uses: actions/checkout@v3 - + uses: actions/checkout@v6 + + # Always pick the newest production Xcode on the runner. + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. - name: Set up Ruby and Bundler uses: ruby/setup-ruby@v1 with: - ruby-version: 3.1 + ruby-version: 3.3 bundler-cache: true # Setup and cache Flutter to install packages and build. - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version-file: pubspec.yaml cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). diff --git a/.gitignore b/.gitignore index 61cf8b34..dfee3eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,63 +1,73 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# Folder containing random testing files -random/ - -# fastlane specific -**/fastlane/report.xml - -# deliver temporary files -**/fastlane/Preview.html - -# snapshot generated screenshots -**/fastlane/screenshots -**/fastlane/metadata/android/en-US/images - -# scan temporary files -**/fastlane/test_output - -# potentially sensitive review information -**/fastlane/metadata/review_information \ No newline at end of file +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +# .vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release +# AGP 9 writes a problems report here even when the Gradle build dir is redirected +/android/build/ + +# Folder containing random testing files +random/ + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots +**/fastlane/metadata/android/en-US/images + +# scan temporary files +**/fastlane/test_output + +# potentially sensitive review information +**/fastlane/metadata/review_information + +# secrets +.env + +# ai +.claude/settings.local.json \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..0127976e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "dart": { + "command": "dart", + "args": ["mcp-server", "--force-roots-fallback"] + }, + "next-devtools": { + "command": "node", + "args": ["web/node_modules/next-devtools-mcp/dist/index.js"] + } + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..dad4cfce --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,25 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "frosty", + "request": "launch", + "type": "dart", + "args": ["--dart-define-from-file=.env"] + }, + { + "name": "frosty (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile", + "args": ["--dart-define-from-file=.env"] + }, + { + "name": "frosty (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release", + "args": ["--dart-define-from-file=.env"] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4495eb73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# AGENTS.md + +Flutter Twitch client for iOS and Android. MobX with `build_runner`. The landing page is `web/` (own `AGENTS.md`). Flutter is pinned in `pubspec.yaml` `environment.flutter` — bump that one field; CI reads it via `flutter-version-file`. + +## Commands + +```bash +flutter pub get +flutter run --dart-define-from-file=.env # local; .env is gitignored +flutter run --dart-define=CLIENT_ID=ID --dart-define=SECRET=SECRET +flutter analyze +flutter test +flutter test test/path/to/file.dart +dart run build_runner build # after MobX / @JsonSerializable changes +``` + +`--delete-conflicting-outputs` is ignored as of build_runner 2.15. Don't add it. + +## Layout + +- `lib/apis/` — API clients extend `BaseApiClient` +- `lib/models/` — data models; `@JsonSerializable` ones commit `.g.dart` +- `lib/screens/{feature}/` — screens; feature stores in co-located `stores/` +- `lib/stores/` — global stores only (`global_assets_store.dart`) +- `test/` mirrors `lib/`; shared fixtures in `test/fixtures/` + +Don't add top-level store files or standalone API classes. Canonical MobX store: `lib/screens/settings/stores/auth_store.dart`. Extract state, reactions, and actions into stores when it keeps widgets on rendering. + +New or changed logic (stores, API clients, parsing, utils) ships with tests; extend existing tests when touching tested code. Pure UI or config may skip. HTTP: `http_mock_adapter` (`DioAdapter`) with full URLs. Otherwise `mocktail`. + +## Gotchas + +- Commit regenerated `.g.dart` files. Never edit them. CI fails the PR if they're stale. +- `main.dart` secure-storage `deleteAll()` looks redundant; it handles Android/iOS storage surviving uninstall. Don't remove it. +- `flutter_secure_storage` stays on v9 (v10 shipped in 5.0.0 and was reverted in 5.0.1 over an Android 14 startup freeze). That also holds `package_info_plus` v9 and `device_info_plus` v12 (win32). Don't jump them in a drive-by upgrade. +- Git-pinned forks in `pubspec.yaml` (`extended_text_field`, `better_native_video_player`, `simple_pip_mode`) carry patches. Don't swap them back to pub.dev. +- `android/gradle.properties`: keep `android.builtInKotlin=false` and `android.newDsl=false` so plugins that still apply KGP build on AGP 9. +- `ios/Runner/AppDelegate.swift` is customized (`CookieExtractorPlugin` + `FlutterImplicitEngineDelegate`). Flutter will not auto-migrate UIScene; don't replace the delegate with the template. +- `android/.../MainActivity.kt` extends `PipCallbackHelperActivityWrapper` and registers `CookieExtractorPlugin` by hand. Don't revert it to `FlutterActivity`. +- Helix OAuth (`Bearer`) and the Twitch web GQL cookie are different tokens. Native playback uses the cookie; don't send the Helix token to GQL/Usher. +- `syncedChatDelay` is runtime-only (`includeFromJson/toJson: false`). Don't persist it with the other settings. +- Don't remove the `ALLOW_DEBUG_SIGNED_RELEASE` branch in `android/app/build.gradle.kts`. Real releases must still fail if `key.properties` is missing. + +## Releases + +When cutting a store or GitHub release, read `docs/release.md` and follow it. That playbook is repo-local, not a global skill. `assets/release-notes.md` keeps about three consecutive versions; drop from the oldest end, never skip. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..07a0fe02 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,61 @@ +# Frosty fork — Twitch ad blocking (DESIGN) + +Personal Forgejo fork of [tommyxchow/frosty](https://github.com/tommyxchow/frosty) that adds **TwitchAdSolutions (vaft)**-style live ad blocking. + +Canonical living plan: session `active-plan.md` for conversation `37b527bf-e540-4753-be1f-39906fe2ba28`. ADR: **ADR-026**. + +## Problem + +Upstream Frosty plays Twitch HLS (native) or `player.twitch.tv` (WebView). Twitch may stitch ad pods into the media playlist (`stitched` / `EXT-X-DATERANGE`). Upstream only **detects** native ad breaks (to avoid bogus latency recovery); it does **not** block ads. Turbo/sub `hide_ads` via linked web session is separate and not a client blocker. + +## Approach (vaft-equivalent) + +Browser vaft hooks `Worker`/`fetch` and rewrites m3u8. Frosty’s native player cannot do that from Dart (`loadUrl` + headers only). + +| Layer | Behavior | +| --- | --- | +| Primary token | GQL `PlaybackAccessToken` with `playerType=popout` (vaft `ForceAccessTokenPlayerType`) when Settings `blockTwitchAds` is on | +| Ad break | Native `adBreakStream` → probe backup types `embed` → `popout` → `autoplay`; accept first Usher multivariant whose sampled media playlist lacks `stitched`; `loadUrl` that URL | +| Ad end | Restore primary popout Usher URL | +| WebView | Best-effort inject of vendored `assets/js/vaft.user.js` (not true document-start on Android) | +| UI | Settings toggle (default on; enabling forces native player); overlay “Blocking ads” | + +## Key code + +- `lib/apis/twitch_ad_solutions.dart` — constants + `TwitchAdFreePlayback` +- `lib/apis/twitch_gql_api.dart` — `playerType` / `platform`, `fetchPlaylistText` +- `lib/screens/channel/video/native_video_store.dart` — load + swap +- `lib/screens/channel/video/video_store.dart` — WebView inject +- `lib/screens/settings/stores/settings_store.dart` — `blockTwitchAds` +- `assets/js/vaft.user.js` — upstream TwitchAdSolutions script + +## Non-goals (v1) + +- Upstream PR +- Full Worker prototype cleaning +- Third-party proxies +- HLS segment stripping inside `better_native_video_player` (unless smoke tests demand it) + +## Tags + viewer sort (v5.2.2+) + +Helix cannot filter by freeform tags or sort ascending. GQL covers both: + +- Search: `SearchFreeformTags` → tap → `TagStreams` (`streams` + `freeformTags`) +- Sort: Top (asc), category (`game.streams`), tag — `VIEWER_COUNT` / `VIEWER_COUNT_ASC` via `StreamSortButton` + +Key code: `lib/apis/twitch_gql_api.dart`, `lib/screens/home/search/`, `lib/widgets/stream_sort_button.dart`, `ListStore` / `ListType.tag`. + +## Stream languages (v5.2.4+) + +Settings → General → Stream languages (multi-select chips). Default `de`. Applied to Top, category, and tag lists. + +- 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 + +Flutter **3.47.0** (pinned in `pubspec.yaml`). Local SDK: `/home/yuna/.local/flutter-3.47.0`. diff --git a/README.md b/README.md index 09e1e8ad..834bbc44 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,6 @@ -

- - The Frosty rounded logo. -

Frosty

- -

+![Showcase of the Frosty app with screenshots in a masonry grid](https://github.com/tommyxchow/frosty/assets/54859075/09178dcc-2fd2-4618-8076-502719159424) -

- A mobile - Twitch - client for iOS and Android with - 7TV, BTTV, - and - FFZ - support. Built with - Flutter. -

- -

+

Badge showing the CI status.

-

+## Download + +

-

- - Buy Me A Coffee badge. - -

+## Why -

- iOS screenshot of the Followed Streams section. - iOS screenshot of xQc's channel with the stream and chat. - iOS screenshot of the search section with results from the query "pokelaw". -

- -## Motivation - -The official Twitch mobile app doesn't support emotes from [7TV](https://chrome.google.com/webstore/detail/7tv/ammjkodgmmoknidbanneddgankgfejfh), [BetterTTV (BTTV)](https://chrome.google.com/webstore/detail/betterttv/ajopnjidmegmdimjlfnijceegpefgped), and [FrankerFaceZ (FFZ)](https://chrome.google.com/webstore/detail/frankerfacez/fadndhdgpmmaapbmfcknlfgcflmmmieb) — third-party services and extensions for Twitch used by millions of viewers and many top channels. This results in a poor mobile chat experience since only emote codes render rather than their image or GIF counterpart. +The official Twitch mobile app doesn't support emotes from [7TV](https://chrome.google.com/webstore/detail/7tv/ammjkodgmmoknidbanneddgankgfejfh), [BetterTTV (BTTV)](https://chrome.google.com/webstore/detail/betterttv/ajopnjidmegmdimjlfnijceegpefgped), and [FrankerFaceZ (FFZ)](https://chrome.google.com/webstore/detail/frankerfacez/fadndhdgpmmaapbmfcknlfgcflmmmieb) — third-party extensions for Twitch used by millions. As a result, only emote text names are rendered rather than their actual image or GIF, making the chat unreadable in many channels. ## Features @@ -129,45 +79,35 @@ For a more detailed overview, visit [frostyapp.io](https://www.frostyapp.io/). ## Development setup -1. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**. +1. [Install Flutter](https://docs.flutter.dev/get-started/install). -2. Clone the repo to a directory (e.g., `git clone https://github.com/tommyxchow/frosty.git`). +2. Clone this repo (e.g., `git clone https://github.com/tommyxchow/frosty.git`). -3. Navigate to `lib/constants.dart` and replace the `clientId` and `secret` constants with your **client ID** and **client secret** from step 1 (if using VSCode, use `--dart-define` to [define them as environment variables](https://dartcode.org/docs/using-dart-define-in-flutter/)). +3. Go to the [Twitch dev console](https://dev.twitch.tv/login) and register a new application to retrieve a **Client ID** and **Client Secret**. -4. Run `flutter pub get` to fetch all the dependencies. +4. Copy `.env.example` to `.env` and fill in your credentials: -5. Choose an emulator or device and run the app! + ```bash + cp .env.example .env + ``` -> **Note** -> +5. Run `flutter pub get` to fetch all the dependencies. + +6. Choose an emulator or device and run the app! The included VS Code launch configurations will automatically read your `.env` file. + +7. (Optional) Run `dart run scripts/get_twitch_token.dart` to quickly get a user auth token. Long-press the **Anonymous** account tile in the app to log in. + +> [!IMPORTANT] > Frosty uses [MobX](https://mobx.netlify.app/) for state management. Please refer to the documentation about code generation, otherwise your changes within MobX stores may not be applied. -> **Warning** -> -> I built Frosty while learning Flutter, so some of the code may not be optimal. I'm working on refactoring, documenting, and cleaning up various parts as much as I can. - -## Feature requests and issues - -If you have a feature request, found a bug, or have a general issue, you can submit it [here](https://github.com/tommyxchow/frosty/issues/new/choose) on the issues tab. Doing so makes it easier for me to keep track of them and makes it publicly visible for others to review. - -> **Note** -> -> I work on Frosty in my free time and take occasional breaks, so I may not respond immediately. - ## Donate -Downloading Frosty and leaving a review or starring this repository is more than enough to show support. +If you appreciate my work and would like to donate/tip, you can through: -If you're feeling generous and would like to support me with a donation, you can do so through the following: - -- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow) - [GitHub Sponsors](https://github.com/sponsors/tommyxchow) -- [PayPal](https://www.paypal.com/donate/?business=NF33JDG6KBU6W) -- BTC: bc1qzpth6gc3vum764lat6a8ul7cmekwles58070a6 -- ETH: 0x317b5930fc2898884f711016dCae79d24910888E +- [Buy Me a Coffee](https://www.buymeacoffee.com/tommychow) -If you decide to support me monetarily, it is extremely appreciated and further motivates me to continue improving and maintaining Frosty ❤️. +Otherwise, downloading Frosty, leaving a review, or starring this repository is more than enough to show support. Thank you! ## License diff --git a/analysis_options.yaml b/analysis_options.yaml index 770b6979..3b60af70 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,36 +1,46 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - - directives_ordering - - prefer_single_quotes - - always_use_package_imports - - avoid_void_async - - prefer_final_locals - - prefer_final_in_for_each - - avoid_redundant_argument_values - - unnecessary_parenthesis - - always_declare_return_types - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - lib/**.g.dart + - .dart_tool/** + - build/** + - ios/** + - android/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + - directives_ordering + - prefer_single_quotes + - always_use_package_imports + - avoid_void_async + - prefer_final_locals + - prefer_final_in_for_each + - avoid_redundant_argument_values + - unnecessary_parenthesis + - always_declare_return_types + - require_trailing_commas +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore index 6f568019..be3943c9 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,9 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/android/Gemfile.lock b/android/Gemfile.lock index 1555cfbc..41b69f5c 100644 --- a/android/Gemfile.lock +++ b/android/Gemfile.lock @@ -1,43 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) - rexml - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.757.0) - aws-sdk-core (3.171.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-eventstream (1.4.0) + aws-partitions (1.1260.0) + aws-sdk-core (3.252.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.63.0) - aws-sdk-core (~> 3, >= 3.165.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.121.0) - aws-sdk-core (~> 3, >= 3.165.0) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.225.1) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.4) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.99.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -49,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.212.2) - CFPropertyList (>= 2.3, < 4.0.0) + fastimage (2.4.1) + fastlane (2.234.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) - colored + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) + colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -82,138 +92,148 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, <= 2.1.1) google-cloud-storage (~> 1.31) highline (~> 2.0) + http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (~> 2.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) - optparse (~> 0.1.1) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.41.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - webrick - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.5.2) - faraday (>= 0.17.3, < 3.a) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.6.3) - jwt (2.7.0) - memoist (0.16.2) - mini_magick (4.12.0) - mini_mime (1.1.2) - multi_json (1.15.0) - multipart-post (2.0.0) - nanaimo (0.3.0) - naturally (2.2.1) - optparse (0.1.1) + json (2.19.9) + jwt (2.10.3) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.3.0) + optparse (0.8.1) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.2.5) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) - security (0.1.3) - signet (0.17.0) + rubyzip (2.4.1) + security (0.1.5) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - xcpretty (0.3.0) - rouge (~> 2.0.7) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-21 universal-darwin-22 + universal-darwin-23 + universal-darwin-24 x64-unknown - x86_64-darwin-21 x86_64-darwin-20 + x86_64-darwin-21 x86_64-linux DEPENDENCIES diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 51ad3adb..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,86 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -// START: FlutterFire Configuration -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.firebase.firebase-perf' -apply plugin: 'com.google.firebase.crashlytics' -// END: FlutterFire Configuration -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - namespace "com.tommychow.frosty" - compileSdkVersion flutter.compileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "com.tommychow.frosty" - minSdkVersion 26 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - buildTypes { - release { - signingConfig signingConfigs.release - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..998334f3 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,70 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + id("com.google.gms.google-services") + id("com.google.firebase.firebase-perf") + id("com.google.firebase.crashlytics") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.tommychow.frosty" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.tommychow.frosty" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + + buildTypes { + release { + // Use the real upload key when key.properties is present (release + // workflows create it from secrets). Allow debug signing only when a CI + // compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE; + // otherwise fail loudly so a real release can never be silently + // debug-signed if the keystore step is ever missing. + signingConfig = when { + keystorePropertiesFile.exists() -> signingConfigs.getByName("release") + System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug") + else -> error("key.properties missing — set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds") + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d8820da2..4beaec33 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,45 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt new file mode 100644 index 00000000..055101d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/tommychow/frosty/CookieExtractorPlugin.kt @@ -0,0 +1,32 @@ +package com.tommychow.frosty + +import android.webkit.CookieManager +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class CookieExtractorPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, "frosty/cookie_extractor") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "extractTwitchAuthToken") { + val cookies = CookieManager.getInstance().getCookie("https://twitch.tv") + val authToken = cookies?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.startsWith("auth-token=") } + ?.substringAfter("auth-token=") + result.success(authToken) + } else { + result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt index a7791025..30e59dc7 100644 --- a/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tommychow/frosty/MainActivity.kt @@ -1,6 +1,11 @@ package com.tommychow.frosty import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper +import io.flutter.embedding.engine.FlutterEngine class MainActivity: PipCallbackHelperActivityWrapper() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(CookieExtractorPlugin()) + } } diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png index 59d103af..7dd78fca 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png index c562000b..0c07f1fd 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png index d8cccdac..83084f78 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png index 1f1a36fa..604f87bf 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..75578815 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 8c708bcb..a26965ce 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -7,6 +7,8 @@ @drawable/launch_background false false + true + shortEdges + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 0d1fa8fc..ac038c9e 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -7,7 +7,7 @@ @drawable/launch_background false false - false + true shortEdges