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}
/>
-
+