diff --git a/docs/android-capacitor-oauth-template.md b/docs/android-capacitor-oauth-template.md new file mode 100644 index 0000000..642a161 --- /dev/null +++ b/docs/android-capacitor-oauth-template.md @@ -0,0 +1,286 @@ +# Android + Capacitor 8 + OAuth — полный гайд по проблемам и решениям + +> **Контекст:** Vue SPA завёрнут в Capacitor 8. OAuth через внешний провайдер (gnexus-auth). Должно работать одинаково на Android и десктопе. +> **Дата:** 2026-06-08. **Android:** 15 (API 35). **Capacitor:** 8.4.0. + +--- + +## 1. Архитектура авторизации + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Android App │────▶│ Chrome (full) │────▶│ gnexus-auth │ +│ (Capacitor WV) │ │ Intent.ACTION_VIEW│ │ (OAuth page) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + ▲ │ │ + │ ▼ ▼ + │ ┌──────────────────┐ ┌─────────────────┐ + └──────────────│ /auth/callback │────▶│ /auth/mobile- │ + intent:// │ (server OAuth │ │ bridge │ + deep link │ callback) │ │ (bridge page) │ + └──────────────────┘ └─────────────────┘ +``` + +### Почему именно такой flow + +- **Custom Tabs** (`Browser.open()` из `@capacitor/browser`) — не поддерживает `intent://` URL. Браузер открывает CCT, авторизуется, но не может вернуть управление в приложение. **Нельзя использовать для deep link return.** +- **Full Chrome** (`Intent.ACTION_VIEW`) — открывает полный Chrome с адресной строкой. Chrome нативно поддерживает `intent://` ссылки. После авторизации bridge page делает `window.location.href = "intent://auth/callback?token=...#Intent;scheme=shserv;package=com.gnexus.shserv;end"` — и управление возвращается в приложение. +- **Capacitor `BridgeWebViewClient`** автоматически перехватывает навигацию на внешние хосты и открывает их через `Intent.ACTION_VIEW`. Поэтому в Vue-коде достаточно `window.location.href = buildServerUrl('/auth/login?return_to=/auth/mobile-bridge')` — без `Browser.open()`. + +--- + +## 2. Проблема: OAuth не возвращает в приложение + +### Симптом +Нажимаешь "Sign In", открывается браузер, авторизуешься, но приложение не получает токен. Пользователь остаётся в браузере. + +### Причина 1: Custom Tabs блокируют intent:// +`Browser.open({ url: '...' })` открывает Chrome Custom Tabs (или DuckDuckGo CCT). CCT — это sandboxed WebView, который не умеет обрабатывать `intent://` ссылки. Bridge page пытается `window.location.href = "intent://..."`, но CCT не передаёт это в систему. + +### Решение: не использовать Browser.open() +```javascript +// ❌ НЕЛЬЗЯ — открывает CCT, intent:// не сработает +import { Browser } from '@capacitor/browser'; +Browser.open({ url: authUrl }); + +// ✅ ПРАВИЛЬНО — Capacitor BridgeWebViewClient сам перехватывает +// внешние хосты и открывает через Intent.ACTION_VIEW (full Chrome) +window.location.href = buildServerUrl( + `/auth/login?return_to=${encodeURIComponent('/auth/mobile-bridge')}` +); +``` + +### Причина 2: Сервер теряет returnTo +На сервере `AuthController::callback()` вызывал `$service->handleCallback($code, $state)`, который **удаляет** OAuth state из PHP-сессии (`stateStore->forget($state)`). После этого код пытался прочитать `$_SESSION['gauth_state'][$state]['context']` — но state уже стёрт. `returnTo` всегда падал в `'/'`. + +### Решение: читать returnTo ДО handleCallback() +```php +public function callback() +{ + // ... + + // ✅ Читаем returnTo ПОКА state живой в сессии + $context = $_SESSION['gauth_state'][$state]['context'] ?? []; + + $service = new AuthService(); + $user = $service->handleCallback($code, $state); // state тут стирается + + // ... + + $returnTo = $context['return_to'] ?? '/'; + return $this->utils()->redirect($returnTo); +} +``` + +--- + +## 3. Проблема: Desktop сломался после добавления mobile + +### Симптом +После добавления Capacitor-зависимостей десктопный клиент показывает `/#/mobile-setup` и требует указать адрес сервера. Либо авторизация цикличит. + +### Причина: `isNativeApp()` проверял `window.Capacitor !== undefined` +```javascript +// ❌ НЕЛЬЗЯ — window.Capacitor injects и в браузер (dev serve, live reload) +export function isNativeApp() { + return typeof window !== "undefined" && window.Capacitor !== undefined; +} +``` + +Capacitor runtime может присутствовать и в браузере (например, при `npm run dev` через Vite proxy). Десктоп думает что он нативное приложение: +- Показывает `/#/mobile-setup` (router guard `hasServerUrl()`) +- Редиректит на `/auth/mobile-bridge` с `intent://` (который в браузере не работает) +- `initAccessToken()` не вызывается (был под `if (isNativeApp())`), токен не загружается + +### Решение: использовать `Capacitor.isNativePlatform()` +```javascript +import { Capacitor } from "@capacitor/core"; + +export function isNativeApp() { + return Capacitor.isNativePlatform(); // true только на реальных iOS/Android +} +``` + +Также: **всегда** вызывать `initAccessToken()` в `bootstrap()`, не под `if (isNativeApp())`. Эта функция сама внутри различает web (`localStorage`) и native (`Preferences`). + +--- + +## 4. Проблема: Статус бар перекрывает интерфейс (Android 15) + +### Симптом +Верхняя часть приложения (заголовок, логотип) залезает под статус бар. Статус бар прозрачный. + +### Причина: Android 15 (API 35) mandates edge-to-edge +Android 15 **принудительно** включает edge-to-edge для всех приложений. Контент рисуется за статус баром и navigation bar. Это нельзя отключить стандартными `windowTranslucentStatus=false`. + +Capacitor 8 добавляет `SystemBars` plugin, который: +1. Устанавливает `OnApplyWindowInsetsListener` на CoordinatorLayout +2. Возвращает `systemBars insets = 0`, блокируя `fitsSystemWindows` +3. Пытается управлять padding программно, но это ненадёжно + +### Решение: комплексный фикс трёх уровней + +#### A. Custom layout с `fitsSystemWindows="true"` +Создать `res/layout/capacitor_bridge_layout_main.xml` — override'ит Capacitor'овский layout: +```xml + + + +``` + +#### B. MainActivity: сброс SystemBars listener + fitsSystemWindows +```java +public class MainActivity extends BridgeActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(R.style.AppTheme_NoActionBar); + super.onCreate(savedInstanceState); + + // Prevent edge-to-edge + Window window = getWindow(); + WindowCompat.setDecorFitsSystemWindows(window, true); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); + + // ✅ Сбрасываем OnApplyWindowInsetsListener от Capacitor SystemBars plugin + View webView = findViewById(com.getcapacitor.android.R.id.webview); + if (webView != null) { + ViewGroup parent = (ViewGroup) webView.getParent(); + if (parent != null) { + ViewCompat.setOnApplyWindowInsetsListener(parent, null); + parent.setFitsSystemWindows(true); + } + } + } +} +``` + +#### C. Theme opt-out + capacitor config +`styles.xml` — оба стиля: +```xml +true +``` + +`capacitor.config.json`: +```json +"android": { + "adjustMarginsForEdgeToEdge": "force" +} +``` + +--- + +## 5. Проблема: Splash screen размытый / расплющенный + +### Симптом +Иконка на splash screen размытая (низкое разрешение) или расплющенная (неправильные пропорции). + +### Причина 1: PNG слишком маленькие (размытость) +Capacitor по умолчанию генерирует splash PNG ~200×200px для `drawable/`. На xxxhdpi (1440×2560) это растягивается в ~7× — получается мыло. + +### Решение: правильные размеры per density +``` +mdpi: 360×640 / 640×360 (logo 120px) +hdpi: 540×960 / 960×540 (logo 180px) +xhdpi: 720×1280 / 1280×720 (logo 240px) +xxhdpi: 1080×1920 / 1920×1080 (logo 360px) +xxxhdpi: 1440×2560 / 2560×1440 (logo 480px) +``` +Генерировать из SVG через ImageMagick, центрировать логотип `gravity=center`. + +### Причина 2: Android 12+ SplashScreen API (расплющенность) +Android 12+ (API 31+) использует SplashScreen API. Иконка должна быть **288dp** canvas с **192dp** content area. Если иконка меньше или неправильного формата — система растягивает/сплющивает. + +### Решение: SplashScreen API с per-density PNG +`values-v31/styles.xml`: +```xml + +``` + +`drawable-*dpi-v31/ic_splash.png`: +``` +mdpi: 288×288 (logo 192px) +hdpi: 432×432 (logo 288px) +xhdpi: 576×576 (logo 384px) +xxhdpi: 864×864 (logo 576px) +xxxhdpi: 1152×1152 (logo 768px) +``` + +Генерация: +```bash +magick -size ${canvas}x${canvas} xc:#0f172a \ + logo.svg -resize ${logo}x${logo} \ + -gravity center -compose over -composite \ + drawable-${d}-v31/ic_splash.png +``` + +**Не использовать `layer-list` с маленьким `48dp`** — это неправильный размер для SplashScreen API. + +--- + +## 6. Чеклист для следующего проекта + +### Capacitor setup +- [ ] `capacitor.config.json` с `"appId": "com.company.app"` — уникальный +- [ ] `androidScheme` не конфликтует с deep link scheme (shserv / https) +- [ ] `CapacitorHttp.enabled: true` если нужен native HTTP +- [ ] `adjustMarginsForEdgeToEdge: "force"` для Android 15+ + +### Deep link (OAuth return) +- [ ] `AndroidManifest.xml` intent-filter для `scheme://host/path` +- [ ] Server bridge page с `intent://` URL +- [ ] Vue `appUrlOpen` listener + `getLaunchUrl()` для cold-start +- [ ] `window.location.reload()` после получения токена (чтобы bootstrap перезапустился) + +### Desktop vs Native branching +- [ ] `isNativeApp()` через `Capacitor.isNativePlatform()`, НЕ `window.Capacitor` +- [ ] `initAccessToken()` и `initServerUrl()` вызывать **всегда** в bootstrap +- [ ] Storage abstraction (`storage.js`) — единая точка для Preferences/localStorage +- [ ] Navigation abstraction (`navigation.js`) — единая точка для OAuth redirect + +### Android visual +- [ ] Custom `MainActivity.java` с `setTheme()` ПЕРЕД `super.onCreate()` +- [ ] Custom `capacitor_bridge_layout_main.xml` с `fitsSystemWindows="true"` +- [ ] Сброс `OnApplyWindowInsetsListener` от SystemBars plugin в `onCreate()` +- [ ] `windowOptOutEdgeToEdgeEnforcement=true` в обоих theme стилях +- [ ] Splash PNG per density в `drawable-land-*dpi/` и `drawable-port-*dpi/` +- [ ] Android 12+ splash API: `drawable-*dpi-v31/ic_splash.png` 288dp canvas +- [ ] `colors.xml` с `bg_dark`, `toolbar_bg`, `accent`, `text_primary` + +### Server (OAuth callback) +- [ ] `returnTo` читать из сессии **ДО** `handleCallback()` — state стирается +- [ ] Bridge page `/auth/mobile-bridge` с `intent://` redirect +- [ ] `sanitizeReturnTo()` разрешает `/auth/mobile-bridge` и same-origin URLs + +--- + +## 7. Ключевые файлы проекта + +| Файл | Зачем | +|------|-------| +| `server/SHServ/Controllers/AuthController.php` | OAuth callback + mobileBridge bridge page | +| `webclient/src/app/main.js` | Bootstrap, deep link handling, `initAccessToken()` | +| `webclient/src/api/server-config.js` | `isNativeApp()`, `buildServerUrl()`, storage | +| `webclient/src/api/storage.js` | Единый адаптер Preferences/localStorage | +| `webclient/src/api/navigation.js` | `redirectToOAuth()`, `getOAuthReturnTo()` | +| `webclient/capacitor.config.json` | Capacitor config, plugins, android scheme | +| `android/app/src/main/java/.../MainActivity.java` | Theme override, fitsSystemWindows, edge-to-edge opt-out | +| `android/app/src/main/res/layout/capacitor_bridge_layout_main.xml` | Override с fitsSystemWindows="true" | +| `android/app/src/main/res/values/styles.xml` | Theme + windowOptOutEdgeToEdgeEnforcement | +| `android/app/src/main/res/values-v31/styles.xml` | Android 12+ SplashScreen API | +| `android/app/src/main/AndroidManifest.xml` | Deep link intent-filter | + +--- + +*Написано после реального production-дебага. Все проблемы воспроизводились на Android 15 (API 35), Motorola, Capacitor 8.4.0.*