diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 000000000..ba03bb96e --- /dev/null +++ b/.github/workflows/build-android.yml @@ -0,0 +1,272 @@ +name: Build Android APK + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + inputs: + build_type: + description: 'Build type to produce' + required: true + default: 'both' + type: choice + options: + - debug + - release + - both + app_env: + description: 'App environment' + required: true + default: 'development' + type: choice + options: + - development + - preview + - production + +env: + NODE_VERSION: '20' + JAVA_VERSION: '17' + +jobs: + # Fast typecheck - fails fast on type errors + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run typecheck + run: yarn typecheck + + # Prebuild job - generates native Android project, shared by all ABI builds + prebuild: + runs-on: ubuntu-latest + needs: typecheck + if: | + github.event_name != 'workflow_dispatch' || + github.event.inputs.build_type == 'debug' || + github.event.inputs.build_type == 'both' + outputs: + app_env: ${{ steps.env.outputs.APP_ENV }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Determine APP_ENV + id: env + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "APP_ENV=${{ github.event.inputs.app_env }}" >> $GITHUB_OUTPUT + else + echo "APP_ENV=development" >> $GITHUB_OUTPUT + fi + + - name: Generate native Android project + run: npx expo prebuild --platform android --no-install + env: + APP_ENV: ${{ steps.env.outputs.APP_ENV }} + + - name: Upload prebuild artifact + uses: actions/upload-artifact@v4 + with: + name: android-prebuild + path: android + retention-days: 1 + + # Parallel release builds - one per ABI (standalone APKs with bundled JS) + build: + runs-on: ubuntu-latest + needs: prebuild + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a, x86_64] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL & + sudo rm -rf /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup & + sudo docker system prune -af --volumes & + wait + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ matrix.abi }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}-${{ matrix.abi }}- + gradle-${{ runner.os }}- + + - name: Download prebuild artifact + uses: actions/download-artifact@v4 + with: + name: android-prebuild + path: android + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + cat >> ~/.gradle/gradle.properties << 'EOF' + org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + org.gradle.parallel=true + org.gradle.workers.max=4 + org.gradle.caching=true + kotlin.daemon.jvmargs=-Xmx2g + kotlin.incremental=true + EOF + + - name: Build Release APK for ${{ matrix.abi }} + working-directory: android + run: | + chmod +x ./gradlew + ./gradlew assembleRelease --no-daemon --build-cache -PreactNativeArchitectures=${{ matrix.abi }} + + - name: Upload Release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-${{ matrix.abi }} + path: android/app/build/outputs/apk/release/app-release.apk + retention-days: 14 + + # Release build - runs on workflow_dispatch only + build-release: + runs-on: ubuntu-latest + needs: typecheck + if: | + github.event_name == 'workflow_dispatch' && + (github.event.inputs.build_type == 'release' || github.event.inputs.build_type == 'both') + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, armeabi-v7a] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL & + sudo rm -rf /usr/local/share/powershell /usr/share/swift /usr/local/.ghcup & + sudo docker system prune -af --volumes & + wait + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'yarn' + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ matrix.abi }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}-${{ matrix.abi }}- + gradle-${{ runner.os }}- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Generate native Android project + run: npx expo prebuild --platform android --no-install + env: + APP_ENV: ${{ github.event.inputs.app_env }} + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + cat >> ~/.gradle/gradle.properties << 'EOF' + org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError + org.gradle.parallel=true + org.gradle.workers.max=4 + org.gradle.caching=true + kotlin.daemon.jvmargs=-Xmx2g + kotlin.incremental=true + EOF + + - name: Decode keystore + if: env.ANDROID_KEYSTORE_BASE64 != '' + run: | + echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + + - name: Build Release APK for ${{ matrix.abi }} + working-directory: android + run: | + chmod +x ./gradlew + ./gradlew assembleRelease --no-daemon --build-cache -PreactNativeArchitectures=${{ matrix.abi }} + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Upload Release APK + uses: actions/upload-artifact@v4 + with: + name: app-release-${{ matrix.abi }} + path: android/app/build/outputs/apk/release/app-release.apk + retention-days: 14 diff --git a/app.config.js b/app.config.js index 4d5ccea29..99e655974 100644 --- a/app.config.js +++ b/app.config.js @@ -78,6 +78,7 @@ export default { }, plugins: [ require("./plugins/withEinkCompatibility.js"), + require("./plugins/withNetworkSecurityConfig.js"), [ "expo-router", { diff --git a/plugins/withNetworkSecurityConfig.js b/plugins/withNetworkSecurityConfig.js new file mode 100644 index 000000000..41112c0c9 --- /dev/null +++ b/plugins/withNetworkSecurityConfig.js @@ -0,0 +1,88 @@ +const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Generates the network security config XML content based on environment. + * + * @param {boolean} allowCleartext - Whether to allow cleartext (HTTP) traffic + * @returns {string} XML content for network_security_config.xml + */ +function generateNetworkSecurityConfig(allowCleartext) { + return ` + + + + + + + + +`; +} + +/** + * Expo config plugin that configures Android network security settings. + * + * This plugin: + * 1. Creates a network_security_config.xml file that trusts user-installed CA certificates + * 2. Optionally enables cleartext (HTTP) traffic based on APP_ENV + * 3. Adds the networkSecurityConfig attribute to the AndroidManifest.xml + * + * Environment-based cleartext behavior: + * - development/preview: cleartext enabled (for local development servers) + * - production: cleartext disabled (HTTPS only) + * + * User CA certificates are always trusted to support mTLS with custom servers. + */ +const withNetworkSecurityConfig = (config) => { + const variant = process.env.APP_ENV || 'development'; + const allowCleartext = variant !== 'production'; + + // Step 1: Create the network_security_config.xml file + config = withDangerousMod(config, [ + 'android', + async (config) => { + const resXmlDir = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'src', + 'main', + 'res', + 'xml' + ); + + // Ensure the xml directory exists + if (!fs.existsSync(resXmlDir)) { + fs.mkdirSync(resXmlDir, { recursive: true }); + } + + const configPath = path.join(resXmlDir, 'network_security_config.xml'); + const xmlContent = generateNetworkSecurityConfig(allowCleartext); + + fs.writeFileSync(configPath, xmlContent, 'utf-8'); + + console.log('✅ Network security config plugin applied'); + console.log(` Cleartext traffic: ${allowCleartext ? 'ENABLED' : 'DISABLED'} (APP_ENV=${variant})`); + console.log(' User CA certificates: TRUSTED'); + + return config; + }, + ]); + + // Step 2: Add networkSecurityConfig attribute to AndroidManifest.xml + config = withAndroidManifest(config, (config) => { + const manifest = config.modResults.manifest; + const application = manifest.application?.[0]; + + if (application) { + application.$['android:networkSecurityConfig'] = '@xml/network_security_config'; + } + + return config; + }); + + return config; +}; + +module.exports = withNetworkSecurityConfig; diff --git a/scripts/setup-android-signing.sh b/scripts/setup-android-signing.sh new file mode 100755 index 000000000..02ed51a3f --- /dev/null +++ b/scripts/setup-android-signing.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# +# Setup Android signing keystore and configure GitHub Actions secrets +# +# Usage: ./scripts/setup-android-signing.sh [--repo owner/repo] +# +# This script will: +# 1. Generate a new Android signing keystore (if not exists) +# 2. Set up GitHub Actions secrets for release signing +# +# Requirements: +# - keytool (comes with JDK) +# - gh CLI (authenticated) +# - base64 +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Default values +KEYSTORE_PATH="android-release.keystore" +KEY_ALIAS="happy-release" +VALIDITY_DAYS=10000 + +# Parse arguments +REPO="" +while [[ $# -gt 0 ]]; do + case $1 in + --repo) + REPO="$2" + shift 2 + ;; + --keystore) + KEYSTORE_PATH="$2" + shift 2 + ;; + --alias) + KEY_ALIAS="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [--repo owner/repo] [--keystore path] [--alias key-alias]" + echo "" + echo "Options:" + echo " --repo GitHub repository (e.g., owner/repo). Auto-detected if not provided." + echo " --keystore Path for the keystore file (default: android-release.keystore)" + echo " --alias Key alias name (default: happy-release)" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +# Check requirements +check_command() { + if ! command -v "$1" &> /dev/null; then + echo -e "${RED}Error: $1 is required but not installed.${NC}" + exit 1 + fi +} + +check_command keytool +check_command gh +check_command base64 + +# Check gh auth status +if ! gh auth status &> /dev/null; then + echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" + exit 1 +fi + +# Auto-detect repo if not provided +if [ -z "$REPO" ]; then + REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true) + if [ -z "$REPO" ]; then + echo -e "${RED}Error: Could not detect repository. Use --repo owner/repo${NC}" + exit 1 + fi + echo -e "${GREEN}Detected repository: ${REPO}${NC}" +fi + +echo "" +echo -e "${YELLOW}=== Android Signing Setup ===${NC}" +echo "" + +# Generate keystore if it doesn't exist +if [ -f "$KEYSTORE_PATH" ]; then + echo -e "${YELLOW}Keystore already exists at ${KEYSTORE_PATH}${NC}" + read -p "Do you want to use the existing keystore? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}Please remove the existing keystore or specify a different path with --keystore${NC}" + exit 1 + fi +else + echo -e "${GREEN}Generating new Android signing keystore...${NC}" + echo "" + + # Prompt for passwords + read -s -p "Enter keystore password (min 6 characters): " KEYSTORE_PASSWORD + echo + read -s -p "Confirm keystore password: " KEYSTORE_PASSWORD_CONFIRM + echo + + if [ "$KEYSTORE_PASSWORD" != "$KEYSTORE_PASSWORD_CONFIRM" ]; then + echo -e "${RED}Error: Passwords do not match${NC}" + exit 1 + fi + + if [ ${#KEYSTORE_PASSWORD} -lt 6 ]; then + echo -e "${RED}Error: Password must be at least 6 characters${NC}" + exit 1 + fi + + read -s -p "Enter key password (press Enter to use same as keystore): " KEY_PASSWORD + echo + + if [ -z "$KEY_PASSWORD" ]; then + KEY_PASSWORD="$KEYSTORE_PASSWORD" + fi + + # Prompt for certificate details + echo "" + echo "Enter certificate details (press Enter for defaults):" + read -p " Common Name (CN) [Happy App]: " CN + CN=${CN:-"Happy App"} + read -p " Organization (O) [Happy]: " O + O=${O:-"Happy"} + read -p " Country (C) [US]: " C + C=${C:-"US"} + + # Generate the keystore + keytool -genkeypair \ + -v \ + -storetype PKCS12 \ + -keystore "$KEYSTORE_PATH" \ + -alias "$KEY_ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -validity $VALIDITY_DAYS \ + -storepass "$KEYSTORE_PASSWORD" \ + -keypass "$KEY_PASSWORD" \ + -dname "CN=${CN}, O=${O}, C=${C}" + + echo "" + echo -e "${GREEN}Keystore generated successfully at ${KEYSTORE_PATH}${NC}" +fi + +# If we used an existing keystore, prompt for passwords +if [ -z "$KEYSTORE_PASSWORD" ]; then + read -s -p "Enter keystore password: " KEYSTORE_PASSWORD + echo + read -s -p "Enter key password (press Enter if same as keystore): " KEY_PASSWORD + echo + if [ -z "$KEY_PASSWORD" ]; then + KEY_PASSWORD="$KEYSTORE_PASSWORD" + fi +fi + +# Encode keystore to base64 +echo "" +echo -e "${GREEN}Encoding keystore to base64...${NC}" +KEYSTORE_BASE64=$(base64 -w 0 "$KEYSTORE_PATH" 2>/dev/null || base64 -i "$KEYSTORE_PATH") + +# Set GitHub secrets +echo "" +echo -e "${GREEN}Setting GitHub Actions secrets for ${REPO}...${NC}" +echo "" + +echo "$KEYSTORE_BASE64" | gh secret set ANDROID_KEYSTORE_BASE64 --repo "$REPO" +echo -e " ${GREEN}✓${NC} ANDROID_KEYSTORE_BASE64" + +echo "$KEYSTORE_PASSWORD" | gh secret set ANDROID_KEYSTORE_PASSWORD --repo "$REPO" +echo -e " ${GREEN}✓${NC} ANDROID_KEYSTORE_PASSWORD" + +echo "$KEY_ALIAS" | gh secret set ANDROID_KEY_ALIAS --repo "$REPO" +echo -e " ${GREEN}✓${NC} ANDROID_KEY_ALIAS" + +echo "$KEY_PASSWORD" | gh secret set ANDROID_KEY_PASSWORD --repo "$REPO" +echo -e " ${GREEN}✓${NC} ANDROID_KEY_PASSWORD" + +echo "" +echo -e "${GREEN}=== Setup Complete ===${NC}" +echo "" +echo "GitHub Actions secrets have been configured for release signing." +echo "" +echo -e "${YELLOW}Important:${NC}" +echo " - Keep your keystore file (${KEYSTORE_PATH}) safe and backed up" +echo " - Never commit the keystore to version control" +echo " - Store your passwords securely" +echo "" +echo "The keystore is valid for $VALIDITY_DAYS days (~27 years)." +echo "" +echo "To trigger a release build, use:" +echo " gh workflow run build-android.yml -f build_type=release -f app_env=production" diff --git a/sources/components/markdown/MarkdownView.tsx b/sources/components/markdown/MarkdownView.tsx index d646ada15..ec37ad35b 100644 --- a/sources/components/markdown/MarkdownView.tsx +++ b/sources/components/markdown/MarkdownView.tsx @@ -150,7 +150,7 @@ function RenderCodeBlock(props: { content: string, language: string | null, firs selectable={props.selectable} /> - +