diff --git a/bin/release-android.mjs b/bin/release-android.mjs new file mode 100755 index 0000000..23c9eb2 --- /dev/null +++ b/bin/release-android.mjs @@ -0,0 +1,494 @@ +#!/usr/bin/env node +/** + * Android Release Script - Universal Version + * + * Automatically builds Android APK and uploads to Gitea Release. + * + * Features: + * - Auto-detect Android project root (standalone or monorepo) + * - Auto-detect Gitea config from git remote URL (HTTPS/SSH) + * - Auto-detect Java Home from Android Studio + * - Support tag-based release workflow + * + * Environment Variables: + * - GITEA_TOKEN (required): Gitea API token + * - GITEA_API_URL (optional): Override Gitea API URL + * - GITEA_OWNER (optional): Override repository owner + * - GITEA_REPO (optional): Override repository name + * + * Usage: + * node release-android.mjs [android-dir] + * + * If android-dir is not specified, will auto-detect from current directory. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// ============================================================================ +// Project Detection +// ============================================================================ + +/** + * Find Android project root directory + * Looks for app/build.gradle.kts or build.gradle.kts with android plugin + */ +function findAndroidRoot(startDir) { + const candidates = [ + startDir, // Current dir is Android root + path.join(startDir, 'android'), // Monorepo: ./android/ + path.join(startDir, '..'), // Parent dir + ]; + + for (const dir of candidates) { + const buildGradle = path.join(dir, 'app/build.gradle.kts'); + const buildGradleGroovy = path.join(dir, 'app/build.gradle'); + + if (fs.existsSync(buildGradle) || fs.existsSync(buildGradleGroovy)) { + return path.resolve(dir); + } + } + + return null; +} + +/** + * Find git repository root + */ +function findGitRoot(startDir) { + try { + const result = execSync('git rev-parse --show-toplevel', { + cwd: startDir, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + return result; + } catch { + return null; + } +} + +/** + * Detect if project is a monorepo (Android is a subdirectory) + */ +function isMonorepo(androidDir, gitRoot) { + if (!gitRoot) return false; + return path.resolve(androidDir) !== path.resolve(gitRoot); +} + +// ============================================================================ +// Git/Gitea Configuration +// ============================================================================ + +/** + * Parse git remote URL to extract Gitea configuration + * Supports both HTTPS and SSH formats + */ +function detectGiteaConfig(gitRoot) { + try { + const remoteUrl = execSync('git remote get-url origin', { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + // HTTPS format: https://git.example.com/owner/repo.git + const httpsMatch = remoteUrl.match(/https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(\.git)?$/); + if (httpsMatch) { + return { + apiUrl: `https://${httpsMatch[1]}`, + owner: httpsMatch[2], + repo: httpsMatch[3] + }; + } + + // SSH format: git@git.example.com:owner/repo.git + const sshMatch = remoteUrl.match(/git@([^:]+):([^/]+)\/([^/]+?)(\.git)?$/); + if (sshMatch) { + return { + apiUrl: `https://${sshMatch[1]}`, + owner: sshMatch[2], + repo: sshMatch[3] + }; + } + } catch { + // Ignore errors + } + return {}; +} + +// ============================================================================ +// Java Detection +// ============================================================================ + +/** + * Detect Java Home, prioritizing Android Studio's bundled JDK + */ +function detectJavaHome() { + const possiblePaths = [ + // macOS - Android Studio bundled JDK + '/Applications/Android Studio.app/Contents/jbr/Contents/Home', + '/Applications/Android Studio.app/Contents/jre/Contents/Home', + // Linux - Android Studio bundled JDK + `${process.env.HOME}/android-studio/jbr`, + `${process.env.HOME}/android-studio/jre`, + // Environment variable + process.env.JAVA_HOME, + // Common system paths + '/usr/lib/jvm/java-11-openjdk', + '/usr/lib/jvm/java-17-openjdk', + ]; + + for (const javaHome of possiblePaths) { + if (javaHome && fs.existsSync(javaHome)) { + const javaBin = path.join(javaHome, 'bin', 'java'); + if (fs.existsSync(javaBin)) { + return javaHome; + } + } + } + + return null; +} + +// ============================================================================ +// Version & Tag Management +// ============================================================================ + +/** + * Read version from build.gradle.kts + */ +function readVersion(androidDir) { + const buildGradlePath = path.join(androidDir, 'app/build.gradle.kts'); + const buildGradleGroovyPath = path.join(androidDir, 'app/build.gradle'); + + let buildGradle; + if (fs.existsSync(buildGradlePath)) { + buildGradle = fs.readFileSync(buildGradlePath, 'utf-8'); + } else if (fs.existsSync(buildGradleGroovyPath)) { + buildGradle = fs.readFileSync(buildGradleGroovyPath, 'utf-8'); + } else { + return null; + } + + const versionMatch = buildGradle.match(/versionName\s*[=:]\s*["']([^"']+)["']/); + return versionMatch ? versionMatch[1] : null; +} + +/** + * Get tag name based on project structure + */ +function getTagName(version, isMonorepoProject) { + // Monorepo uses prefixed tag, standalone uses v-prefix + return isMonorepoProject ? `android-${version}` : `v${version}`; +} + +/** + * Get tag information (check existence and get annotation) + */ +function getTagInfo(tagName, gitRoot) { + try { + const tagExists = execSync(`git tag -l ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (!tagExists) { + return { exists: false }; + } + + // Get tag annotation + const annotation = execSync(`git tag -l --format='%(contents)' ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (annotation) { + return { exists: true, message: annotation }; + } + + // Fall back to commit message + const commitMsg = execSync(`git log -1 --format=%B ${tagName}`, { + cwd: gitRoot, + encoding: 'utf-8', + shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + return { exists: true, message: commitMsg || `Release ${tagName}` }; + } catch { + return { exists: false }; + } +} + +// ============================================================================ +// Build & Upload +// ============================================================================ + +/** + * Build Android APK + */ +function buildApk(androidDir, javaHome) { + console.log('Building APK...'); + execSync('./gradlew assembleRelease --quiet', { + cwd: androidDir, + stdio: 'inherit', + shell: true, + env: { ...process.env, JAVA_HOME: javaHome } + }); +} + +/** + * Find built APK file + */ +function findApk(androidDir) { + const signedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release.apk'); + const unsignedApkPath = path.join(androidDir, 'app/build/outputs/apk/release/app-release-unsigned.apk'); + + if (fs.existsSync(signedApkPath)) { + return { path: signedApkPath, signed: true }; + } else if (fs.existsSync(unsignedApkPath)) { + return { path: unsignedApkPath, signed: false }; + } + return null; +} + +/** + * Execute curl and return JSON response + */ +function curlJson(cmd) { + try { + const result = execSync(cmd, { encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + return JSON.parse(result); + } catch { + return null; + } +} + +/** + * Upload APK to Gitea Release + */ +function uploadToGitea(config) { + const { apiUrl, owner, repo, token, tagName, fileName, apkPath, releaseMessage } = config; + + const repoApiBase = `${apiUrl}/api/v1/repos/${owner}/${repo}`; + + // Get or create release + let releaseId; + const releases = curlJson( + `curl -s -H "Authorization: token ${token}" "${repoApiBase}/releases"` + ); + + if (Array.isArray(releases)) { + const existingRelease = releases.find((r) => r.tag_name === tagName); + if (existingRelease) { + releaseId = existingRelease.id; + console.log(`Found existing Release (ID: ${releaseId})`); + + // Delete existing asset with same name + const existingAsset = (existingRelease.assets || []).find((a) => a.name === fileName); + if (existingAsset) { + console.log(`Deleting existing asset: ${fileName}`); + execSync( + `curl -s -X DELETE -H "Authorization: token ${token}" "${repoApiBase}/releases/${releaseId}/assets/${existingAsset.id}"`, + { shell: true, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + } + } + + if (!releaseId) { + console.log('Creating new Release...'); + const releaseData = { + tag_name: tagName, + name: `Android APK ${tagName}`, + body: releaseMessage, + draft: false, + prerelease: false + }; + + const tempJsonPath = path.join(process.cwd(), '.release-data.json'); + fs.writeFileSync(tempJsonPath, JSON.stringify(releaseData)); + + const releaseInfo = curlJson( + `curl -s -X POST "${repoApiBase}/releases" \ + -H "Authorization: token ${token}" \ + -H "Content-Type: application/json" \ + -d @${tempJsonPath}` + ); + + fs.unlinkSync(tempJsonPath); + + if (!releaseInfo || !releaseInfo.id) { + throw new Error(`Failed to create release: ${JSON.stringify(releaseInfo)}`); + } + + releaseId = releaseInfo.id; + console.log(`Release created (ID: ${releaseId})`); + } + + // Upload APK + console.log('Uploading APK...'); + const uploadUrl = `${repoApiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; + const uploadResult = curlJson( + `curl -s -X POST "${uploadUrl}" \ + -H "Authorization: token ${token}" \ + -F "attachment=@${apkPath}"` + ); + + if (!uploadResult || !uploadResult.id) { + throw new Error(`Failed to upload APK: ${JSON.stringify(uploadResult)}`); + } + + return { + releaseUrl: `${apiUrl}/${owner}/${repo}/releases/tag/${tagName}`, + fileSize: uploadResult.size + }; +} + +// ============================================================================ +// Main +// ============================================================================ + +function main() { + const cwd = process.cwd(); + const argDir = process.argv[2]; + + console.log('='.repeat(60)); + console.log('Android Release Script'); + console.log('='.repeat(60)); + + // 1. Find Android project root + const searchDir = argDir ? path.resolve(cwd, argDir) : cwd; + const androidDir = findAndroidRoot(searchDir); + + if (!androidDir) { + console.error('Error: Cannot find Android project'); + console.error('Make sure you are in an Android project directory or specify the path'); + console.error('Usage: release-android [android-dir]'); + process.exit(1); + } + console.log(`Android project: ${androidDir}`); + + // 2. Find git root + const gitRoot = findGitRoot(androidDir); + if (!gitRoot) { + console.error('Error: Not a git repository'); + process.exit(1); + } + console.log(`Git root: ${gitRoot}`); + + const monorepo = isMonorepo(androidDir, gitRoot); + console.log(`Project type: ${monorepo ? 'Monorepo' : 'Standalone'}`); + + // 3. Detect Gitea configuration + const detected = detectGiteaConfig(gitRoot); + const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; + const GITEA_API_URL = process.env.GITEA_API_URL || detected.apiUrl || ''; + const GITEA_OWNER = process.env.GITEA_OWNER || detected.owner || ''; + const GITEA_REPO = process.env.GITEA_REPO || detected.repo || ''; + + if (!GITEA_TOKEN) { + console.error('Error: GITEA_TOKEN environment variable is not set'); + console.error('Please set: export GITEA_TOKEN="your_token"'); + process.exit(1); + } + + if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { + console.error('Error: Cannot detect Gitea repository configuration'); + console.error('Please set environment variables: GITEA_API_URL, GITEA_OWNER, GITEA_REPO'); + process.exit(1); + } + + console.log(`Gitea: ${GITEA_API_URL}/${GITEA_OWNER}/${GITEA_REPO}`); + + // 4. Read version + const version = readVersion(androidDir); + if (!version) { + console.error('Error: Cannot read versionName from build.gradle'); + process.exit(1); + } + console.log(`Version: ${version}`); + + // 5. Check tag + const tagName = getTagName(version, monorepo); + const tagInfo = getTagInfo(tagName, gitRoot); + + if (!tagInfo.exists) { + console.error(`Error: Git tag "${tagName}" not found`); + console.error(''); + console.error('Please create a tag before releasing:'); + console.error(` git tag -a ${tagName} -m "Release notes"`); + console.error(` git push origin ${tagName}`); + process.exit(1); + } + console.log(`Tag: ${tagName}`); + + // 6. Detect Java + const javaHome = detectJavaHome(); + if (!javaHome) { + console.error('Error: Cannot find Java Runtime'); + console.error('Please install JDK or ensure Android Studio is properly installed'); + process.exit(1); + } + console.log(`Java: ${javaHome}`); + + console.log(''); + console.log('Building...'); + + // 7. Build APK + try { + buildApk(androidDir, javaHome); + } catch (err) { + console.error('Build failed:', err.message); + process.exit(1); + } + + // 8. Find APK + const apk = findApk(androidDir); + if (!apk) { + console.error('Error: APK file not found'); + console.error('Check build output at: app/build/outputs/apk/release/'); + process.exit(1); + } + console.log(`APK: ${apk.path} (${apk.signed ? 'signed' : 'unsigned'})`); + + // 9. Upload to Gitea + const fileName = `${GITEA_REPO}-android-${version}.apk`; + + console.log(''); + console.log('Uploading to Gitea...'); + + try { + const result = uploadToGitea({ + apiUrl: GITEA_API_URL, + owner: GITEA_OWNER, + repo: GITEA_REPO, + token: GITEA_TOKEN, + tagName, + fileName, + apkPath: apk.path, + releaseMessage: tagInfo.message + }); + + console.log(''); + console.log('='.repeat(60)); + console.log('Release successful!'); + console.log(`File: ${fileName}`); + console.log(`Size: ${(result.fileSize / 1024 / 1024).toFixed(2)} MB`); + console.log(`URL: ${result.releaseUrl}`); + console.log('='.repeat(60)); + } catch (err) { + console.error('Upload failed:', err.message); + process.exit(1); + } +} + +main(); diff --git a/command/release-android.md b/command/release-android.md new file mode 100644 index 0000000..58b5bad --- /dev/null +++ b/command/release-android.md @@ -0,0 +1,61 @@ +--- +description: Build and release Android APK to Gitea +--- + +# Android Release Command + +Build Android APK and upload to Gitea Release. + +## Prerequisites Check + +First, verify the environment: + +```bash +# Check GITEA_TOKEN +echo "GITEA_TOKEN: ${GITEA_TOKEN:+SET}" + +# Check current directory for Android project +ls -la app/build.gradle.kts 2>/dev/null || ls -la android/app/build.gradle.kts 2>/dev/null || echo "No Android project found" + +# Check current version +grep -h 'versionName' app/build.gradle.kts android/app/build.gradle.kts 2>/dev/null | head -1 + +# Check existing tags +git tag -l '*android*' -l 'v*' | tail -5 +``` + +## Release Process + +**If there are uncommitted changes:** +1. Increment version: update `versionCode` (+1) and `versionName` in `app/build.gradle.kts` +2. Commit the changes with a descriptive message +3. Create annotated git tag: + - Monorepo: `git tag -a android-{version} -m "Release notes"` + - Standalone: `git tag -a v{version} -m "Release notes"` +4. Push commit and tag: `git push && git push origin {tag}` + +**If no uncommitted changes but tag doesn't exist:** +1. Create tag and push it + +## Build and Upload + +After tag is created and pushed, run: + +```bash +node ~/.config/opencode/bin/release-android.mjs +``` + +The script will: +- Auto-detect Android project root (standalone or monorepo) +- Auto-detect Gitea config from git remote URL (HTTPS/SSH) +- Auto-detect Java from Android Studio +- Build release APK +- Upload to Gitea Release + +## Error Handling + +- If `GITEA_TOKEN` is not set: `export GITEA_TOKEN="your_token"` +- If build fails: check Java/Android SDK installation +- If tag not found: create and push the tag first + +$ARGUMENTS diff --git a/opencode.json b/opencode.json index bd61761..1164098 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,27 @@ { "$schema": "https://opencode.ai/config.json", + "provider": { + "opencode": { + "models": { + "claude-opus-4-5": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + } + }, + "claude-sonnet-4-5": { + "options": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + } + } + } + } + }, "mcp": { "ref": { "type": "remote", diff --git a/skill/release-android/SKILL.md b/skill/release-android/SKILL.md new file mode 100644 index 0000000..e77cdb7 --- /dev/null +++ b/skill/release-android/SKILL.md @@ -0,0 +1,352 @@ +--- +name: release-android +description: Build Android APK and upload to Gitea Release with auto-detection of project structure and git configuration +--- + +# Android Release Skill + +Build and release Android APK to Gitea with automatic configuration detection. + +## Features + +- Auto-detect Android project root (standalone or monorepo) +- Auto-detect Gitea config from git remote URL (HTTPS/SSH) +- Auto-detect Java Home from Android Studio +- Support tag-based release workflow +- Support both Kotlin DSL (build.gradle.kts) and Groovy (build.gradle) + +## Prerequisites + +1. **GITEA_TOKEN**: Environment variable with Gitea API token +2. **Git tag**: Must create a tag before releasing +3. **Java/Android SDK**: Android Studio or JDK installed + +## Release Workflow + +### Step 1: Check Environment + +```bash +# Check GITEA_TOKEN is set +echo "GITEA_TOKEN: ${GITEA_TOKEN:+SET}" + +# Check Android project +ls app/build.gradle.kts 2>/dev/null || ls android/app/build.gradle.kts 2>/dev/null + +# Check current version +grep -h 'versionName' app/build.gradle.kts android/app/build.gradle.kts 2>/dev/null | head -1 + +# Check git tags +git tag -l '*android*' -l 'v*' | tail -5 +``` + +### Step 2: Prepare Release + +If there are uncommitted changes: +1. Update `versionCode` (+1) and `versionName` in `app/build.gradle.kts` +2. Commit changes +3. Create annotated git tag: + - Monorepo: `git tag -a android-{version} -m "Release notes"` + - Standalone: `git tag -a v{version} -m "Release notes"` +4. Push: `git push && git push origin {tag}` + +### Step 3: Run Release Script + +```bash +node ~/.config/opencode/bin/release-android.mjs +``` + +Or specify Android directory: +```bash +node ~/.config/opencode/bin/release-android.mjs ./android +``` + +## Script Location + +The release script is located at: `~/.config/opencode/bin/release-android.mjs` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| GITEA_TOKEN | Yes | Gitea API token | +| GITEA_API_URL | No | Override Gitea API URL (auto-detected from git remote) | +| GITEA_OWNER | No | Override repository owner (auto-detected) | +| GITEA_REPO | No | Override repository name (auto-detected) | + +## Tag Naming Convention + +- **Monorepo**: `android-{version}` (e.g., `android-1.2.0`) +- **Standalone**: `v{version}` (e.g., `v1.2.0`) + +## Output + +APK file naming: `{repo}-android-{version}.apk` + +Example: `bms-android-1.2.0.apk` + +## Error Handling + +| Error | Solution | +|-------|----------| +| GITEA_TOKEN not set | `export GITEA_TOKEN="your_token"` | +| Tag not found | Create and push tag first | +| Build failed | Check Java/Android SDK installation | +| APK not found | Check `app/build/outputs/apk/release/` | + +## Release Script Source + +
+Click to expand release-android.mjs source code + +```javascript +#!/usr/bin/env node +/** + * Android Release Script - Universal Version + * + * Automatically builds Android APK and uploads to Gitea Release. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +// Project Detection +function findAndroidRoot(startDir) { + const candidates = [ + startDir, + path.join(startDir, 'android'), + path.join(startDir, '..'), + ]; + + for (const dir of candidates) { + const buildGradle = path.join(dir, 'app/build.gradle.kts'); + const buildGradleGroovy = path.join(dir, 'app/build.gradle'); + + if (fs.existsSync(buildGradle) || fs.existsSync(buildGradleGroovy)) { + return path.resolve(dir); + } + } + return null; +} + +function findGitRoot(startDir) { + try { + return execSync('git rev-parse --show-toplevel', { + cwd: startDir, encoding: 'utf-8', shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + } catch { return null; } +} + +function isMonorepo(androidDir, gitRoot) { + if (!gitRoot) return false; + return path.resolve(androidDir) !== path.resolve(gitRoot); +} + +// Gitea Configuration +function detectGiteaConfig(gitRoot) { + try { + const remoteUrl = execSync('git remote get-url origin', { + cwd: gitRoot, encoding: 'utf-8', shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + // HTTPS: https://git.example.com/owner/repo.git + const httpsMatch = remoteUrl.match(/https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(\.git)?$/); + if (httpsMatch) { + return { apiUrl: `https://${httpsMatch[1]}`, owner: httpsMatch[2], repo: httpsMatch[3] }; + } + + // SSH: git@git.example.com:owner/repo.git + const sshMatch = remoteUrl.match(/git@([^:]+):([^/]+)\/([^/]+?)(\.git)?$/); + if (sshMatch) { + return { apiUrl: `https://${sshMatch[1]}`, owner: sshMatch[2], repo: sshMatch[3] }; + } + } catch {} + return {}; +} + +// Java Detection +function detectJavaHome() { + const possiblePaths = [ + '/Applications/Android Studio.app/Contents/jbr/Contents/Home', + '/Applications/Android Studio.app/Contents/jre/Contents/Home', + `${process.env.HOME}/android-studio/jbr`, + `${process.env.HOME}/android-studio/jre`, + process.env.JAVA_HOME, + '/usr/lib/jvm/java-11-openjdk', + '/usr/lib/jvm/java-17-openjdk', + ]; + + for (const javaHome of possiblePaths) { + if (javaHome && fs.existsSync(javaHome)) { + const javaBin = path.join(javaHome, 'bin', 'java'); + if (fs.existsSync(javaBin)) return javaHome; + } + } + return null; +} + +// Version & Tag +function readVersion(androidDir) { + const paths = [ + path.join(androidDir, 'app/build.gradle.kts'), + path.join(androidDir, 'app/build.gradle') + ]; + + for (const p of paths) { + if (fs.existsSync(p)) { + const content = fs.readFileSync(p, 'utf-8'); + const match = content.match(/versionName\s*[=:]\s*["']([^"']+)["']/); + if (match) return match[1]; + } + } + return null; +} + +function getTagName(version, isMonorepoProject) { + return isMonorepoProject ? `android-${version}` : `v${version}`; +} + +function getTagInfo(tagName, gitRoot) { + try { + const tagExists = execSync(`git tag -l ${tagName}`, { + cwd: gitRoot, encoding: 'utf-8', shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (!tagExists) return { exists: false }; + + const annotation = execSync(`git tag -l --format='%(contents)' ${tagName}`, { + cwd: gitRoot, encoding: 'utf-8', shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + if (annotation) return { exists: true, message: annotation }; + + const commitMsg = execSync(`git log -1 --format=%B ${tagName}`, { + cwd: gitRoot, encoding: 'utf-8', shell: true, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + return { exists: true, message: commitMsg || `Release ${tagName}` }; + } catch { return { exists: false }; } +} + +// Build & Upload +function buildApk(androidDir, javaHome) { + console.log('Building APK...'); + execSync('./gradlew assembleRelease --quiet', { + cwd: androidDir, stdio: 'inherit', shell: true, + env: { ...process.env, JAVA_HOME: javaHome } + }); +} + +function findApk(androidDir) { + const signed = path.join(androidDir, 'app/build/outputs/apk/release/app-release.apk'); + const unsigned = path.join(androidDir, 'app/build/outputs/apk/release/app-release-unsigned.apk'); + + if (fs.existsSync(signed)) return { path: signed, signed: true }; + if (fs.existsSync(unsigned)) return { path: unsigned, signed: false }; + return null; +} + +function curlJson(cmd) { + try { + const result = execSync(cmd, { encoding: 'utf-8', shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + return JSON.parse(result); + } catch { return null; } +} + +function uploadToGitea(config) { + const { apiUrl, owner, repo, token, tagName, fileName, apkPath, releaseMessage } = config; + const repoApiBase = `${apiUrl}/api/v1/repos/${owner}/${repo}`; + + let releaseId; + const releases = curlJson(`curl -s -H "Authorization: token ${token}" "${repoApiBase}/releases"`); + + if (Array.isArray(releases)) { + const existing = releases.find((r) => r.tag_name === tagName); + if (existing) { + releaseId = existing.id; + const asset = (existing.assets || []).find((a) => a.name === fileName); + if (asset) { + execSync(`curl -s -X DELETE -H "Authorization: token ${token}" "${repoApiBase}/releases/${releaseId}/assets/${asset.id}"`, + { shell: true, stdio: ['pipe', 'pipe', 'pipe'] }); + } + } + } + + if (!releaseId) { + const tempJson = path.join(process.cwd(), '.release-data.json'); + fs.writeFileSync(tempJson, JSON.stringify({ + tag_name: tagName, name: `Android APK ${tagName}`, + body: releaseMessage, draft: false, prerelease: false + })); + + const releaseInfo = curlJson(`curl -s -X POST "${repoApiBase}/releases" -H "Authorization: token ${token}" -H "Content-Type: application/json" -d @${tempJson}`); + fs.unlinkSync(tempJson); + + if (!releaseInfo?.id) throw new Error(`Failed to create release`); + releaseId = releaseInfo.id; + } + + const uploadUrl = `${repoApiBase}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; + const result = curlJson(`curl -s -X POST "${uploadUrl}" -H "Authorization: token ${token}" -F "attachment=@${apkPath}"`); + + if (!result?.id) throw new Error(`Failed to upload APK`); + return { releaseUrl: `${apiUrl}/${owner}/${repo}/releases/tag/${tagName}`, fileSize: result.size }; +} + +// Main +function main() { + const cwd = process.cwd(); + const argDir = process.argv[2]; + + const searchDir = argDir ? path.resolve(cwd, argDir) : cwd; + const androidDir = findAndroidRoot(searchDir); + if (!androidDir) { console.error('Error: Cannot find Android project'); process.exit(1); } + + const gitRoot = findGitRoot(androidDir); + if (!gitRoot) { console.error('Error: Not a git repository'); process.exit(1); } + + const monorepo = isMonorepo(androidDir, gitRoot); + const detected = detectGiteaConfig(gitRoot); + + const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; + const GITEA_API_URL = process.env.GITEA_API_URL || detected.apiUrl || ''; + const GITEA_OWNER = process.env.GITEA_OWNER || detected.owner || ''; + const GITEA_REPO = process.env.GITEA_REPO || detected.repo || ''; + + if (!GITEA_TOKEN) { console.error('Error: GITEA_TOKEN not set'); process.exit(1); } + if (!GITEA_API_URL || !GITEA_OWNER || !GITEA_REPO) { console.error('Error: Cannot detect Gitea config'); process.exit(1); } + + const version = readVersion(androidDir); + if (!version) { console.error('Error: Cannot read versionName'); process.exit(1); } + + const tagName = getTagName(version, monorepo); + const tagInfo = getTagInfo(tagName, gitRoot); + if (!tagInfo.exists) { console.error(`Error: Tag "${tagName}" not found`); process.exit(1); } + + const javaHome = detectJavaHome(); + if (!javaHome) { console.error('Error: Cannot find Java'); process.exit(1); } + + buildApk(androidDir, javaHome); + + const apk = findApk(androidDir); + if (!apk) { console.error('Error: APK not found'); process.exit(1); } + + const fileName = `${GITEA_REPO}-android-${version}.apk`; + const result = uploadToGitea({ + apiUrl: GITEA_API_URL, owner: GITEA_OWNER, repo: GITEA_REPO, + token: GITEA_TOKEN, tagName, fileName, apkPath: apk.path, + releaseMessage: tagInfo.message + }); + + console.log(`Release successful! URL: ${result.releaseUrl}`); +} + +main(); +``` + +