# 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
<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    <com.getcapacitor.CapacitorWebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
```

#### 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
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
```

`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
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
    <item name="android:windowSplashScreenBackground">@color/bg_dark</item>
    <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
    <item name="android:windowSplashScreenIconBackgroundColor">@color/bg_dark</item>
    <item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
</style>
```

`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.*
