From 73df7e53b3684b874a8f8196d2cbac8daad56d18 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 3 Apr 2024 15:14:44 -0700 Subject: [PATCH] Add OTA updates support for `testflight` channel (#3291) * some progress another adjustment, testing another adjustment, testing fix again fix again set default runtime version fix test this script test this script test this script add build numbers to the deployment url clean give script access to build number add `useBuildNumberEnv` without a bump new line fix missing async add channel name to deployment url add updates check on launch for testflight users ver bump init updates on launch for native add `testflight` as default in build submit add is_testflight check * disable inline predictions to prevent ios composer jank * temp bump * Revert "temp bump" This reverts commit 44c51134a35d817c73edb1e635495597c95117b3. * adjustments version bump adjust fixes test * cleanup and finalize drop check down to every 15 minutes adjustments change to 15 mins use jq to get version if necessary rm test on push figured it out remove nightly testflight releases test again again again again again AGAIN ONCE MORE test again again again again again AGAIN test again again again again again AGAIN test again again again again again test again again again again test again again again test again again test again test test test run deploy if necessary run deploy if necessary run deploy if necessary run deploy if necessary run deploy if necessary remove test message fix environment oops cleanup merge in changes * remove unnecessary `workflow_call` * remove changes that have been merged into main now * finalize android update git ignore rm test stuff from the bundle action remove test message test message fix test message test message few android fixes few android fixes fix jq add a test message fix slack webhook create android deployments test 2 create android deployments add `testflight-android` profile to eas.json more cleanup some more cleanup simplify some logic remove unnecessary channel rename to `useOTAUpdates` * rm test portion --- .github/workflows/build-submit-android.yml | 2 +- .github/workflows/build-submit-ios.yml | 5 +- .../workflows/bundle-deploy-eas-update.yml | 233 +++++++++++++++++- .gitignore | 6 +- app.config.js | 25 +- eas.json | 34 ++- package.json | 14 +- patches/expo-updates+0.24.7.patch | 26 ++ patches/expo-updates+0.24.7.patch.md | 7 + scripts/bundleUpdate.sh | 7 +- scripts/useBuildNumberEnv.sh | 8 +- scripts/useBuildNumberEnvWithBump.sh | 11 + src/App.native.tsx | 2 + .../ReportDialog/SelectLabelerView.tsx | 8 +- .../ReportDialog/SelectReportOptionView.tsx | 13 +- src/components/moderation/LabelPreference.tsx | 15 +- src/lib/app-info.ts | 10 +- src/lib/hooks/useOTAUpdates.ts | 142 +++++++++++ src/screens/Moderation/index.tsx | 64 +++-- src/screens/Profile/Sections/Labels.tsx | 27 +- src/view/screens/Settings/index.tsx | 86 ++++--- 21 files changed, 589 insertions(+), 156 deletions(-) create mode 100644 patches/expo-updates+0.24.7.patch create mode 100644 patches/expo-updates+0.24.7.patch.md create mode 100755 scripts/useBuildNumberEnvWithBump.sh create mode 100644 src/lib/hooks/useOTAUpdates.ts diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 8cbd9098..51fa5f4c 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -59,7 +59,7 @@ jobs: echo "$json" > google-services.json - name: 🏗️ EAS Build - run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive + run: yarn use-build-number-with-bump eas build -p android --profile production --local --output build.aab --non-interactive - name: 🚀 Deploy run: eas submit -p android --non-interactive --path build.aab diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index f5188b4b..c9752d86 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -2,14 +2,13 @@ name: Build and Submit iOS on: - schedule: - - cron: '0 5 * * *' workflow_dispatch: inputs: profile: type: choice description: Build profile to use options: + - testflight - production jobs: @@ -69,7 +68,7 @@ jobs: echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json - name: 🏗️ EAS Build - run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive + run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive - name: 🚀 Deploy run: eas submit -p ios --non-interactive --path build.ipa diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 72a38eaa..1c7e57e5 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -4,6 +4,12 @@ name: Bundle and Deploy EAS Update on: workflow_dispatch: inputs: + channel: + type: choice + description: Deployment channel to use + options: + - testflight + - production runtimeVersion: type: string description: Runtime version (in x.x.x format) that this update is for @@ -13,13 +19,48 @@ jobs: bundleDeploy: name: Bundle and Deploy EAS Update runs-on: ubuntu-latest + outputs: + fingerprint-diff: ${{ steps.fingerprint.outputs.fingerprint-diff }} steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + # Validate the version if one is supplied. This should generally happen if the update is for a production client - name: 🧐 Validate version + if: ${{ inputs.runtimeVersion }} run: | - [[ "${{ github.event.inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 + if [ -z "${{ inputs.runtimeVersion }}" ]; then + [[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 + fi - name: ⬇️ Checkout uses: actions/checkout@v4 + with: + fetch-depth: 100 + + - name: ⬇️ Fetch commits from base branch + run: git fetch origin main:main --depth 100 + + # This should get the current production release's commit's hash to see if the update is compatible + - name: 🕵️ Get the base commit + id: base-commit + run: | + if [ -z "${{ inputs.channel == 'production' }}" ]; then + echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT" + else + echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT" + fi + + - name: ✓ Make sure we found a base commit + run: | + if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then + echo "Could not find a base commit for this release. Exiting." + exit 1 + fi - name: 🔧 Setup Node uses: actions/setup-node@v4 @@ -30,13 +71,167 @@ jobs: - name: ⚙️ Install Dependencies run: yarn install - - name: 🪛 Install jq - uses: dcarbone/install-jq-action@v2 + # Run the fingerprint + - name: 📷 Check fingerprint + id: fingerprint + uses: expo/expo-github-action/fingerprint@main + with: + previous-git-commit: ${{ steps.base-commit.outputs.base-commit }} + + - name: 👀 Debug fingerprint + run: | + echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}" + echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}" + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} - name: ⛏️ Setup Expo + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} run: yarn global add eas-cli-local-build-plugin + - name: 🪛 Setup jq + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + uses: dcarbone/install-jq-action@v2 + - name: 🔤 Compile Translations + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: yarn intl:build + + - name: ✏️ Write environment variables + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: | + export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' + echo "${{ secrets.ENV_TOKEN }}" > .env + echo "$json" > google-services.json + + - name: 🏗️ Create Bundle + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export + + - name: 📦 Package Bundle and 🚀 Deploy + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: yarn use-build-number bash scripts/bundleUpdate.sh + env: + DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} + RUNTIME_VERSION: ${{ inputs.runtimeVersion }} + CHANNEL_NAME: ${{ inputs.channel || 'testflight' }} + + # GitHub actions are horrible so let's just copy paste this in + buildIfNecessaryIOS: + name: Build and Submit iOS + runs-on: macos-14 + needs: [bundleDeploy] + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be + # available here + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} + steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - name: ⬇️ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: ⛏️ Setup EAS local builds + run: yarn global add eas-cli-local-build-plugin + + - name: ⚙️ Install dependencies + run: yarn install + + - name: ☕️ Setup Cocoapods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + version: 1.14.3 + + - name: 💾 Cache Pods + uses: actions/cache@v3 + id: pods-cache + with: + path: ./ios/Pods + # We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not + # change unless the yarn version changes as well. + key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }} + + - name: 🔤 Compile translations + run: yarn intl:build + + - name: ✏️ Write environment variables + run: | + echo "${{ secrets.ENV_TOKEN }}" > .env + echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json + + - name: 🏗️ EAS Build + run: yarn use-build-number-with-bump eas build -p ios --profile testflight --local --output build.ipa --non-interactive + + - name: 🚀 Deploy + run: eas submit -p ios --non-interactive --path build.ipa + + buildIfNecessaryAndroid: + name: Build and Submit Android + runs-on: ubuntu-latest + needs: [ bundleDeploy ] + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be + # available here + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} + + steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - name: ⬇️ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: ⛏️ Setup EAS local builds + run: yarn global add eas-cli-local-build-plugin + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: ⚙️ Install dependencies + run: yarn install + + - name: 🔤 Compile translations run: yarn intl:build - name: ✏️ Write environment variables @@ -45,11 +240,31 @@ jobs: echo "${{ secrets.ENV_TOKEN }}" > .env echo "$json" > google-services.json - - name: 🏗️ Create Bundle - run: yarn export + - name: 🏗️ EAS Build + run: yarn use-build-number-with-bump eas build -p android --profile testflight-android --local --output build.apk --non-interactive - - name: 📦 Package Bundle and 🚀 Deploy - run: yarn make-deploy-bundle + - name: ⏰ Get a timestamp + id: timestamp + uses: nanzm/get-time-action@master + with: + format: 'MM-DD-HH-mm-ss' + + - name: 🚀 Upload Artifact + id: upload-artifact + uses: actions/upload-artifact@v4 + with: + retention-days: 30 + compression-level: 0 + name: build-${{ steps.timestamp.outputs.time }}.apk + path: build.apk + + - name: 🔔 Notify Slack + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}" + } env: - DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} - RUNTIME_VERSION: ${{ github.event.inputs.runtimeVersion }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.gitignore b/.gitignore index ddb553d2..77dbd00f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ xcuserdata *.moved-aside DerivedData *.hmap -*.ipa *.xcuserstate # Android/IntelliJ @@ -110,3 +109,8 @@ google-services.json # i18n src/locale/locales/_build/ src/locale/locales/**/*.js + +# local builds +*.apk +*.aab +*.ipa diff --git a/app.config.js b/app.config.js index c151862a..21b79491 100644 --- a/app.config.js +++ b/app.config.js @@ -41,6 +41,9 @@ module.exports = function (config) { : process.env.BSKY_IOS_BUILD_NUMBER const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' + const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' + + const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' return { expo: { @@ -122,10 +125,20 @@ module.exports = function (config) { favicon: './assets/favicon.png', }, updates: { - enabled: true, - fallbackToCacheTimeout: 1000, - url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302', + url: 'https://updates.bsky.app/manifest', + // TODO Eventually we want to enable this for all environments, but for now it will only be used for + // TestFlight builds + enabled: IS_TESTFLIGHT, + fallbackToCacheTimeout: 30000, + codeSigningCertificate: './code-signing/certificate.pem', + codeSigningMetadata: { + keyid: 'main', + alg: 'rsa-v1_5-sha256', + }, + checkAutomatically: 'NEVER', + channel: UPDATES_CHANNEL, }, + assetBundlePatterns: ['**/*'], plugins: [ 'expo-localization', Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', @@ -145,12 +158,6 @@ module.exports = function (config) { }, }, ], - [ - 'expo-updates', - { - username: 'blueskysocial', - }, - ], [ 'expo-notifications', { diff --git a/eas.json b/eas.json index 2b4c7cb6..ed647dbb 100644 --- a/eas.json +++ b/eas.json @@ -16,14 +16,20 @@ "ios": { "simulator": true, "resourceClass": "large" + }, + "env": { + "EXPO_PUBLIC_ENV": "production" } }, "preview": { "extends": "base", "distribution": "internal", - "channel": "preview", + "channel": "production", "ios": { "resourceClass": "large" + }, + "env": { + "EXPO_PUBLIC_ENV": "production" } }, "production": { @@ -35,9 +41,12 @@ "android": { "autoIncrement": true }, - "channel": "production" + "channel": "production", + "env": { + "EXPO_PUBLIC_ENV": "production" + } }, - "github": { + "testflight": { "extends": "base", "ios": { "autoIncrement": true @@ -45,7 +54,24 @@ "android": { "autoIncrement": true }, - "channel": "production" + "channel": "testflight", + "env": { + "EXPO_PUBLIC_ENV": "testflight" + } + }, + "testflight-android": { + "extends": "base", + "distribution": "internal", + "ios": { + "autoIncrement": true + }, + "android": { + "autoIncrement": true + }, + "channel": "testflight", + "env": { + "EXPO_PUBLIC_ENV": "testflight" + } } }, "submit": { diff --git a/package.json b/package.json index c51d6f22..b4018463 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.75.0", + "version": "1.76.0", "private": true, "engines": { "node": ">=18" @@ -14,11 +14,12 @@ "ios": "expo run:ios", "web": "expo start --web", "use-build-number": "./scripts/useBuildNumberEnv.sh", + "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/", - "build-all": "yarn intl:build && yarn use-build-number eas build --platform all", - "build-ios": "yarn use-build-number eas build -p ios", - "build-android": "yarn use-build-number eas build -p android", - "build": "yarn use-build-number eas build", + "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", + "build-ios": "yarn use-build-number-with-bump eas build -p ios", + "build-android": "yarn use-build-number-with-bump eas build -p android", + "build": "yarn use-build-number-with-bump eas build", "start": "expo start --dev-client", "start:prod": "expo start --dev-client --no-dev --minify", "clean-cache": "rm -rf node_modules/.cache/babel-loader/*", @@ -43,8 +44,7 @@ "intl:compile": "lingui compile", "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", "update-extensions": "bash scripts/updateExtensions.sh", - "export": "npx expo export", - "make-deploy-bundle": "bash scripts/bundleUpdate.sh" + "export": "npx expo export" }, "dependencies": { "@atproto/api": "^0.12.2", diff --git a/patches/expo-updates+0.24.7.patch b/patches/expo-updates+0.24.7.patch new file mode 100644 index 00000000..603ae32e --- /dev/null +++ b/patches/expo-updates+0.24.7.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift +index 189a5f5..8d5b8e6 100644 +--- a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift ++++ b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift +@@ -68,13 +68,20 @@ public final class NewUpdate: Update { + processedAssets.append(asset) + } + ++ // Instead of relying on various hacks to get the correct format for the specific ++ // platform on the backend, we can just add this little patch.. ++ let dateFormatter = DateFormatter() ++ dateFormatter.locale = Locale(identifier: "en_US_POSIX") ++ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" ++ let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)! ++ + return Update( + manifest: manifest, + config: config, + database: database, + updateId: uuid, + scopeKey: config.scopeKey, +- commitTime: RCTConvert.nsDate(commitTime), ++ commitTime: date, + runtimeVersion: runtimeVersion, + keep: true, + status: UpdateStatus.StatusPending, diff --git a/patches/expo-updates+0.24.7.patch.md b/patches/expo-updates+0.24.7.patch.md new file mode 100644 index 00000000..8a884812 --- /dev/null +++ b/patches/expo-updates+0.24.7.patch.md @@ -0,0 +1,7 @@ +# Expo-Updates Patch + +This is a small patch to convert timestamp formats that are returned from the backend. Instead of relying on the +backend to return the correct format for a specific format (the format required on Android is not the same as on iOS) +we can just add this conversion in. + +Don't remove unless we make changes on the backend to support both platforms. \ No newline at end of file diff --git a/scripts/bundleUpdate.sh b/scripts/bundleUpdate.sh index 18db81a2..5927a36c 100644 --- a/scripts/bundleUpdate.sh +++ b/scripts/bundleUpdate.sh @@ -9,10 +9,13 @@ rm -rf bundle.tar.gz echo "Creating tarball..." node scripts/bundleUpdate.js -cd bundleTempDir || exit +if [ -z "$RUNTIME_VERSION" ]; then + RUNTIME_VERSION=$(cat package.json | jq '.version' -r) +fi +cd bundleTempDir || exit BUNDLE_VERSION=$(date +%s) -DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION" +DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION&channel=$CHANNEL_NAME&ios-build-number=$BSKY_IOS_BUILD_NUMBER&android-build-number=$BSKY_ANDROID_VERSION_CODE" tar czvf bundle.tar.gz ./* diff --git a/scripts/useBuildNumberEnv.sh b/scripts/useBuildNumberEnv.sh index fe273d39..2251c090 100755 --- a/scripts/useBuildNumberEnv.sh +++ b/scripts/useBuildNumberEnv.sh @@ -1,11 +1,7 @@ #!/bin/bash outputIos=$(eas build:version:get -p ios) outputAndroid=$(eas build:version:get -p android) -currentIosVersion=${outputIos#*buildNumber - } -currentAndroidVersion=${outputAndroid#*versionCode - } - -BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) -BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) +BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - } +BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - } bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" - diff --git a/scripts/useBuildNumberEnvWithBump.sh b/scripts/useBuildNumberEnvWithBump.sh new file mode 100755 index 00000000..fe273d39 --- /dev/null +++ b/scripts/useBuildNumberEnvWithBump.sh @@ -0,0 +1,11 @@ +#!/bin/bash +outputIos=$(eas build:version:get -p ios) +outputAndroid=$(eas build:version:get -p android) +currentIosVersion=${outputIos#*buildNumber - } +currentAndroidVersion=${outputAndroid#*versionCode - } + +BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) +BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) + +bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" + diff --git a/src/App.native.tsx b/src/App.native.tsx index d6e726a5..2c880f21 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -19,6 +19,7 @@ import {init as initPersistedState} from '#/state/persisted' import * as persisted from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' import * as notifications from 'lib/notifications/notifications' import { asyncStoragePersister, @@ -60,6 +61,7 @@ function InnerApp() { const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() + useOTAUpdates() // init useEffect(() => { diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx index 383d1b95..dd07cafa 100644 --- a/src/components/ReportDialog/SelectLabelerView.tsx +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -1,18 +1,16 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyLabelerDefs} from '@atproto/api' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' import {getLabelingServiceTitle} from '#/lib/moderation' - -import {atoms as a, useTheme, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, useButtonContext} from '#/components/Button' import {Divider} from '#/components/Divider' import * as LabelingServiceCard from '#/components/LabelingServiceCard' - +import {Text} from '#/components/Typography' import {ReportDialogProps} from './types' export function SelectLabelerView({ diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx index 54844cfd..c6769834 100644 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -1,16 +1,15 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyLabelerDefs} from '@atproto/api' -import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' -import {DMCA_LINK} from '#/components/ReportDialog/const' +import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions' import {Link} from '#/components/Link' +import {DMCA_LINK} from '#/components/ReportDialog/const' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' -import {atoms as a, useTheme, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import { Button, ButtonIcon, @@ -19,11 +18,11 @@ import { } from '#/components/Button' import {Divider} from '#/components/Divider' import { - ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, } from '#/components/icons/Chevron' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' - +import {Text} from '#/components/Typography' import {ReportDialogProps} from './types' export function SelectReportOptionView({ diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx index 7d4bd9c3..028bd1a3 100644 --- a/src/components/moderation/LabelPreference.tsx +++ b/src/components/moderation/LabelPreference.tsx @@ -1,22 +1,21 @@ import React from 'react' import {View} from 'react-native' import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' +import {getLabelStrings} from '#/lib/moderation/useLabelInfo' import { usePreferencesQuery, usePreferencesSetContentLabelMutation, } from '#/state/queries/preferences' -import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' -import {getLabelStrings} from '#/lib/moderation/useLabelInfo' - -import {useTheme, atoms as a, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' -import {InlineLink} from '#/components/Link' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import * as ToggleButton from '#/components/forms/ToggleButton' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' export function Outer({children}: React.PropsWithChildren<{}>) { return ( diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 3f026d3f..3071e031 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,5 +1,9 @@ import VersionNumber from 'react-native-version-number' -import * as Updates from 'expo-updates' -export const updateChannel = Updates.channel -export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})` +export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' +export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' + +const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' +export const appVersion = `${VersionNumber.appVersion} (${ + VersionNumber.buildVersion +}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})` diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts new file mode 100644 index 00000000..181f0b2c --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.ts @@ -0,0 +1,142 @@ +import React from 'react' +import {Alert, AppState, AppStateStatus} from 'react-native' +import app from 'react-native-version-number' +import { + checkForUpdateAsync, + fetchUpdateAsync, + isEnabled, + reloadAsync, + setExtraParamAsync, + useUpdates, +} from 'expo-updates' + +import {logger} from '#/logger' +import {IS_TESTFLIGHT} from 'lib/app-info' +import {isIOS} from 'platform/detection' + +const MINIMUM_MINIMIZE_TIME = 15 * 60e3 + +async function setExtraParams() { + await setExtraParamAsync( + isIOS ? 'ios-build-number' : 'android-build-number', + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. + // This just ensures it gets passed as a string + `${app.buildVersion}`, + ) + await setExtraParamAsync( + 'channel', + IS_TESTFLIGHT ? 'testflight' : 'production', + ) +} + +export function useOTAUpdates() { + const appState = React.useRef('active') + const lastMinimize = React.useRef(0) + const ranInitialCheck = React.useRef(false) + const timeout = React.useRef() + const {isUpdatePending} = useUpdates() + + const setCheckTimeout = React.useCallback(() => { + timeout.current = setTimeout(async () => { + try { + await setExtraParams() + + logger.debug('Checking for update...') + const res = await checkForUpdateAsync() + + if (res.isAvailable) { + logger.debug('Attempting to fetch update...') + await fetchUpdateAsync() + } else { + logger.debug('No update available.') + } + } catch (e) { + logger.warn('OTA Update Error', {error: `${e}`}) + } + }, 10e3) + }, []) + + const onIsTestFlight = React.useCallback(() => { + setTimeout(async () => { + try { + await setExtraParams() + + const res = await checkForUpdateAsync() + if (res.isAvailable) { + await fetchUpdateAsync() + + Alert.alert( + 'Update Available', + 'A new version of the app is available. Relaunch now?', + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } + } catch (e: any) { + // No need to handle + } + }, 3e3) + }, []) + + React.useEffect(() => { + // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This + // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update + // immediately. + if (IS_TESTFLIGHT) { + onIsTestFlight() + return + } else if (!isEnabled || __DEV__ || ranInitialCheck.current) { + // Development client shouldn't check for updates at all, so we skip that here. + return + } + + setCheckTimeout() + ranInitialCheck.current = true + }, [onIsTestFlight, setCheckTimeout]) + + // After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available + // or B check for an update again. + React.useEffect(() => { + if (!isEnabled) return + + const subscription = AppState.addEventListener( + 'change', + async nextAppState => { + if ( + appState.current.match(/inactive|background/) && + nextAppState === 'active' + ) { + // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since + // chances are that there isn't anything important going on in the current session. + if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { + if (isUpdatePending) { + await reloadAsync() + } else { + setCheckTimeout() + } + } + } else { + lastMinimize.current = Date.now() + } + + appState.current = nextAppState + }, + ) + + return () => { + clearTimeout(timeout.current) + subscription.remove() + } + }, [isUpdatePending, setCheckTimeout]) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 7d991cc7..9d51a619 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -1,51 +1,49 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {LABELS} from '@atproto/api' import {useSafeAreaFrame} from 'react-native-safe-area-context' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {LABELS} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' -import {CenteredView} from '#/view/com/util/Views' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {useAnalytics} from 'lib/analytics/analytics' -import {useSetMinimalShellMode} from '#/state/shell' -import {useSession} from '#/state/session' +import {getLabelingServiceTitle} from '#/lib/moderation' +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {logger} from '#/logger' +import { + useMyLabelersQuery, + usePreferencesQuery, + UsePreferencesQueryResponse, + usePreferencesSetAdultContentMutation, +} from '#/state/queries/preferences' import { useProfileQuery, useProfileUpdateMutation, } from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import {useAnalytics} from 'lib/analytics/analytics' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views' - -import { - UsePreferencesQueryResponse, - useMyLabelersQuery, - usePreferencesQuery, - usePreferencesSetAdultContentMutation, -} from '#/state/queries/preferences' - -import {getLabelingServiceTitle} from '#/lib/moderation' -import {logger} from '#/logger' -import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf' +import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' +import {Props as SVGIconProps} from '#/components/icons/common' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' -import {Text} from '#/components/Typography' -import * as Toggle from '#/components/forms/Toggle' -import {InlineLink, Link} from '#/components/Link' -import {Button, ButtonText} from '#/components/Button' -import {Loader} from '#/components/Loader' import * as LabelingService from '#/components/LabelingServiceCard' +import {InlineLink, Link} from '#/components/Link' +import {Loader} from '#/components/Loader' import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {Props as SVGIconProps} from '#/components/icons/common' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' function ErrorState({error}: {error: string}) { const t = useTheme() diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index 2b2b9959..5ba8f00a 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -1,30 +1,29 @@ import React from 'react' import {View} from 'react-native' +import {useSafeAreaFrame} from 'react-native-safe-area-context' import { AppBskyLabelerDefs, - ModerationOpts, - interpretLabelValueDefinitions, InterpretedLabelValueDefinition, + interpretLabelValueDefinitions, + ModerationOpts, } from '@atproto/api' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {useScrollHandlers} from '#/lib/ScrollContext' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' -import {ListRef} from '#/view/com/util/List' -import {SectionRef} from './types' +import {useScrollHandlers} from '#/lib/ScrollContext' import {isNative} from '#/platform/detection' - -import {useTheme, atoms as a} from '#/alf' -import {Text} from '#/components/Typography' -import {Loader} from '#/components/Loader' -import {Divider} from '#/components/Divider' +import {ListRef} from '#/view/com/util/List' import {CenteredView, ScrollView} from '#/view/com/util/Views' -import {ErrorState} from '../ErrorState' -import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' +import {atoms as a, useTheme} from '#/alf' +import {Divider} from '#/components/Divider' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' +import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' +import {Text} from '#/components/Typography' +import {ErrorState} from '../ErrorState' +import {SectionRef} from './types' interface LabelsSectionProps { isLabelerLoading: boolean diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 3967678b..790ce5ee 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -3,72 +3,72 @@ import { ActivityIndicator, Linking, Platform, - StyleSheet, Pressable, + StyleSheet, TextStyle, TouchableOpacity, View, ViewStyle, } from 'react-native' -import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import * as AppInfo from 'lib/app-info' -import {usePalette} from 'lib/hooks/usePalette' -import {useCustomPalette} from 'lib/hooks/useCustomPalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' -import {useAnalytics} from 'lib/analytics/analytics' -import {NavigationProp} from 'lib/routes/types' -import {HandIcon, HashtagIcon} from 'lib/icons' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import Clipboard from '@react-native-clipboard/clipboard' -import {makeProfileLink} from 'lib/routes/links' -import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import { - useSetMinimalShellMode, - useThemePrefs, - useSetThemePrefs, - useOnboardingDispatch, -} from '#/state/shell' +import {clearLegacyStorage} from '#/state/persisted/legacy' +// TODO import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' import { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from '#/state/preferences' -import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useProfileQuery} from '#/state/queries/profile' -import {useClearPreferencesMutation} from '#/state/queries/preferences' -// TODO import {useInviteCodesQuery} from '#/state/queries/invites' -import {clear as clearStorage} from '#/state/persisted/store' -import {clearLegacyStorage} from '#/state/persisted/legacy' -import {STATUS_PAGE_URL} from 'lib/constants' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useCloseAllActiveElements} from '#/state/util' import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' -import {isNative} from '#/platform/detection' -import {useDialogControl} from '#/components/Dialog' - -import {s, colors} from 'lib/styles' -import {ScrollView} from 'view/com/util/Views' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import { + useOnboardingDispatch, + useSetMinimalShellMode, + useSetThemePrefs, + useThemePrefs, +} from '#/state/shell' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' +import {useAnalytics} from 'lib/analytics/analytics' +import * as AppInfo from 'lib/app-info' +import {STATUS_PAGE_URL} from 'lib/constants' +import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' +import {useCustomPalette} from 'lib/hooks/useCustomPalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {HandIcon, HashtagIcon} from 'lib/icons' +import {makeProfileLink} from 'lib/routes/links' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {NavigationProp} from 'lib/routes/types' +import {colors, s} from 'lib/styles' +import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' +import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {Link, TextLink} from 'view/com/util/Link' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' -import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' -import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' -import {ExportCarDialog} from './ExportCarDialog' +import {ScrollView} from 'view/com/util/Views' +import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {ExportCarDialog} from './ExportCarDialog' function SettingsAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') @@ -890,9 +890,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" onPress={onPressBuildInfo}> - - Build version {AppInfo.appVersion} {AppInfo.updateChannel} - + Version {AppInfo.appVersion}