# 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. Онлайн-загрузка SPA (тонкий клиент)

> **Зачем:** не пересобирать APK при каждом изменении UI. SPA грузится с сервера — обновляешь `dist/` на сервере, приложение подхватывает автоматически.

### Архитектура

```
┌─────────────────┐     ┌──────────────────┐
│   Android App   │────▶│  Server SPA      │
│  (Capacitor WV) │     │  (dist/index.html│
│  тонкий клиент  │     │   + JS + CSS)    │
└─────────────────┘     └──────────────────┘
         │                      │
         │                      ▼
         │            ┌──────────────────┐
         └────────────│  PHP API         │
              XHR     │  /api/v1/*       │
                      └──────────────────┘
```

### Как это работает

**Первый запуск** (нет `server_url` в Preferences):
1. Загружается локальный `index.html` из APK (`assets/public`)
2. Vue router показывает `/mobile-setup`
3. Пользователь вводит адрес сервера
4. URL сохраняется через `@capacitor/preferences` → `SharedPreferences("CapacitorStorage")`
5. `App.exitApp()` — приложение закрывается

**Второй и последующие запуски**:
1. `MainActivity.onCreate()` **перед** `super.onCreate()` читает `server_url` из Preferences
2. Если URL есть — создаёт `CapConfig` через `Builder` с `setServerUrl(url)`
3. `super.onCreate(savedInstanceState)` видит этот `config` и WebView грузит SPA с сервера
4. Все API-запросы идут на тот же origin — **CORS не нужен**

### MainActivity.java

```java
public class MainActivity extends BridgeActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        setTheme(R.style.AppTheme_NoActionBar);

        // ⚠️ ДО super.onCreate() — иначе BridgeActivity загрузит bundled assets
        SharedPreferences prefs = getSharedPreferences("CapacitorStorage", MODE_PRIVATE);
        String serverUrl = prefs.getString("server_url", null);

        if (serverUrl != null && !serverUrl.isEmpty()) {
            this.config = new CapConfig.Builder(this)
                .setServerUrl(serverUrl)
                .create();
        }

        super.onCreate(savedInstanceState);
        // ... rest of onCreate (status bar, edge-to-edge, etc.)
    }
}
```

**Критически важно:** `this.config` присваивается **перед** `super.onCreate()`. `BridgeActivity.onCreate()` вызывает `load()` → `bridgeBuilder.setConfig(config)` → если `config` уже есть, `CapConfig` читает `server.url` и WebView открывает его вместо `file:///android_asset/public/index.html`.

### MobileSetupPage.vue

```vue
<script setup>
import { App } from "@capacitor/app";
// ...

async function handleSave() {
  // ... validate URL, test connectivity ...
  await setServerUrl(url); // сохраняет в @capacitor/preferences

  alert("Server address saved. Please restart the app to continue.");
  App.exitApp(); // пользователь перезапускает приложение вручную
}
</script>
```

### Почему не `capacitor.config.json`?

В `capacitor.config.json` можно статически прописать `"server": { "url": "https://..." }`, но тогда **всегда** будет грузиться с сервера, даже при первом запуске (когда URL ещё неизвестен). Динамический `CapConfig.Builder` в `MainActivity` позволяет:
- Первый запуск → bundled assets → `/mobile-setup`
- Последующие → remote SPA

### Что остаётся в APK

| Что | Зачем |
|-----|-------|
| Bundled `dist/` в `assets/public` | Fallback для первого запуска и офлайн-режима |
| Capacitor plugins (App, Preferences, StatusBar) | Нативная функциональность |
| Splash screen, themes, layouts | Нативная обёртка |
| Deep link intent-filter | OAuth callback |

### Важные нюансы

- **`usesCleartextTraffic="true"`** в `AndroidManifest.xml` — нужен для HTTP (локальная сеть)
- **Splash screen** работает одинаково — до загрузки SPA показывается нативный splash
- **Status bar / edge-to-edge** — не затрагивается, фиксы из раздела 4 работают
- **Deep link `shserv://auth/callback`** — обрабатывается на уровне Android (intent-filter), не зависит от `serverUrl`
- **Ошибка соединения** — если сервер недоступен, WebView покажет стандартную ошибку загрузки страницы. Можно добавить `onReceivedError` в WebViewClient для graceful fallback.

---

## 8. Чеклист для следующего проекта

### Capacitor setup
- [x] `capacitor.config.json` с `"appId": "com.company.app"` — уникальный
- [x] `androidScheme` не конфликтует с deep link scheme (shserv / https)
- [x] `CapacitorHttp.enabled: true` если нужен native HTTP
- [x] `adjustMarginsForEdgeToEdge: "force"` для Android 15+

### Deep link (OAuth return)
- [x] `AndroidManifest.xml` intent-filter для `scheme://host/path`
- [x] Server bridge page с `intent://` URL
- [x] Vue `appUrlOpen` listener + `getLaunchUrl()` для cold-start
- [x] `window.location.reload()` после получения токена (чтобы bootstrap перезапустился)

### Desktop vs Native branching
- [x] `isNativeApp()` через `Capacitor.isNativePlatform()`, НЕ `window.Capacitor`
- [x] `initAccessToken()` и `initServerUrl()` вызывать **всегда** в bootstrap
- [x] Storage abstraction (`storage.js`) — единая точка для Preferences/localStorage
- [x] Navigation abstraction (`navigation.js`) — единая точка для OAuth redirect

### Онлайн-загрузка SPA (тонкий клиент)
- [x] `MainActivity.onCreate()` читает `server_url` из Preferences **перед** `super.onCreate()`
- [x] `CapConfig.Builder(this).setServerUrl(url)` если URL сохранён
- [x] `/mobile-setup` страница — сохраняет URL и вызывает `App.exitApp()`
- [x] Bundled `dist/` в APK как fallback для первого запуска

### Android visual
- [x] Custom `MainActivity.java` с `setTheme()` ПЕРЕД `super.onCreate()`
- [x] Custom `capacitor_bridge_layout_main.xml` с `fitsSystemWindows="true"`
- [x] Сброс `OnApplyWindowInsetsListener` от SystemBars plugin в `onCreate()`
- [x] `windowOptOutEdgeToEdgeEnforcement=true` в обоих theme стилях
- [x] Splash PNG per density в `drawable-land-*dpi/` и `drawable-port-*dpi/`
- [x] Android 12+ splash API: `drawable-*dpi-v31/ic_splash.png` 288dp canvas
- [x] `colors.xml` с `bg_dark`, `toolbar_bg`, `accent`, `text_primary`

### Server (OAuth callback)
- [x] `returnTo` читать из сессии **ДО** `handleCallback()` — state стирается
- [x] Bridge page `/auth/mobile-bridge` с `intent://` redirect
- [x] `sanitizeReturnTo()` разрешает `/auth/mobile-bridge` и same-origin URLs

---

## 9. Ключевые файлы проекта

| Файл | Зачем |
|------|-------|
| `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/src/features/auth/pages/MobileSetupPage.vue` | Страница ввода адреса сервера (только native) |
| `webclient/src/api/storage.js` | Единый адаптер Preferences/localStorage |
| `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.*
