diff --git a/.gitlab/ci/build_app.yml b/.gitlab/ci/build_app.yml index 66b90414..3854e3ad 100644 --- a/.gitlab/ci/build_app.yml +++ b/.gitlab/ci/build_app.yml @@ -86,7 +86,7 @@ app-web-image: - npm install -g pnpm@10 - pnpm config set @polycentric:registry https://${CI_SERVER_HOST}/api/v4/projects/${CI_PROJECT_ID}/packages/npm/ - pnpm install - - apt-get update && apt-get install -y --no-install-recommends git + - apt-get update && apt-get install -y --no-install-recommends git jq # Tag builds use the tag version; others use the latest release + a patch. - export APP_VERSION=$(node "$CI_PROJECT_DIR/tools/expo/resolve-version.js") - echo "Building app version ${APP_VERSION}" @@ -117,9 +117,42 @@ app-web-image: app-android-apk: extends: .app-eas-build script: - - npx eas-cli build --platform android --profile preview --non-interactive --wait --json > eas-build.json + - | + if [ -n "${CI_COMMIT_TAG:-}" ]; then + EAS_PROFILE="production-apk" + else + EAS_PROFILE="preview" + fi + echo "Building Android APK with EAS profile ${EAS_PROFILE}" + npx eas-cli build --platform android --profile "$EAS_PROFILE" --non-interactive --wait --json > eas-build.json # eas only builds remotely; download the resulting APK so it is a CI artifact. - node "$CI_PROJECT_DIR/tools/expo/download-apk.js" eas-build.json polycentric.apk + - | + if [ "$CI_PIPELINE_SOURCE" != "merge_request_event" ]; then + export CLOUDFLARE_API_TOKEN="$CLOUDFLARE_R2_API_TOKEN" + APP_BUILD_NUMBER=$(jq -r '.[0].appBuildVersion' eas-build.json) + APK_KEY="android/$EAS_PROFILE/builds/$APP_BUILD_NUMBER/harbor-v$APP_VERSION.apk" + APK_URL="${EXPO_PUBLIC_RELEASE_URL%/}/$APK_KEY" + + jq -n \ + --argjson buildNumber "$APP_BUILD_NUMBER" \ + --arg version "$APP_VERSION" \ + --arg apkUrl "$APK_URL" \ + '{ buildNumber: $buildNumber, version: $version, apkUrl: $apkUrl }' \ + > latest.json + + npx --yes wrangler@4 r2 object put "$R2_BUCKET_NAME/$APK_KEY" \ + --file polycentric.apk \ + --content-type application/vnd.android.package-archive \ + --cache-control "public, max-age=31536000, immutable" \ + --remote + + npx --yes wrangler@4 r2 object put "$R2_BUCKET_NAME/android/$EAS_PROFILE/latest.json" \ + --file latest.json \ + --content-type application/json \ + --cache-control no-store \ + --remote + fi artifacts: paths: - apps/polycentric/polycentric.apk @@ -129,9 +162,9 @@ app-android-apk: app-android-aab: extends: .app-eas-build script: - - npx eas-cli build --platform android --non-interactive --no-wait --auto-submit + - npx eas-cli build --platform android --profile production --non-interactive --no-wait --auto-submit app-ios: extends: .app-eas-build script: - - npx eas-cli build --platform ios --non-interactive --no-wait --auto-submit + - npx eas-cli build --platform ios --profile production --non-interactive --no-wait --auto-submit diff --git a/apps/polycentric/app.config.ts b/apps/polycentric/app.config.ts index 44f958e6..567fcc5c 100644 --- a/apps/polycentric/app.config.ts +++ b/apps/polycentric/app.config.ts @@ -4,6 +4,9 @@ import fs from 'fs'; const { version: PKG_VERSION } = require('./package.json'); const IS_DEV = process.env.APP_VARIANT === 'dev'; +const BUILD_PROFILE = process.env.EXPO_PUBLIC_BUILD_PROFILE; +const IS_APK_BUILD = + BUILD_PROFILE === 'preview' || BUILD_PROFILE === 'production-apk'; const NAME = IS_DEV ? 'Harbor Dev' : 'Harbor'; const ID = IS_DEV ? 'org.futo.polycentric.dev' : 'org.futo.polycentric'; @@ -49,7 +52,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ backgroundColor: '#FF6A00', }, package: ID, - permissions: ['android.permission.CAMERA'], + permissions: [ + 'android.permission.CAMERA', + ...(IS_APK_BUILD ? ['android.permission.REQUEST_INSTALL_PACKAGES'] : []), + ], ...(HAS_GOOGLE_SERVICES && { googleServicesFile: GOOGLE_SERVICES_FILE }), }, plugins: [ diff --git a/apps/polycentric/app/_layout.tsx b/apps/polycentric/app/_layout.tsx index 369ae90a..70de1b8e 100644 --- a/apps/polycentric/app/_layout.tsx +++ b/apps/polycentric/app/_layout.tsx @@ -6,6 +6,7 @@ import { } from '@/src/common/lib/polycentric-hooks'; import { APP_NAME } from '@/src/common/constants'; import { Atoms, ThemeProvider, useTheme } from '@/src/common/theme'; +import { UpdateModal } from '@/src/features/updates/UpdateModal'; import { isWeb } from '@/src/common/util/platform'; import '@/src/common/util/react-native-screens-feature-flags'; import { TrueSheetProvider } from '@lodev09/react-native-true-sheet'; @@ -130,6 +131,7 @@ export default function RootLayout() { + diff --git a/apps/polycentric/eas.json b/apps/polycentric/eas.json index 1826bf6b..4c918d81 100644 --- a/apps/polycentric/eas.json +++ b/apps/polycentric/eas.json @@ -6,13 +6,39 @@ "build": { "development": { "developmentClient": true, - "distribution": "internal" + "distribution": "internal", + "environment": "development", + "env": { + "EXPO_PUBLIC_BUILD_PROFILE": "development" + } }, "preview": { - "distribution": "internal" + "distribution": "internal", + "autoIncrement": true, + "environment": "preview", + "env": { + "EXPO_PUBLIC_BUILD_PROFILE": "preview" + }, + "android": { + "buildType": "apk" + } }, "production": { - "autoIncrement": true + "autoIncrement": true, + "environment": "production", + "env": { + "EXPO_PUBLIC_BUILD_PROFILE": "production" + } + }, + "production-apk": { + "extends": "production", + "distribution": "internal", + "env": { + "EXPO_PUBLIC_BUILD_PROFILE": "production-apk" + }, + "android": { + "buildType": "apk" + } } }, "submit": { diff --git a/apps/polycentric/package.json b/apps/polycentric/package.json index a91511a2..74c0237e 100644 --- a/apps/polycentric/package.json +++ b/apps/polycentric/package.json @@ -36,6 +36,7 @@ "@types/react-native-web": "^0.19.2", "better-sqlite3": "^12.10.0", "expo": "~57.0.4", + "expo-application": "~57.0.0", "expo-blur": "~57.0.0", "expo-build-properties": "~57.0.3", "expo-clipboard": "~57.0.0", @@ -49,6 +50,7 @@ "expo-image": "~57.0.0", "expo-image-manipulator": "~57.0.2", "expo-image-picker": "~57.0.2", + "expo-intent-launcher": "~57.0.1", "expo-linking": "~57.0.2", "expo-notifications": "~57.0.3", "expo-router": "~57.0.4", diff --git a/apps/polycentric/src/features/updates/UpdateModal.tsx b/apps/polycentric/src/features/updates/UpdateModal.tsx new file mode 100644 index 00000000..dc628a33 --- /dev/null +++ b/apps/polycentric/src/features/updates/UpdateModal.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { Modal, Platform, View } from 'react-native'; +import { fetch } from 'expo/fetch'; +import { File, Paths } from 'expo-file-system'; +import { getContentUriAsync } from 'expo-file-system/legacy'; +import { startActivityAsync } from 'expo-intent-launcher'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Atoms, useTheme } from '@/src/common/theme'; +import { Button, Text } from '@/src/common/components'; +import * as Application from 'expo-application'; + +type ApkBuildProfile = 'preview' | 'production-apk'; + +const RELEASE_URL = process.env.EXPO_PUBLIC_RELEASE_URL; +const BUILD_PROFILE = process.env.EXPO_PUBLIC_BUILD_PROFILE; + +export interface LatestRelease { + buildNumber: number; + version: string; + apkUrl: string; +} + +export async function fetchLatestRelease( + buildProfile: ApkBuildProfile, +): Promise { + const response = await fetch( + `${RELEASE_URL}/android/${buildProfile}/latest.json`, + ); + + if (!response.ok) { + throw new Error(`Release check failed: ${response.status}`); + } + + return response.json(); +} + +export function UpdateModal() { + const [availableRelease, setAvailableRelease] = + useState(null); + const { theme } = useTheme(); + + const onUpdateNow = async () => { + if (!availableRelease) return; + + const apk = await File.downloadFileAsync( + availableRelease.apkUrl, + new File(Paths.cache, 'polycentric-update.apk'), + { idempotent: true }, + ); + const contentUri = await getContentUriAsync(apk.uri); + + await startActivityAsync('android.intent.action.VIEW', { + data: contentUri, + flags: 1, + type: 'application/vnd.android.package-archive', + }); + }; + + useEffect(() => { + if ( + Platform.OS !== 'android' || + !RELEASE_URL || + (BUILD_PROFILE !== 'preview' && BUILD_PROFILE !== 'production-apk') + ) { + return; + } + + void fetchLatestRelease(BUILD_PROFILE).then((release) => { + const installedBuildNumber = Number(Application.nativeBuildVersion); + if ( + Number.isNaN(installedBuildNumber) || + release.buildNumber <= installedBuildNumber + ) { + return; + } + + setAvailableRelease(release); + }); + }, []); + + return ( + setAvailableRelease(null)} + presentationStyle="fullScreen" + visible={availableRelease !== null} + > + + + + + + New Update + {'\n'} + Available + + + + New Android APK version available. Version{' '} + {availableRelease?.version} is now ready to install. + + + + +