Newer
Older
smart-home-server / docs / android-capacitor-oauth-template.md

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()

// ❌ НЕЛЬЗЯ — открывает 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()

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

// ❌ НЕЛЬЗЯ — 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()

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:

<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

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 — оба стиля:

<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>

capacitor.config.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:

<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)

Генерация:

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+
  • 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.