Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions .github/workflows/android-apk.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Android APK (debug)

# Triggers:
# - push v*-tauri tag → release APK (signed), updater manifest, attach to GitHub Release
# - workflow_dispatch → debug APK only, upload artifact (no release)
# - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release
# - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist
# (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking)
#
# Scope: full overlay/accessibility APK for ADB testing and tag releases.

Expand Down Expand Up @@ -30,17 +31,47 @@ jobs:
- name: Detect build mode
id: mode
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
is_tag=false
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
is_tag=true
fi
echo "is_tag_release=$is_tag" >> "$GITHUB_OUTPUT"

has_keystore=true
for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!name:-}" ]; then
has_keystore=false
break
fi
done

if $is_tag; then
echo "mode=release" >> "$GITHUB_OUTPUT"
echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "signing=tag-release" >> "$GITHUB_OUTPUT"
echo "signing_label=Tag release with release keystore" >> "$GITHUB_OUTPUT"
elif $has_keystore; then
echo "mode=release" >> "$GITHUB_OUTPUT"
echo "label=dispatch-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "signing=dispatch-release" >> "$GITHUB_OUTPUT"
echo "signing_label=Manual dispatch with release keystore (overlay install OK)" >> "$GITHUB_OUTPUT"
else
echo "mode=debug" >> "$GITHUB_OUTPUT"
echo "label=run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "label=unsigned-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "signing=debug-fallback" >> "$GITHUB_OUTPUT"
echo "signing_label=Unsigned debug fallback (no keystore secrets)" >> "$GITHUB_OUTPUT"
echo "::notice title=Unsigned debug APK::ANDROID_KEYSTORE_* secrets not configured. Building debug APK without release signing. Overlay install and user data preservation require the release keystore; uninstall before installing if replacing a signed build."
fi

- name: Check updater signing availability (tag release)
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
Expand All @@ -51,7 +82,7 @@ jobs:
fi

- name: Check Android keystore secrets (tag release)
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
Expand Down Expand Up @@ -281,7 +312,7 @@ jobs:
PY

- name: Sign release APKs (minisign)
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
working-directory: openless-all/app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
Expand All @@ -295,7 +326,7 @@ jobs:
"${{ steps.apk.outputs.x86_64_path }}"

- name: Write Android updater manifests
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
working-directory: openless-all/app
env:
OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }}
Expand All @@ -311,7 +342,7 @@ jobs:
done

- name: Append release files (manifests + signatures)
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
id: release_assets
shell: bash
run: |
Expand Down Expand Up @@ -356,7 +387,7 @@ jobs:
if-no-files-found: error

- name: Prepare Android release body
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
shell: bash
run: |
cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF'
Expand All @@ -374,7 +405,7 @@ jobs:
EOF

- name: Attach Android assets to GitHub Release
if: steps.mode.outputs.mode == 'release'
if: steps.mode.outputs.is_tag_release == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
Expand All @@ -384,3 +415,26 @@ jobs:
append_body: true
body_path: ${{ runner.temp }}/android-release-body.md
files: ${{ steps.release_assets.outputs.files }}

- name: Write build summary
if: always() && steps.mode.outputs.signing != ''
run: |
cat >> "$GITHUB_STEP_SUMMARY" << EOF
### Android APK build

