feat(mobile): improve android edgeless & ci (#15118)

#### PR Dependency Tree


* **PR #15118** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Improved mobile CI workflow with change-aware Android/iOS build jobs
and updated completion dependencies so tests wait for the relevant
mobile builds.
* **Performance / App Behavior**
* Enhanced Android WebView behavior: improved viewport/WebView tuning,
disabled zoom and scrollbars, and made mixed-content allowance
environment-aware (debug vs non-debug).
* Adjusted Android cleartext traffic handling based on build/debug
settings and Capacitor server URL configuration.
* **Tests**
* Strengthened Electron BYOK storage tests with per-test temporary
directories, mock control, and added coverage for when secure storage is
unavailable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-17 02:08:15 +08:00
committed by GitHub
parent a77d89bb1a
commit da7781a751
8 changed files with 272 additions and 17 deletions
@@ -27,6 +27,7 @@ android {
versionCode 1
versionName System.getenv('VERSION_NAME') ?: 'v1.0.0-local'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [usesCleartextTraffic: "false"]
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -49,6 +50,7 @@ android {
debug {
minifyEnabled false
debuggable true
manifestPlaceholders = [usesCleartextTraffic: "true"]
}
}
flavorDimensions = ['chanel']
@@ -13,14 +13,16 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:hardwareAccelerated="true"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch">
@@ -93,7 +93,27 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
override fun load() {
super.load()
AuthInitializer.initialize(bridge)
bridge.webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
configureEditorWebView()
}
private fun configureEditorWebView() {
bridge.webView.apply {
overScrollMode = View.OVER_SCROLL_NEVER
isHorizontalScrollBarEnabled = false
isVerticalScrollBarEnabled = false
settings.apply {
// Debug builds may point CAP_SERVER_URL at an HTTP dev server; release builds
// should keep mixed content blocked.
mixedContentMode = if (BuildConfig.DEBUG) {
WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
} else {
WebSettings.MIXED_CONTENT_NEVER_ALLOW
}
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
}
}
}
override fun present() {
@@ -7,6 +7,9 @@ const packageJson = JSON.parse(
readFileSync(resolve(__dirname, './package.json'), 'utf-8')
);
const capServerUrl = process.env.CAP_SERVER_URL;
const allowsCleartextServer = capServerUrl?.startsWith('http://') ?? false;
interface AppConfig {
affineVersion: string;
}
@@ -27,9 +30,6 @@ const config: CapacitorConfig & AppConfig = {
},
adjustMarginsForEdgeToEdge: 'force',
},
server: {
cleartext: true,
},
plugins: {
CapacitorHttp: {
enabled: false,
@@ -40,10 +40,11 @@ const config: CapacitorConfig & AppConfig = {
},
};
if (process.env.CAP_SERVER_URL) {
if (capServerUrl) {
Object.assign(config, {
server: {
url: process.env.CAP_SERVER_URL,
url: capServerUrl,
cleartext: allowsCleartextServer,
},
});
}
@@ -2,3 +2,21 @@ import '@affine/core/bootstrap/browser';
import '@affine/component/theme';
import '@affine/core/mobile/styles/mobile.css';
import './proxy';
import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx';
// Android WebView is less prone to the iOS WKWebView process kill, so keep the
// user-facing zoom range unchanged. These knobs only reduce gesture-time work
// and low-zoom canvas memory while preserving the normal-zoom render quality.
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60;
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300;
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true;
viewportRuntimeConfig.OVERSCAN_RATIO = 0.2;
viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0;
// Keep this aligned with iOS: the ~200ms gesture debounce plus this delay gives
// elements/connectors enough time to settle while still reappearing within ~500ms.
viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220;
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];
@@ -1,33 +1,62 @@
import os from 'node:os';
import path from 'node:path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp-byok-storage');
const electronMock = vi.hoisted(() => ({
tmpDir: '',
appOn: vi.fn(),
isEncryptionAvailable: vi.fn(() => true),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
}));
let disposeWorkspaceByokStorage: (() => void) | undefined;
vi.mock('electron', () => ({
app: {
getPath: () => tmpDir,
on: vi.fn(),
getPath: () => electronMock.tmpDir,
on: electronMock.appOn,
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
decryptString: (value: Buffer) => value.toString('utf-8'),
isEncryptionAvailable: electronMock.isEncryptionAvailable,
encryptString: electronMock.encryptString,
decryptString: electronMock.decryptString,
},
}));
vi.mock('../../src/main/logger', () => ({
logger: {
error: vi.fn(),
},
}));
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
electronMock.appOn.mockReset();
electronMock.isEncryptionAvailable.mockReset().mockReturnValue(true);
electronMock.encryptString
.mockReset()
.mockImplementation((value: string) => Buffer.from(value, 'utf-8'));
electronMock.decryptString
.mockReset()
.mockImplementation((value: Buffer) => value.toString('utf-8'));
disposeWorkspaceByokStorage = undefined;
await fs.remove(tmpDir);
electronMock.tmpDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'affine-byok-storage-')
);
});
afterEach(async () => {
disposeWorkspaceByokStorage?.();
disposeWorkspaceByokStorage = undefined;
vi.resetModules();
await fs.remove(tmpDir);
if (electronMock.tmpDir) {
await fs.remove(electronMock.tmpDir);
}
electronMock.tmpDir = '';
});
describe('byok storage handlers', () => {
@@ -84,6 +113,26 @@ describe('byok storage handlers', () => {
).resolves.toEqual([]);
});
test('does not write local keys when secure storage is unavailable', async () => {
electronMock.isEncryptionAvailable.mockReturnValue(false);
const { byokStorageHandlers, disposeWorkspaceByokStorage: dispose } =
await import('@affine/electron/main/byok-storage/handlers');
disposeWorkspaceByokStorage = dispose;
const ipcEvent = undefined;
await expect(byokStorageHandlers.isSupported()).resolves.toBe(false);
await expect(
byokStorageHandlers.upsertWorkspaceKey(ipcEvent, 'workspace-1', {
id: 'local-openai',
provider: 'openai',
name: 'OpenAI',
apiKey: 'sk-openai',
})
).rejects.toThrow('Secure BYOK key storage is not available.');
expect(electronMock.encryptString).not.toHaveBeenCalled();
});
test('preserves existing local key fields during partial updates', async () => {
const { byokStorageHandlers, disposeWorkspaceByokStorage: dispose } =
await import('@affine/electron/main/byok-storage/handlers');