diff --git a/.github/renovate.json b/.github/renovate.json index fdbe3734d..45c62042c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -44,22 +44,42 @@ ] } }, - "lockFileMaintenance": { - "vulnerabilityAlerts": { - "enabled": true, - "addLabels": ["security", "vulnerability"], - "assigneesFromCodeOwners": true, - "commitMessageSuffix": " [SECURITY]" + "vulnerabilityAlerts": { + "enabled": true, + "addLabels": ["security", "vulnerability"], + "assigneesFromCodeOwners": true, + "commitMessageSuffix": " [SECURITY]" + }, + "packageRules": [ + { + "description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard", + "matchPackageNames": [ + "expo", + "react", + "react-dom", + "react-native", + "react-native-web", + "expo-*", + "@expo/*" + ], + "groupName": "Expo SDK", + "dependencyDashboardApproval": true }, - "packageRules": [ - { - "description": "Group minor and patch GitHub Action updates into a single PR", - "matchManagers": ["github-actions"], - "groupName": "CI dependencies", - "groupSlug": "ci-deps", - "matchUpdateTypes": ["minor", "patch", "digest", "pin"], - "automerge": true - } - ] - } + { + "description": "Group minor and patch GitHub Action updates into a single PR", + "matchManagers": ["github-actions"], + "groupName": "CI dependencies", + "groupSlug": "ci-deps", + "matchUpdateTypes": ["minor", "patch", "digest", "pin"], + "automerge": true + }, + { + "description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)", + "matchDatasources": ["maven"], + "registryUrls": [ + "https://dl.google.com/dl/android/maven2/", + "https://repo.maven.apache.org/maven2/" + ] + } + ] } diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml index 7068c2f03..69a115072 100644 --- a/.github/workflows/build-apps.yml +++ b/.github/workflows/build-apps.yml @@ -11,6 +11,12 @@ on: push: branches: [develop, master] +# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the +# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead. +env: + EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} + EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }} + jobs: build-android-phone: if: (!contains(github.event.head_commit.message, '[skip ci]')) diff --git a/app.config.js b/app.config.js index 96bbd8ea0..970567360 100644 --- a/app.config.js +++ b/app.config.js @@ -1,3 +1,41 @@ +const { execFileSync } = require("node:child_process"); + +// Build metadata, injected into `extra.build` and read at runtime via +// expo-constants (see utils/version.ts). Sources in priority order: +// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null. +const git = (args) => { + try { + return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return null; + } +}; + +const buildMeta = { + commit: + ( + process.env.EAS_BUILD_GIT_COMMIT_HASH || + process.env.GITHUB_SHA || + process.env.EXPO_PUBLIC_GIT_COMMIT || + git(["rev-parse", "HEAD"]) || + "" + ).slice(0, 7) || null, + branch: + process.env.EAS_BUILD_GIT_BRANCH || + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF_NAME || + process.env.EXPO_PUBLIC_GIT_BRANCH || + git(["rev-parse", "--abbrev-ref", "HEAD"]) || + null, + profile: + process.env.EAS_BUILD_PROFILE || + process.env.EXPO_PUBLIC_BUILD_PROFILE || + null, + builtAt: new Date().toISOString(), +}; + module.exports = ({ config }) => { if (process.env.EXPO_TV !== "1") { config.plugins.push("expo-background-task"); @@ -22,6 +60,8 @@ module.exports = ({ config }) => { androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON; } + config.extra = { ...config.extra, build: buildMeta }; + return { ...(Object.keys(androidConfig).length > 0 && { android: androidConfig }), ...config, diff --git a/components/SaveAccountModal.tsx b/components/SaveAccountModal.tsx index 42615c7d1..81508adef 100644 --- a/components/SaveAccountModal.tsx +++ b/components/SaveAccountModal.tsx @@ -69,17 +69,23 @@ export const SaveAccountModal: React.FC = ({ [isAndroid], ); + const isPresentedRef = useRef(false); + useEffect(() => { if (visible) { bottomSheetModalRef.current?.present(); - } else { + } else if (isPresentedRef.current) { bottomSheetModalRef.current?.dismiss(); + isPresentedRef.current = false; } }, [visible]); const handleSheetChanges = useCallback( (index: number) => { - if (index === -1) { + if (index >= 0) { + isPresentedRef.current = true; + } else if (index === -1 && isPresentedRef.current) { + isPresentedRef.current = false; resetState(); onClose(); } diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx index 56b6413d2..6711d6554 100644 --- a/components/settings/UserInfo.tsx +++ b/components/settings/UserInfo.tsx @@ -1,8 +1,8 @@ -import * as Application from "expo-application"; import { useAtom } from "jotai"; import { useTranslation } from "react-i18next"; import { View, type ViewProps } from "react-native"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getVersionInfo } from "@/utils/version"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; @@ -13,10 +13,9 @@ export const UserInfo: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const { t } = useTranslation(); - const version = - Application?.nativeApplicationVersion || - Application?.nativeBuildVersion || - "N/A"; + // Graduated build identifier — see utils/version.ts: + // dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit", production → "0.54.1 (42)". + const { display: version } = getVersionInfo(); return ( diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 185c306c7..52647bde7 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -40,6 +40,7 @@ import { } from "@/utils/secureCredentials"; import { store } from "@/utils/store"; import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync"; +import { APP_VERSION } from "@/utils/version"; interface Server { address: string; @@ -53,7 +54,7 @@ const initialApi = (() => { const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); const jellyfinInstance = new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.1" }, + clientInfo: { name: "Streamyfin", version: APP_VERSION }, deviceInfo: { name: deviceName, id, @@ -135,7 +136,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const id = getOrSetDeviceId(); const deviceName = getDeviceNameSync(); return new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.54.1" }, + clientInfo: { name: "Streamyfin", version: APP_VERSION }, deviceInfo: { name: deviceName, id, @@ -169,7 +170,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.54.1"`, + }, DeviceId="${deviceId}", Version="${APP_VERSION}"`, }; }, [deviceId]); diff --git a/utils/version.ts b/utils/version.ts new file mode 100644 index 000000000..79ba2c1bf --- /dev/null +++ b/utils/version.ts @@ -0,0 +1,87 @@ +import * as Application from "expo-application"; +import Constants from "expo-constants"; + +/** Raw marketing version (app.json `version`), e.g. "0.54.1". Exposed so the Jellyfin + * clientInfo auto-tracks the app version instead of a hardcoded string. */ +export const APP_VERSION = Application.nativeApplicationVersion ?? "unknown"; + +/** Build metadata injected at build time by `app.config.js` into `extra.build`. */ +export interface BuildMeta { + commit?: string | null; + branch?: string | null; + profile?: string | null; + builtAt?: string | null; +} + +export interface VersionInfo { + /** Marketing version (CFBundleShortVersionString / android versionName), e.g. "0.54.1". */ + version: string | null; + /** Build number (CFBundleVersion / versionCode), e.g. "42". */ + build: string | null; + /** Short git commit the build was made from, e.g. "a1b2c3d". */ + commit: string | null; + /** Git branch the build was made from, e.g. "develop". */ + branch: string | null; + /** EAS build profile, e.g. "production", "preview", or null for local. */ + profile: string | null; + isDev: boolean; + isProduction: boolean; + /** Graduated label for the Settings "App version" row (see tiering below). */ + display: string; +} + +/** + * Resolve a graduated version string for Settings. + * + * Tiering (most → least detailed): + * - dev / local build → `version · branch · commit` (full context for debugging) + * - develop / CI / preview → `version · commit` (pin the exact source) + * - production (store / TestFlight) → `version (build)` (store-correlatable; the + * build number lets TestFlight reports pin a build whose version isn't a + * published release. Note: TestFlight and the public App Store ship the same + * binary — telling them apart needs a runtime iOS receipt check, intentionally + * not done here.) + */ +export function getVersionInfo(): VersionInfo { + // Read native/config values defensively — a version string must never crash Settings + // (e.g. a dev build whose native expo-constants is out of sync with the JS). + const read = (fn: () => T): T | null => { + try { + return fn() ?? null; + } catch { + return null; + } + }; + + const version = read(() => Application.nativeApplicationVersion); + const build = read(() => Application.nativeBuildVersion); + const meta = (read(() => Constants.expoConfig?.extra?.build) ?? + {}) as BuildMeta; + const commit = meta.commit ?? null; + const branch = meta.branch ?? null; + const profile = meta.profile ?? null; + const isDev = __DEV__ === true; + const isProduction = + typeof profile === "string" && profile.startsWith("production"); + + let display: string; + if (isDev) { + display = [version ?? "dev", branch, commit].filter(Boolean).join(" · "); + } else if (isProduction) { + display = + version && build ? `${version} (${build})` : (version ?? build ?? "N/A"); + } else { + display = [version, commit].filter(Boolean).join(" · ") || version || "N/A"; + } + + return { + version, + build, + commit, + branch, + profile, + isDev, + isProduction, + display, + }; +}