| Field | Value |
|-------|-------|
| Gradle mode | \`${{ steps.mode.outputs.mode }}\` |
| Signing | ${{ steps.mode.outputs.signing_label }} |
| Artifact label | \`${{ steps.mode.outputs.label }}\` |

EOF
if [ "${{ steps.mode.outputs.signing }}" = "debug-fallback" ]; then
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
> **Unsigned debug APK.** Debug builds use a CI-only signature. Use `adb install -r` only when replacing the same debug build. To upgrade from a signed release without losing user data, configure `ANDROID_KEYSTORE_*` repo secrets and re-run this workflow.
EOF
elif [ "${{ steps.mode.outputs.signing }}" = "dispatch-release" ]; then
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
> **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install.
EOF
fi
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ jobs:
# generate_context! requires frontendDist (../dist) to exist on Android too.
run: npm run build

- name: Check Android updater pubkey matches tauri.conf.json
run: npm run check:android-updater-pubkey

- name: Check Tauri backend (Android target)
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android

Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ target/
ci-artifacts/
*.apk

# UI smoke / Playwright 截图与报告(本地校验用,非源码)
openless-all/ui-check-screenshots/
test-results/
playwright-report/
playwright/.cache/
blob-report/

# Android 真机调试产物(日志 / 截图 / adb 导出 / 数据备份 / 临时还原目录)
diagnostics/
android-logs/
Expand Down
28 changes: 26 additions & 2 deletions docs/android-mobile-apk-overlay-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ Desktop permissions live in `capabilities/default.json` with `"platforms": ["mac

| Trigger | Build mode | Behavior |
|---|---|---|
| `workflow_dispatch` | **debug** | Build 4 split debug APKs → upload Actions artifacts only |
| Push tag `v*-tauri` / `v*-beta-tauri` | **release** | Signed release APKs + minisign `.sig` + `latest-android-{arch}[-beta].json` → attach to GitHub Release |
| `workflow_dispatch` | **release** if `ANDROID_KEYSTORE_*` secrets configured; else **debug (unsigned)** | Upload Actions artifacts; non-blocking fallback with job summary notice when unsigned |
| Push tag `v*-tauri` / `v*-beta-tauri` | **release** (required secrets) | Signed release APKs + minisign `.sig` + `latest-android-{arch}[-beta].json` → attach to GitHub Release |

`OPENLESS_RELEASE_CHANNEL` matches desktop `release-tauri.yml`: `-beta-tauri` → beta (prerelease manifests); otherwise stable.

Expand Down Expand Up @@ -277,6 +277,30 @@ OPENLESS_UPDATE_APK_DIR=... OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH=
- `merge-android-overlay-manifest.mjs` — overlay + accessibility service
- `merge-android-updater-manifest.mjs` — `REQUEST_INSTALL_PACKAGES` + `FileProvider` for APK install

### In-app updater (Android)

Custom Rust module [`openless-all/app/src-tauri/src/android/updater.rs`](../openless-all/app/src-tauri/src/android/updater.rs) + shared helpers [`updater_logic.rs`](../openless-all/app/src-tauri/src/android/updater_logic.rs). Desktop continues to use `tauri-plugin-updater`.

**Manifest URLs** (generated by [`write-updater-manifest.mjs`](../openless-all/app/scripts/write-updater-manifest.mjs)):

| Channel | Filename | GitHub path |
|---|---|---|
| Stable | `latest-android-{arch}.json` | `releases/latest/download/...` |
| Beta | `latest-android-{arch}-beta.json` | `releases/download/{v*-beta-tauri tag}/...` |

Client tries mirror URL first (`-mirror.json`), then direct GitHub. Beta tag is resolved from `releases.atom` (first `-beta-tauri` entry).

**User-facing behavior**:

- **Settings → About**: manual “Check stable update” always fetches stable manifest.
- **Settings → Advanced**: Beta toggle sets `prefs.updateChannel` for background checks; “Check Beta update” always fetches beta manifest (independent of toggle).
- **Settings → Advanced → Auto-update** (Android): `autoUpdateCheck` toggle — when on, `AutoUpdateGate` checks on launch (+4s) and every 60 minutes using `updateChannel`, then **automatically downloads**, minisign-verifies, and opens the **system APK installer**. When off, only manual buttons run.
- **Desktop**: `autoUpdateCheck` only auto-checks; user confirms in `UpdateDialog` before download/install/restart.

**Install flow**: download to app cache → minisign verify → JNI `install_apk_from_path` → Android system installer. Over-the-air replace is **not** implemented in-app; the user completes install via the system UI. Requires matching APK signature for upgrade.

**Prefs**: `updateChannel` (`stable` | `beta`) = background auto-update channel only; manual buttons pass explicit channel and ignore this pref.

---

## 8. 验收标准
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ src-tauri/src/android/ # Rust 运行时模块(crate::android)
| `overlay.rs` | 悬浮窗权限与 show/hide |
| `accessibility.rs` | 无障碍服务状态与 paste |
| `insert.rs` | 跨 App 文本插入策略 |
| `updater.rs` | 应用内更新(manifest 拉取、minisign 校验、系统安装器) |
| `updater_logic.rs` | 更新 URL / 版本比较纯函数(全平台可测) |
| `types.rs` | Android 偏好与状态类型 |

主 crate 通过 `mod android;` 引入,常用 API 经 `crate::android::` 扁平 re-export。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityWindowInfo
import androidx.annotation.Keep
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
Expand Down Expand Up @@ -288,12 +289,14 @@ class OpenLessAccessibilityService : AccessibilityService() {
private set

@JvmStatic
@Keep
fun pasteToFocusedField(): Boolean {
instance?.let { return it.performPasteToFocusedField() }
return sendPasteRequestToAccessibilityProcess()
}

@JvmStatic
@Keep
fun captureSelectedText(): String {
return instance?.captureSelectedTextFromFocusedNode().orEmpty()
}
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/android/kotlin/OpenLessOverlayBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package com.openless.app

import android.os.Handler
import android.os.Looper
import androidx.annotation.Keep

/**
* Rust calls back into this object to refresh overlay UI state.
*/
@Keep
object OpenLessOverlayBridge {
private val mainHandler = Handler(Looper.getMainLooper())

Expand All @@ -16,13 +18,15 @@ object OpenLessOverlayBridge {
fun onCapsuleStateChanged(state: String, message: String?)
}

@Keep
@JvmStatic
fun onCapsuleStateChanged(state: String, message: String?) {
mainHandler.post {
listener?.onCapsuleStateChanged(state, message)
}
}

@Keep
@JvmStatic
fun showToast(message: String) {
mainHandler.post {
Expand Down
55 changes: 37 additions & 18 deletions openless-all/app/android/kotlin/OpenLessOverlayService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
ACTION_SHOW -> showOverlay()
ACTION_START_RECORDING -> {
showOverlay()
startRecordingFromOverlay()
if (!tryPromoteRecordingForeground()) {
abortRecordingStart(startId)
return START_NOT_STICKY
}
beginDictationFromOverlay()
}
ACTION_HIDE -> {
hideOverlay()
Expand Down Expand Up @@ -634,26 +638,41 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}
}

private fun abortRecordingStart(startId: Int) {
recording = false
processing = false
try {
OpenLessNative.nativeCancelDictation()
} catch (error: Throwable) {
Log.w(TAG, "cancel dictation after foreground failure", error)
}
stopSelf(startId)
}

private fun beginDictationFromOverlay(translation: Boolean = false) {
try {
if (translation) {
OpenLessNative.nativeStartDictationWithTranslation(true)
} else {
OpenLessNative.nativeStartDictation()
}
recording = true
processing = false
setArmed(false)
applyVisualState(OverlayVisualState.Recording)
} catch (error: Throwable) {
Log.w(TAG, "start dictation bridge unavailable", error)
recording = false
processing = false
applyVisualState(OverlayVisualState.Error)
showToast("语音服务未就绪,请打开 OpenLess 后重试")
}
}

private fun startRecordingFromOverlay(translation: Boolean = false) {
showOverlay()
if (tryPromoteRecordingForeground()) {
try {
if (translation) {
OpenLessNative.nativeStartDictationWithTranslation(true)
} else {
OpenLessNative.nativeStartDictation()
}
recording = true
processing = false
setArmed(false)
applyVisualState(OverlayVisualState.Recording)
} catch (error: Throwable) {
Log.w(TAG, "start dictation bridge unavailable", error)
recording = false
processing = false
applyVisualState(OverlayVisualState.Error)
showToast("语音服务未就绪,请打开 OpenLess 后重试")
}
beginDictationFromOverlay(translation)
return
}
applyVisualState(OverlayVisualState.Error)
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/android/kotlin/OpenLessPermissionBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.annotation.Keep
import java.util.concurrent.atomic.AtomicBoolean

@Keep
object OpenLessPermissionBridge {
private const val TAG = "OpenLessPermissionBridge"

private val requestInFlight = AtomicBoolean(false)

@Keep
@JvmStatic
fun requestRecordAudioPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
Expand Down
4 changes: 4 additions & 0 deletions openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.annotation.Keep
import androidx.core.content.FileProvider
import java.io.File

/**
* Triggers system package installer for a downloaded APK via FileProvider.
*/
@Keep
object OpenLessUpdateInstaller {
@Keep
@JvmStatic
fun installApk(context: Context, apkPath: String): Boolean {
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:aura-skin": "node scripts/aura-skin-contract.test.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs"
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
Expand Down
Loading
Loading