Repository navigation
fix github markdown tables; fix mobile editor markdown rendering; upd… #32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build & Release APK | |
| # ─── Triggers ───────────────────────────────────────────────────────────────── | |
| on: | |
| push: | |
| branches: | |
| - main # every push to main → build + release | |
| workflow_dispatch: # manual "Run workflow" button → build + release | |
| # ─── Permissions ────────────────────────────────────────────────────────────── | |
| permissions: | |
| contents: write # needed to create GitHub Releases | |
| jobs: | |
| build: | |
| name: Build Signed APK | |
| runs-on: ubuntu-latest | |
| steps: | |
| # ── 1. Checkout ────────────────────────────────────────────────────────── | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| # ── 2. Node.js ─────────────────────────────────────────────────────────── | |
| - name: Set up Node.js 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| # ── 3. Preflight required GitHub Secrets ──────────────────────────────── | |
| - name: Check required GitHub Secrets | |
| run: | | |
| missing=0 | |
| for key in KEYSTORE_BASE64 KEY_ALIAS KEY_PASSWORD STORE_PASSWORD VITE_GOOGLE_CLIENT_ID; do | |
| value="${!key}" | |
| if [ -z "$value" ]; then | |
| echo "❌ Missing GitHub Secret: $key" | |
| missing=1 | |
| fi | |
| done | |
| if [ "$missing" -eq 1 ]; then | |
| echo "Add missing secrets in: Repository Settings → Secrets and variables → Actions" | |
| exit 1 | |
| fi | |
| echo "✅ Required secrets are present" | |
| env: | |
| KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} | |
| KEY_ALIAS: ${{ secrets.KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} | |
| VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }} | |
| # ── 4. Install npm dependencies ────────────────────────────────────────── | |
| - name: Install npm dependencies | |
| run: npm ci | |
| - name: Release safety check | |
| run: npm run release:check | |
| env: | |
| VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }} | |
| VITE_GITHUB_REPO: ${{ github.repository }} | |
| # ── 4. Patch Google Auth plugin ────────────────────────────────────────── | |
| - name: Patch GoogleAuth plugin — add Drive scope to access token | |
| run: | | |
| PLUGIN_JAVA="node_modules/@codetrix-studio/capacitor-google-auth/android/src/main/java/com/codetrixstudio/capacitor/GoogleAuth/GoogleAuth.java" | |
| DRIVE_SCOPE="https://www.googleapis.com/auth/drive.appdata" | |
| sed -i "s|oauth2:profile email\"|oauth2:profile email ${DRIVE_SCOPE}\"|g" "$PLUGIN_JAVA" | |
| echo "✅ Patched Drive scope in GoogleAuth plugin" | |
| grep -n "getAuthToken\|oauth2:" "$PLUGIN_JAVA" | |
| # ── 5. Derive version from run number ──────────────────────────────────── | |
| # Formula: run #45 → v1.4.5 run #106 → v2.0.6 | |
| # MAJOR = floor(run_number / 100) + 1 | |
| # MINOR = floor((run_number % 100) / 10) | |
| # PATCH = run_number % 10 | |
| - name: Derive version from run number | |
| id: versioning | |
| run: | | |
| RUN=${{ github.run_number }} | |
| MAJOR=$(( RUN / 100 + 1 )) | |
| MINOR=$(( (RUN % 100) / 10 )) | |
| PATCH=$(( RUN % 10 )) | |
| VERSION="${MAJOR}.${MINOR}.${PATCH}" | |
| TAG="v${VERSION}" | |
| echo "VERSION=${VERSION}" >> $GITHUB_ENV | |
| echo "TAG=${TAG}" >> $GITHUB_ENV | |
| echo "APK_NAME=Inkwell-${TAG}.apk" >> $GITHUB_ENV | |
| echo "version=${VERSION}" >> $GITHUB_OUTPUT | |
| echo "tag=${TAG}" >> $GITHUB_OUTPUT | |
| echo "▸ Run #${RUN} → ${TAG}" | |
| # ── 6. Write version into package.json ─────────────────────────────────── | |
| # So VITE_APP_VERSION (baked by vite.config.js) matches the release tag. | |
| # Uses a temp JS file to avoid shell quoting conflicts with node -e. | |
| - name: Write version into package.json | |
| run: | | |
| cat > /tmp/set-version.js << 'JSEOF' | |
| const fs = require('fs'); | |
| const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); | |
| pkg.version = process.env.VERSION; | |
| fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); | |
| console.log('✅ package.json version set to', pkg.version); | |
| JSEOF | |
| node /tmp/set-version.js | |
| # ── 7. Build web app (Vite) ─────────────────────────────────────────────── | |
| - name: Build web app (Vite) | |
| run: npm run build | |
| env: | |
| VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }} | |
| VITE_GITHUB_REPO: ${{ github.repository }} | |
| # ── 8. Java ─────────────────────────────────────────────────────────────── | |
| - name: Set up Java 21 | |
| uses: actions/setup-java@v4 | |
| with: | |
| java-version: '21' | |
| distribution: 'temurin' | |
| # ── 9. Android SDK ──────────────────────────────────────────────────────── | |
| - name: Set up Android SDK | |
| uses: android-actions/setup-android@v3 | |
| - name: Install Android SDK components | |
| run: | | |
| sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" | |
| # ── 10. Capacitor — generate Android project ────────────────────────────── | |
| - name: Add Capacitor Android platform | |
| run: npx cap add android | |
| - name: Add native APK installer plugin | |
| run: | | |
| set -euo pipefail | |
| JAVA_DIR="android/app/src/main/java/com/inkwell/notes" | |
| mkdir -p "$JAVA_DIR" android/app/src/main/res/xml | |
| cat > "$JAVA_DIR/MainActivity.java" <<'JAVAFILE' | |
| package com.inkwell.notes; | |
| import android.os.Bundle; | |
| import com.getcapacitor.BridgeActivity; | |
| public class MainActivity extends BridgeActivity { | |
| @Override | |
| public void onCreate(Bundle savedInstanceState) { | |
| registerPlugin(ApkInstallerPlugin.class); | |
| super.onCreate(savedInstanceState); | |
| } | |
| } | |
| JAVAFILE | |
| cat > "$JAVA_DIR/ApkInstallerPlugin.java" <<'JAVAFILE' | |
| package com.inkwell.notes; | |
| import android.content.Intent; | |
| import android.net.Uri; | |
| import android.os.Handler; | |
| import android.os.Looper; | |
| import androidx.core.content.FileProvider; | |
| import com.getcapacitor.JSObject; | |
| import com.getcapacitor.Plugin; | |
| import com.getcapacitor.PluginCall; | |
| import com.getcapacitor.PluginMethod; | |
| import com.getcapacitor.annotation.CapacitorPlugin; | |
| import java.io.File; | |
| import java.io.FileOutputStream; | |
| import java.io.InputStream; | |
| import java.net.HttpURLConnection; | |
| import java.net.URL; | |
| @CapacitorPlugin(name = "ApkInstaller") | |
| public class ApkInstallerPlugin extends Plugin { | |
| @PluginMethod | |
| public void installFromUrl(PluginCall call) { | |
| String url = call.getString("url"); | |
| String fileName = call.getString("fileName", "Inkwell-update.apk"); | |
| if (url == null || url.trim().isEmpty()) { | |
| call.reject("Missing APK URL"); | |
| return; | |
| } | |
| new Thread(() -> { | |
| try { | |
| File apk = new File(getContext().getCacheDir(), fileName); | |
| downloadToFile(url, apk); | |
| openInstaller(apk); | |
| new Handler(Looper.getMainLooper()).postDelayed(() -> { | |
| try { if (apk.exists()) apk.delete(); } catch (Exception ignored) {} | |
| }, 5 * 60 * 1000); | |
| JSObject ret = new JSObject(); | |
| ret.put("opened", true); | |
| ret.put("fileName", fileName); | |
| call.resolve(ret); | |
| } catch (Exception e) { | |
| call.reject("Failed to download/open APK installer", e); | |
| } | |
| }).start(); | |
| } | |
| private void downloadToFile(String urlText, File outFile) throws Exception { | |
| String current = urlText; | |
| for (int redirect = 0; redirect < 6; redirect++) { | |
| HttpURLConnection conn = (HttpURLConnection) new URL(current).openConnection(); | |
| conn.setInstanceFollowRedirects(false); | |
| conn.setRequestProperty("User-Agent", "Inkwell-Android-Updater"); | |
| conn.setRequestProperty("Accept", "application/octet-stream,*/*"); | |
| conn.connect(); | |
| int code = conn.getResponseCode(); | |
| if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) { | |
| String next = conn.getHeaderField("Location"); | |
| conn.disconnect(); | |
| if (next == null) throw new Exception("Redirect without Location"); | |
| current = next; | |
| continue; | |
| } | |
| if (code < 200 || code >= 300) { | |
| conn.disconnect(); | |
| throw new Exception("Download HTTP " + code); | |
| } | |
| try (InputStream in = conn.getInputStream(); FileOutputStream out = new FileOutputStream(outFile)) { | |
| byte[] buffer = new byte[64 * 1024]; | |
| int read; | |
| while ((read = in.read(buffer)) != -1) { | |
| out.write(buffer, 0, read); | |
| } | |
| } finally { | |
| conn.disconnect(); | |
| } | |
| if (!outFile.exists() || outFile.length() <= 0) { | |
| throw new Exception("Downloaded APK is empty"); | |
| } | |
| return; | |
| } | |
| throw new Exception("Too many redirects"); | |
| } | |
| private void openInstaller(File apk) throws Exception { | |
| Uri uri = FileProvider.getUriForFile( | |
| getContext(), | |
| getContext().getPackageName() + ".fileprovider", | |
| apk | |
| ); | |
| Intent intent = new Intent(Intent.ACTION_VIEW); | |
| intent.setDataAndType(uri, "application/vnd.android.package-archive"); | |
| intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); | |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); | |
| getContext().startActivity(intent); | |
| } | |
| } | |
| JAVAFILE | |
| cat > android/app/src/main/res/xml/file_paths.xml <<'XMLFILE' | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <paths xmlns:android="http://schemas.android.com/apk/res/android"> | |
| <cache-path name="update_apk_cache" path="." /> | |
| </paths> | |
| XMLFILE | |
| python3 <<'PYEOF' | |
| from pathlib import Path | |
| manifest = Path("android/app/src/main/AndroidManifest.xml") | |
| m = manifest.read_text() | |
| perm = '<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />' | |
| if perm not in m: | |
| m = m.replace("<application", " " + perm + "\n\n <application", 1) | |
| provider = "\n".join([ | |
| ' <provider', | |
| ' android:name="androidx.core.content.FileProvider"', | |
| ' android:authorities="${applicationId}.fileprovider"', | |
| ' android:exported="false"', | |
| ' android:grantUriPermissions="true">', | |
| ' <meta-data', | |
| ' android:name="android.support.FILE_PROVIDER_PATHS"', | |
| ' android:resource="@xml/file_paths" />', | |
| ' </provider>', | |
| ]) | |
| if '${applicationId}.fileprovider' not in m: | |
| m = m.replace("</application>", provider + "\n </application>", 1) | |
| manifest.write_text(m) | |
| print("✅ Android in-app APK installer patched") | |
| PYEOF | |
| echo "✅ Native APK updater downloads and installs without browser" | |
| # ── 11. Generate Android launcher icons ─────────────────────────────────── | |
| # Correct Android icon sizes: | |
| # - legacy ic_launcher.png / ic_launcher_round.png use 48/72/96/144/192 px | |
| # - adaptive foreground uses 108/162/216/324/432 px canvas | |
| # - adaptive visible safe zone is smaller, so we add padding instead of | |
| # filling the whole foreground canvas. This prevents the launcher from | |
| # cropping/zooming the icon on MIUI/Android adaptive icons. | |
| - name: Install Pillow | |
| run: pip install Pillow numpy --break-system-packages -q | |
| - name: Generate Android app icons | |
| run: | | |
| python3 << 'PYEOF' | |
| from PIL import Image | |
| import os, numpy as np | |
| SRC = "public/icon-512.png" | |
| RES = "android/app/src/main/res" | |
| DARK_BG = (17, 18, 20, 255) | |
| SIZES = { | |
| "mipmap-mdpi": (48, 108), | |
| "mipmap-hdpi": (72, 162), | |
| "mipmap-xhdpi": (96, 216), | |
| "mipmap-xxhdpi": (144, 324), | |
| "mipmap-xxxhdpi": (192, 432), | |
| } | |
| src = Image.open(SRC).convert("RGBA") | |
| # Crop transparent padding only. If the icon is already a full square, | |
| # keep it unchanged so the rounded-square background is preserved. | |
| arr = np.array(src) | |
| alpha = arr[:, :, 3] | |
| rows = np.any(alpha > 10, axis=1) | |
| cols = np.any(alpha > 10, axis=0) | |
| if rows.any() and cols.any(): | |
| rmin, rmax = np.where(rows)[0][[0, -1]] | |
| cmin, cmax = np.where(cols)[0][[0, -1]] | |
| if (rmax - rmin + 1) < src.height * 0.96 or (cmax - cmin + 1) < src.width * 0.96: | |
| src = src.crop((cmin, rmin, cmax + 1, rmax + 1)) | |
| def composite_square(img, size, fill=DARK_BG): | |
| canvas = Image.new("RGBA", (size, size), fill) | |
| fitted = img.resize((size, size), Image.LANCZOS) | |
| canvas.alpha_composite(fitted, (0, 0)) | |
| return canvas.convert("RGB") | |
| for folder, (launcher_px, fg_px) in SIZES.items(): | |
| path = os.path.join(RES, folder) | |
| os.makedirs(path, exist_ok=True) | |
| # Legacy launcher icons: exact Android required size. | |
| legacy = composite_square(src, launcher_px) | |
| legacy.save(os.path.join(path, "ic_launcher.png"), "PNG") | |
| legacy.save(os.path.join(path, "ic_launcher_round.png"), "PNG") | |
| # Adaptive foreground: exact canvas size, with safe-zone padding. | |
| # Android launchers mask/crop adaptive icons; 66% keeps the full | |
| # rounded-square icon visible on MIUI, Pixel Launcher, etc. | |
| inner = int(fg_px * 0.66) | |
| fg = Image.new("RGBA", (fg_px, fg_px), (0, 0, 0, 0)) | |
| fg_img = src.resize((inner, inner), Image.LANCZOS) | |
| offset = ((fg_px - inner) // 2, (fg_px - inner) // 2) | |
| fg.alpha_composite(fg_img, offset) | |
| fg.save(os.path.join(path, "ic_launcher_foreground.png"), "PNG") | |
| print("✅ Android icon sizes generated correctly with adaptive safe-zone padding") | |
| PYEOF | |
| # Dark adaptive icon background — matches Inkwell UI. | |
| cat > android/app/src/main/res/drawable/ic_launcher_background.xml << 'XML' | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <shape xmlns:android="http://schemas.android.com/apk/res/android"> | |
| <solid android:color="#111214"/> | |
| </shape> | |
| XML | |
| sed -i 's|<color name="ic_launcher_background">.*</color>|<color name="ic_launcher_background">#111214</color>|' \ | |
| android/app/src/main/res/values/ic_launcher_background.xml 2>/dev/null || true | |
| echo "✅ Adaptive icon background set to dark UI colour" | |
| # ── Patch build.gradle — set versionCode and versionName from run number ── | |
| # Without this Android always shows "1.0 (1)" regardless of the tag. | |
| # Uses Python to avoid shell quoting problems with sed + double-quotes. | |
| - name: Patch build.gradle version | |
| env: | |
| RUN_NUMBER: ${{ github.run_number }} | |
| APP_VERSION: ${{ env.VERSION }} | |
| run: | | |
| cat > /tmp/patch-gradle.py << 'PEOF' | |
| import re, os | |
| path = "android/app/build.gradle" | |
| code = os.environ["RUN_NUMBER"] | |
| name = os.environ["APP_VERSION"] | |
| txt = open(path).read() | |
| txt = re.sub(r"versionCode \d+", "versionCode " + code, txt) | |
| txt = re.sub(r'versionName "[^"]*"', 'versionName "' + name + '"', txt) | |
| open(path, "w").write(txt) | |
| print("versionCode=" + code + " versionName=" + name) | |
| PEOF | |
| python3 /tmp/patch-gradle.py | |
| - name: Write Android SDK path (local.properties) | |
| run: echo "sdk.dir=$ANDROID_HOME" > android/local.properties | |
| - name: Sync web assets into Android project | |
| run: npx cap sync android | |
| # ── 12. Make gradlew executable ─────────────────────────────────────────── | |
| - name: Make gradlew executable | |
| run: chmod +x android/gradlew | |
| # ── 13. Decode keystore ─────────────────────────────────────────────────── | |
| - name: Decode keystore | |
| run: | | |
| KEYSTORE_PATH="${{ github.workspace }}/android/app/inkwell-release.jks" | |
| echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > "$KEYSTORE_PATH" | |
| echo "KEYSTORE_PATH=$KEYSTORE_PATH" >> $GITHUB_ENV | |
| # ── 14. Print SHA-1 fingerprint ─────────────────────────────────────────── | |
| - name: Print release keystore SHA-1 | |
| run: | | |
| SHA1=$(keytool -list -v \ | |
| -keystore "$KEYSTORE_PATH" \ | |
| -alias "${{ secrets.KEY_ALIAS }}" \ | |
| -storepass "${{ secrets.STORE_PASSWORD }}" \ | |
| -noprompt 2>/dev/null | grep "SHA1:" | head -1 | awk '{print $2}') | |
| echo "### 🔑 Release Keystore SHA-1" >> $GITHUB_STEP_SUMMARY | |
| echo "Needed for Google Cloud Console Android OAuth credential:" >> $GITHUB_STEP_SUMMARY | |
| echo "\`\`\`" >> $GITHUB_STEP_SUMMARY | |
| echo "$SHA1" >> $GITHUB_STEP_SUMMARY | |
| echo "\`\`\`" >> $GITHUB_STEP_SUMMARY | |
| echo "📋 Package name: **com.inkwell.notes**" >> $GITHUB_STEP_SUMMARY | |
| # ── 15. Build signed release APK ────────────────────────────────────────── | |
| - name: Build release APK | |
| id: build_apk | |
| working-directory: android | |
| env: | |
| KEY_ALIAS: ${{ secrets.KEY_ALIAS }} | |
| KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} | |
| STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} | |
| run: | | |
| ./gradlew assembleRelease \ | |
| -Pandroid.injected.signing.store.file=$KEYSTORE_PATH \ | |
| -Pandroid.injected.signing.store.password=$STORE_PASSWORD \ | |
| -Pandroid.injected.signing.key.alias=$KEY_ALIAS \ | |
| -Pandroid.injected.signing.key.password=$KEY_PASSWORD \ | |
| 2>&1 | tee /tmp/gradle-build.log | |
| exit ${PIPESTATUS[0]} | |
| # ── 16. Rename APK ──────────────────────────────────────────────────────── | |
| - name: Rename APK | |
| run: | | |
| mv android/app/build/outputs/apk/release/app-release.apk \ | |
| android/app/build/outputs/apk/release/${{ env.APK_NAME }} | |
| # ── 17. Upload APK as workflow artifact ─────────────────────────────────── | |
| - name: Upload APK artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ env.APK_NAME }} | |
| path: android/app/build/outputs/apk/release/${{ env.APK_NAME }} | |
| retention-days: 30 | |
| # ── 18. Create or update GitHub Release ─────────────────────────────────── | |
| # Creates a tag (TAG env var) and publishes a release with the APK. | |
| # Runs on every successful build — every push to main gets a release. | |
| - name: Create GitHub Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ env.TAG }} | |
| name: "Inkwell ${{ env.TAG }}" | |
| body: | | |
| ## Inkwell ${{ env.TAG }} | |
| Built from run [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) | |
| ### Install | |
| 1. Download `${{ env.APK_NAME }}` below | |
| 2. On your Android phone: Settings → Security → enable **Install unknown apps** | |
| 3. Open the downloaded APK and tap Install | |
| files: android/app/build/outputs/apk/release/${{ env.APK_NAME }} | |
| draft: false | |
| prerelease: false | |
| # ── 19. Job Summary — SUCCESS ───────────────────────────────────────────── | |
| - name: Write success summary | |
| if: success() | |
| run: | | |
| cat >> $GITHUB_STEP_SUMMARY << EOF | |
| ## ✅ Build Succeeded | |
| | Field | Value | | |
| |-------|-------| | |
| | **Version** | \`${{ env.TAG }}\` | | |
| | **Run** | \`#${{ github.run_number }}\` | | |
| | **APK** | \`${{ env.APK_NAME }}\` | | |
| | **Commit** | [\`${{ github.sha }}\`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) | | |
| | **Triggered by** | \`${{ github.event_name }}\` | | |
| EOF | |
| # ── 20. Job Summary — FAILURE ───────────────────────────────────────────── | |
| - name: Write failure summary | |
| if: failure() | |
| run: | | |
| GRADLE_LOG="" | |
| if [ -f /tmp/gradle-build.log ]; then | |
| GRADLE_LOG=$(tail -100 /tmp/gradle-build.log) | |
| fi | |
| cat >> $GITHUB_STEP_SUMMARY << SUMMARY | |
| ## ❌ Build Failed | |
| ### Last 100 lines of Gradle log | |
| \`\`\` | |
| ${GRADLE_LOG:-No Gradle log captured — check the step logs above.} | |
| \`\`\` | |
| ### Common fixes | |
| | Error | Fix | | |
| |-------|-----| | |
| | \`KEYSTORE_BASE64\` | Check GitHub Secrets are set correctly | | |
| | \`SDK location not found\` | The setup-android step may have failed | | |
| | \`Could not resolve com.android\` | Network issue — re-run the workflow | | |
| | \`DEVELOPER_ERROR / code 10\` | Add Android OAuth credential in Google Cloud Console (see SHA-1 above) | | |
| SUMMARY |