What You Need Before Starting
-
A Lovable project (your app)
-
A Despia account (to make it mobile)
-
Google OAuth already enabled in Lovable
If you don't have Google sign-in yet, first ask Lovable: "Add Google authentication to my app"
Get Your iOS Deep Link Scheme
Where to find it:
-
Log into your Despia dashboard
-
Select your app
-
Go to Publish
-
Copy the scheme shown (example:
myapp://)
Important: Copy only the scheme name, not any :// or other characters.
Write it down - you'll need it in Step 3!
Get Your Android Information
You need two pieces of information from Despia:
Package Name:
-
In Despia, find your Android bundle ID
-
It looks like:
com.yourcompany.appname -
Copy it exactly
SHA-256 Fingerprint:
-
In Play Console Settings > Deep Links find "SHA-256 fingerprint" or "Signing certificate"
-
It's a long string like:
1D:06:A8:20:42:1C:60:C3:C8:CE:6C:43:51:78:39:25... -
Copy the entire string
Write both of these down.
Copy and Paste Into Lovable
Open your Lovable project and paste this message into the chat.
Replace the UPPERCASE parts with your actual values from Steps 1 and 2.
COPY THIS MESSAGE:
My iOS deep link scheme is: [YOUR_IOS_SCHEME]
My Android package name is: [YOUR_ANDROID_PACKAGE]
My Android SHA-256 fingerprint is: [YOUR_SHA256_FINGERPRINT]
# Despia OAuth Integration - Production Guide
> **Note:** This guide is for current Despia versions that automatically handle OAuth provider opening. Just use standard `supabase.auth.signInWithOAuth()` - no manual window opening needed!
## šÆ Overview
Complete OAuth integration for Despia hybrid apps with Google authentication via Supabase.
**Platforms:** iOS (deep link), Android (standard web), Web (standard)
**ā ļø Critical Android Caveat:**
Android is simple - it uses standard web OAuth 1:1 with web. However, there's ONE critical rule:
**The OAuth callback route MUST NOT be in your `assetlinks.json` file.**
If you put `/auth/callback` in `assetlinks.json`, Android App Links will intercept the OAuth callback URL and strip the `?` query separator, turning:
```
https://app.com/auth/callback?access_token=xyz
ā
https://app.com/auth/callbackaccess_token=xyz ā BROKEN
```
**The Solution:** Use `/auth/web-callback` (which Android doesn't know about) so the URL loads normally in the WebView with all query parameters intact.
**Why This Happens:** Android App Links work at the domain level and strip query separators during deep link processing. Unlike iOS Universal Links (which support path-level exclusions), the only way to prevent interception is to not mention the path in `assetlinks.json` at all.
**Note on OAuth Providers:**
- **iOS**: Use `skipBrowserRedirect: true` and manually `window.open(data.url, '_blank')` to prevent Supabase auto-redirect and let Despia control in-app browser opening
- **Android**: Use `window.location.href` and Despia automatically opens in external browser (Chrome/etc)
This gives you full control over OAuth flow on both platforms.
---
## šØ CRITICAL RULES
### RULE #1: iOS Deep Link Triggering
**On iOS, custom URL schemes MUST use `window.open()`, NOT `window.location.href`**
ā **WRONG:**
```typescript
window.location.href = 'myapp://?link=...'; // FAILS
```
ā
**CORRECT:**
```typescript
window.open('myapp://?link=...', '_self'); // ā
Works
```
### RULE #2: Android App Links Configuration
**šØ CRITICAL: OAuth callback routes MUST NOT be in assetlinks.json**
**Why:** Android App Links strip the `?` query separator when intercepting URLs, turning:
- `https://app.com/auth/callback?access_token=xyz`
- Into: `https://app.com/auth/callbackaccess_token=xyz` ā
**Solution:** Use separate callback routes:
- `/auth/mobile-callback` - iOS only (can be in App Links)
- `/auth/web-callback` - Android & Web (must NOT be in App Links)
**The Complete Explanation:**
**ā What happens with `/auth/callback` (BROKEN):**
1. Google OAuth redirects to: `https://app.com/auth/callback?access_token=...`
2. Android App Links sees the domain matches `assetlinks.json`
3. Android intercepts and strips the `?` during deep link handling
4. URL becomes: `/auth/callbackaccess_token=...` (no `?`)
5. WebCallback can't parse parameters ā Authentication fails
**ā
What happens with `/auth/web-callback` (WORKS):**
1. Google OAuth redirects to: `https://app.com/auth/web-callback?access_token=...`
2. Android App Links checks `assetlinks.json`
3. Path not configured ā Android lets WebView handle it normally
4. URL loads with intact parameters: `?access_token=...&refresh_token=...`
5. WebCallback successfully parses tokens ā Authentication succeeds! š
**Key Insight:** Android App Links work at the **domain level**, not path level. Unlike iOS Universal Links (which support path exclusions), Android intercepts ALL routes for a configured domain. The ONLY way to prevent interception is to not configure that specific path anywhere in your App Links setup.
---
## š File Structure
```
src/
āāā lib/detectEnvironment.ts
āāā utils/authRetry.ts (iOS only)
āāā pages/auth/
ā āāā Auth.tsx
ā āāā MobileCallback.tsx (iOS only)
ā āāā AuthFinish.tsx (iOS only)
ā āāā WebCallback.tsx (Android & Web)
āāā integrations/supabase/client.ts
public/.well-known/
āāā assetlinks.json (Android - NO callback routes!)
```
---
## š§ Environment Variables
```env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-anon-key
VITE_APP_URL=https://your-app.lovable.app
VITE_DEEP_LINK_SCHEME=myapp # iOS only
```
---
## š ļø Implementation
### 1. Platform Detection (`src/lib/detectEnvironment.ts`)
```typescript
const MOBILE_UA_TOKEN = 'despia';
export type Platform = 'ios' | 'android' | 'web';
export function detectPlatform(): Platform {
const ua = navigator.userAgent.toLowerCase();
if (!ua.includes(MOBILE_UA_TOKEN)) return 'web';
if (ua.includes('iphone') || ua.includes('ipad')) return 'ios';
if (ua.includes('android')) return 'android';
return 'web';
}
export function getAppUrl(): string {
return import.meta.env.VITE_APP_URL || window.location.origin;
}
export function getDeepLinkScheme(): string {
return import.meta.env.VITE_DEEP_LINK_SCHEME || 'myapp';
}
```
---
### 2. Session Retry Utility (`src/utils/authRetry.ts`) - iOS ONLY
```typescript
import { Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
export interface RetryResult {
session: Session | null;
attempts: number;
}
export async function retryGetSession(
maxAttempts = 3,
timeoutMs = 10000
): Promise<RetryResult> {
const delays = [0, 200, 500];
let attempts = 0;
const startTime = Date.now();
for (let i = 0; i < maxAttempts; i++) {
if (Date.now() - startTime > timeoutMs) break;
attempts++;
if (delays[i] > 0) {
await new Promise(resolve => setTimeout(resolve, delays[i]));
}
const { data: { session } } = await supabase.auth.getSession();
if (session?.access_token && session?.refresh_token) {
return { session, attempts };
}
}
return { session: null, attempts };
}
export function buildDeepLink(
session: Session,
baseUrl: string,
scheme: string
): string {
const tokenData = btoa(JSON.stringify({
a: session.access_token,
r: session.refresh_token
}));
const finishUrl = `${baseUrl}/auth/finish?t=${encodeURIComponent(tokenData)}`;
return `${scheme}://?link=${finishUrl}`;
}
```
---
### 3. Auth Page (`src/pages/Auth.tsx`)
```typescript
import { detectPlatform, getAppUrl } from '@/lib/detectEnvironment';
import { supabase } from '@/integrations/supabase/client';
const handleGoogleAuth = async () => {
try {
const platform = detectPlatform();
const baseUrl = getAppUrl();
const ua = navigator.userAgent.toLowerCase();
// CRITICAL: Use separate callback routes
// iOS: /auth/mobile-callback - needs custom deep link handling
// Android/Web: /auth/web-callback - must NOT be in assetlinks.json to prevent query param stripping
const redirectTo = platform === 'ios'
? `${baseUrl}/auth/mobile-callback?returnTo=/&platform=ios`
: `${baseUrl}/auth/web-callback?returnTo=/`;
console.log('[Auth] Platform:', platform, 'RedirectTo:', redirectTo);
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo,
queryParams: {
access_type: 'offline',
prompt: 'consent',
},
},
});
if (error) throw error;
if (data?.url) {
// iOS: _blank opens in-app browser
if (ua.includes('despia') && (ua.includes('ipad') || ua.includes('iphone'))) {
window.open(data.url, '_blank');
}
// Android/Web: Standard redirect (Despia opens accounts.google.com in external browser automatically)
else {
window.location.href = data.url;
}
}
} catch (error) {
console.error('[Auth] Error:', error);
}
};
```
**šØ Critical:**
- iOS ā `/auth/mobile-callback` (can be in App Links)
- Android ā `/auth/web-callback` (must NOT be in App Links)
---
### 4. Web Callback (`src/pages/auth/WebCallback.tsx`) - ANDROID & WEB
```typescript
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
export default function WebCallback() {
const navigate = useNavigate();
const [error, setError] = useState('');
useEffect(() => {
const handleCallback = async () => {
try {
let currentUrl = window.location.href;
console.log('[WebCallback] Original URL:', currentUrl);
// CRITICAL FIX: Normalize URLs in case App Links stripped the '?'
// Handles: /auth/callbackaccess_token= ā /auth/callback?access_token=
currentUrl = currentUrl.replace(
/(\/auth\/(?:web-)?callback|\/callback)(access_token|code|refresh_token)=/g,
'$1?$2='
);
// Ensure '&' separators between parameters
currentUrl = currentUrl.replace(/([^?&])(?=(code|access_token|refresh_token)=)/g, '$1&');
console.log('[WebCallback] Normalized URL:', currentUrl);
const url = new URL(currentUrl);
const params = new URLSearchParams(url.search);
// Check hash for tokens (fallback)
if (url.hash) {
const hashParams = new URLSearchParams(url.hash.substring(1));
hashParams.forEach((value, key) => {
if (!params.has(key)) params.set(key, value);
});
}
const code = params.get('code');
const access_token = params.get('access_token');
const refresh_token = params.get('refresh_token');
// Check existing session
const { data: { session } } = await supabase.auth.getSession();
if (session) {
console.log('[WebCallback] Session exists');
navigate(params.get('returnTo') || '/');
return;
}
// Handle direct tokens
if (access_token && refresh_token) {
console.log('[WebCallback] Setting session from tokens');
const { error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
if (error) throw error;
navigate(params.get('returnTo') || '/');
return;
}
// Handle authorization code
if (code) {
console.log('[WebCallback] Exchanging code for session');
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) throw error;
navigate(params.get('returnTo') || '/');
return;
}
throw new Error('No authentication data found');
} catch (err) {
console.error('[WebCallback] Error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
handleCallback();
}, [navigate]);
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-600">Error: {error}</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
```
**Android Flow:**
1. User approves OAuth
2. Google redirects to `https://app.com/auth/web-callback?code=...`
3. URL stays in WebView (not in assetlinks.json)
4. Query parameters intact
5. Session established ā
---
### 5. iOS Mobile Callback (`src/pages/auth/MobileCallback.tsx`) - iOS ONLY
```typescript
import { useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { getAppUrl, getDeepLinkScheme } from '@/lib/detectEnvironment';
import { retryGetSession, buildDeepLink } from '@/utils/authRetry';
export default function MobileCallback() {
const [searchParams] = useSearchParams();
const [viewState, setViewState] = useState<'loading' | 'success' | 'error'>('loading');
const [deepLinkRaw, setDeepLinkRaw] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const debug = searchParams.get('debug') === '1';
const autoRedirectTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const initAuth = async () => {
try {
const baseUrl = getAppUrl();
const scheme = getDeepLinkScheme();
const { session } = await retryGetSession(3, 10000);
if (!session) {
setErrorMessage('No session found. Please try logging in again.');
setViewState('error');
return;
}
const deepLink = buildDeepLink(session, baseUrl, scheme);
setDeepLinkRaw(deepLink);
setViewState('success');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Unknown error');
setViewState('error');
}
};
initAuth();
}, [searchParams]);
useEffect(() => {
if (deepLinkRaw && viewState === 'success' && !debug) {
autoRedirectTimer.current = setTimeout(() => {
// šØ CRITICAL: iOS requires window.open()
window.open(deepLinkRaw, '_self');
}, 150);
return () => {
if (autoRedirectTimer.current) clearTimeout(autoRedirectTimer.current);
};
}
}, [deepLinkRaw, viewState, debug]);
const handleOpenDeepLink = (e: React.MouseEvent) => {
e.preventDefault();
if (autoRedirectTimer.current) {
clearTimeout(autoRedirectTimer.current);
autoRedirectTimer.current = null;
}
window.open(deepLinkRaw, '_self');
};
if (viewState === 'loading') {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Authenticating...</p>
</div>
);
}
if (viewState === 'error') {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<h2 className="text-xl font-semibold text-red-800 mb-2">Error</h2>
<p className="text-red-600 mb-4">{errorMessage}</p>
<button
onClick={() => window.location.reload()}
className="w-full bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-6 max-w-md">
<h2 className="text-xl font-semibold text-green-800 mb-2">Success!</h2>
<p className="text-green-600 mb-4">Opening app...</p>
<button
onClick={handleOpenDeepLink}
className="w-full bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
Open in App
</button>
</div>
</div>
);
}
```
---
### 6. Auth Finish (`src/pages/auth/AuthFinish.tsx`) - iOS ONLY
```typescript
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
export default function AuthFinish() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<'working' | 'success' | 'error'>('working');
const [countdown, setCountdown] = useState(3);
useEffect(() => {
const establishSession = async () => {
try {
const compactToken = searchParams.get('t');
if (compactToken) {
const decoded = JSON.parse(atob(compactToken));
const { a: access_token, r: refresh_token } = decoded;
if (!access_token || !refresh_token) {
throw new Error('Invalid token data');
}
const { error } = await supabase.auth.setSession({
access_token,
refresh_token,
});
if (error) throw error;
setStatus('success');
return;
}
const { data: { session } } = await supabase.auth.getSession();
if (session) {
setStatus('success');
return;
}
throw new Error('No authentication data found');
} catch (error) {
console.error('[AuthFinish] Error:', error);
setStatus('error');
}
};
establishSession();
}, [searchParams]);
useEffect(() => {
if (status === 'success') {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
navigate('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [status, navigate]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
{status === 'working' && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4">Establishing session...</p>
</>
)}
{status === 'success' && (
<>
<div className="text-green-600 text-xl mb-4">ā Success!</div>
<p>Redirecting in {countdown}...</p>
</>
)}
{status === 'error' && (
<div className="text-red-600">Authentication failed</div>
)}
</div>
</div>
);
}
```
---
## š± Android App Links Setup - CRITICAL CONFIGURATION
### `public/.well-known/assetlinks.json`
```json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.your.package",
"sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
}
}]
```
### šØ CRITICAL ANDROID RULES
**ā DO NOT include OAuth callback routes in assetlinks.json:**
- `/auth/callback` ā
- `/auth/web-callback` ā
- `/callback` ā
**Why:** Android App Links strip the `?` query separator when intercepting URLs:
- Input: `https://app.com/auth/callback?code=xyz`
- Output: `https://app.com/auth/callbackcode=xyz` (broken!)
**ā
DO include app-specific routes:**
- `/auth/mobile-callback` ā (iOS only, safe)
- `/dashboard` ā
- `/profile` ā
- Any internal app routes ā
**How Android OAuth Opens External Browser:**
- Despia automatically handles OAuth provider domains (`accounts.google.com`)
- When OAuth URL is triggered, Despia opens it in the device's external browser
- No "Always Open in Browser" configuration needed in Despia settings
- After OAuth completes, callback URL loads in your app's WebView
### Get SHA-256 Fingerprint
```bash
# Production APK
keytool -list -v -keystore /path/to/keystore.jks
# Debug build
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
# From Google Play Console
# Release > Setup > App integrity > App signing tab
```
### Verification
- [ ] File at `https://yourdomain.com/.well-known/assetlinks.json`
- [ ] Returns `Content-Type: application/json`
- [ ] No redirects (301/302)
- [ ] Package name matches exactly
- [ ] SHA-256 fingerprint correct
- [ ] **Does NOT include `/auth/web-callback` or any callback routes**
- [ ] Test: `curl -I https://yourdomain.com/.well-known/assetlinks.json`
### Despia Configuration Notes
**Both iOS and Android:**
- OAuth provider domains (`accounts.google.com`, `appleid.apple.com`, etc.) are handled automatically by Despia
- No "Always Open in Browser" configuration needed
- Just call `supabase.auth.signInWithOAuth()` and Despia does the rest
- **iOS**: Opens OAuth in in-app browser automatically
- **Android**: Opens OAuth in external browser automatically
---
## šÆ Flow Comparison
### iOS Flow (Custom)
```
Auth.tsx ā supabase.auth.signInWithOAuth()
ā
Despia detects accounts.google.com ā Opens in in-app browser automatically
ā
User approves OAuth in in-app browser
ā
Google OAuth completes
ā
/auth/mobile-callback (waits for session with retry)
ā
Generates: myapp://?link=https://app.com/auth/finish?t=TOKENS
ā
window.open(deepLink, '_self') ā šØ CRITICAL
ā
Despia intercepts ā closes in-app browser
ā
/auth/finish decodes tokens ā setSession() ā
```
### Android Flow - BROKEN ā (Using `/auth/callback`)
```
Auth.tsx ā supabase.auth.signInWithOAuth()
ā
Despia detects accounts.google.com ā Opens in external browser automatically
ā
User approves OAuth in external browser (Chrome/etc)
ā
Google OAuth completes
ā
Redirects to: https://app.com/auth/callback?access_token=xyz&refresh_token=abc
ā
Android App Links: "This domain is mine, intercepting!"
ā
During interception: Query separator stripped
ā
Becomes: https://app.com/auth/callbackaccess_token=xyz&refresh_token=abc
ā
WebCallback tries to parse ā No '?' found ā No params ā FAIL ā
```
### Android Flow - WORKING ā
(Using `/auth/web-callback`)
```
Auth.tsx ā supabase.auth.signInWithOAuth()
ā
Despia detects accounts.google.com ā Opens in external browser automatically
ā
User approves OAuth in external browser (Chrome/etc)
ā
Google OAuth completes
ā
Redirects to: https://app.com/auth/web-callback?access_token=xyz&refresh_token=abc
ā
Android App Links: "Not configured for this path, skip interception"
ā
Loads in Despia app WebView normally
ā
Query parameters stay intact: ?access_token=xyz&refresh_token=abc
ā
WebCallback parses successfully ā exchangeCodeForSession() ā SUCCESS ā
```
**The Critical Difference:** One character change in the route name prevents Android from intercepting and destroying the URL.
---
## š Quick Reference
### Android OAuth - The One Critical Rule
```typescript
// ā BROKEN - Android intercepts and strips '?'
redirectTo: `${baseUrl}/auth/callback`
// ā
WORKS - Android doesn't intercept, params stay intact
redirectTo: `${baseUrl}/auth/web-callback`
```
**What happens:**
1. `/auth/callback` in assetlinks.json ā Android intercepts ā strips `?` ā breaks auth
2. `/auth/web-callback` NOT in assetlinks.json ā loads in WebView ā params intact ā auth succeeds
**Auth flow simplified (new Despia):**
```typescript
// Just use standard Supabase OAuth - Despia handles the rest!
await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: platform === 'ios' ? '/auth/mobile-callback' : '/auth/web-callback' }
});
```
**Remember:**
- Android App Links = domain-level (can't exclude paths like iOS)
- Solution = use route Android doesn't know about
- `/auth/web-callback` is that route
- Despia automatically opens OAuth providers correctly on both platforms
---
## ā
Testing Checklist
### iOS
- [ ] Deep links use `window.open(deepLink, '_self')` (in callback pages)
- [ ] OAuth opens automatically in in-app browser
- [ ] Routes to `/auth/mobile-callback`
- [ ] Auto-redirect works
- [ ] Session persists after restart
### Android
- [ ] Routes to `/auth/web-callback`
- [ ] OAuth opens automatically in external browser
- [ ] Callback returns to app via App Links
- [ ] URL contains `?code=...` or `?access_token=...`
- [ ] Parameters NOT stripped
- [ ] Session establishes
- [ ] Session persists after restart
### Supabase
- [ ] Redirect URLs allowlisted:
- `/auth/mobile-callback` (iOS)
- `/auth/web-callback` (Android & Web)
- `/auth/finish` (iOS)
---
## š Troubleshooting
### Android: "No authentication data found"
**Symptom**: WebCallback can't find `code` or tokens in URL
**Cause #1**: `/auth/web-callback` is in assetlinks.json
- **Fix**: Remove it from assetlinks.json
- App Links are stripping the `?` separator
**Cause #2**: Wrong callback route in Auth.tsx
- **Fix**: Ensure Android uses `/auth/web-callback`, not `/auth/callback`
**Cause #3**: Malformed URL
- **Fix**: The URL normalization in WebCallback.tsx should handle this
- Check console logs for original vs normalized URL
### Android: Parameters Glued Together
**Symptom**: URL shows `/auth/callbackcode=xyz` instead of `/auth/callback?code=xyz`
**Cause**: App Links intercepted the URL and stripped `?`
**Fix**: The URL normalization in WebCallback.tsx handles this:
```typescript
currentUrl = currentUrl.replace(
/(\/auth\/(?:web-)?callback|\/callback)(access_token|code|refresh_token)=/g,
'$1?$2='
);
```
**Prevention**: Don't include callback routes in assetlinks.json
### iOS: Deep Link Not Triggering
**Symptom**: In-app browser stays open
**Fix**: Use `window.open(deepLink, '_self')`, NOT `window.location.href`
### iOS: Timer Race Condition Crash
**Fix**: Clear timer before manual redirect
```typescript
if (autoRedirectTimer.current) {
clearTimeout(autoRedirectTimer.current);
autoRedirectTimer.current = null;
}
window.open(deepLinkRaw, '_self');
```
---
## š Implementation Checklist
### 0. Before Starting
- [ ] Get iOS deep link scheme from Despia
- [ ] Understand: Android callback must NOT be in assetlinks.json
### 1. Auth Logic
- [ ] Use standard `supabase.auth.signInWithOAuth()` call
- [ ] iOS ā `/auth/mobile-callback`
- [ ] Android ā `/auth/web-callback`
- [ ] No manual window opening needed - Despia handles OAuth providers automatically
### 2. Callback Pages
- [ ] iOS: `MobileCallback.tsx` + `AuthFinish.tsx`
- [ ] Android/Web: `WebCallback.tsx` (shared)
- [ ] Add URL normalization to WebCallback.tsx
### 3. Utilities
- [ ] `detectEnvironment.ts`
- [ ] `authRetry.ts` (iOS only)
### 4. Android Configuration
- [ ] Create `assetlinks.json`
- [ ] Add package name
- [ ] Add SHA-256 fingerprint
- [ ] **Verify callback routes NOT included**
- [ ] Deploy and test accessibility
### 5. Supabase
- [ ] Allowlist redirect URLs
- [ ] Test OAuth flow on all platforms
### 6. Despia Settings
- [ ] iOS: Set deep link scheme
- [ ] Android: Set package name
- [ ] **No "Always Open in Browser" configuration needed** - Despia handles OAuth providers automatically
---
## š Key Takeaways
### iOS (Complex)
- Custom deep link flow required for callback handling
- Must use `window.open()` for deep links (in callback pages only)
- OAuth opens automatically in in-app browser (Despia handles this)
- Needs session retry logic
- Routes: `/auth/mobile-callback` ā `/auth/finish`
### Android (Simple BUT...)
- Uses standard web OAuth 1:1
- **CRITICAL**: Callback route must NOT be in assetlinks.json
- If in assetlinks.json, App Links strip `?` separator
- OAuth opens automatically in external browser (Despia handles this)
- Callback returns via App Links to WebView
- Routes: `/auth/web-callback` (same as web)
- URL normalization handles edge cases
### The Android App Links Mechanism
**Why the specific route name matters:**
The change from `/auth/callback` to `/auth/web-callback` is not arbitrary - it's the ENTIRE solution:
1. **Domain-Level Control**: Android App Links work at the domain level (unlike iOS which supports path-level control)
2. **Interception Behavior**: Once `assetlinks.json` configures a domain, Android wants to intercept ALL URLs from that domain
3. **Query Parameter Stripping**: When Android intercepts a URL, it strips the `?` during deep link processing
4. **The Fix**: By using a route Android doesn't know about (`/auth/web-callback`), the URL loads normally in the WebView where query parameters stay intact
**This is why:**
- `/auth/callback` fails (Android knows about it ā intercepts ā strips `?`)
- `/auth/web-callback` works (Android doesn't know ā loads in WebView ā params intact)
iOS doesn't have this problem because it uses custom deep links (`myapp://`) with tokens embedded in the URL scheme itself, not as query parameters.
### How OAuth Providers Open (Automatic in New Despia)
**Both iOS and Android:**
- Just call `supabase.auth.signInWithOAuth()` normally
- Despia automatically detects OAuth provider domains (`accounts.google.com`, `appleid.apple.com`, etc.)
- **iOS**: Automatically opens in Despia's in-app browser
- **Android**: Automatically opens in device's external browser (Chrome/etc)
- No manual `window.open()` or `window.location.href` needed
- No `skipBrowserRedirect: true` needed
- No "Always Open in Browser" configuration needed
**The magic is in the Despia runtime** - it intercepts OAuth provider URLs and handles them appropriately for each platform.
Configure Supabase Redirect URLs
What this does: Tells Google where to send users after they sign in.
Steps:
-
Open your Supabase project dashboard
- In Lovable: Settings > Integrations > Supabase > Click the link
-
In Supabase dashboard:
-
Click "Authentication" in the left sidebar
-
Click "URL Configuration"
-
Scroll to "Redirect URLs"
-
-
Add these three URLs (replace
YOUR-APP-NAMEwith your actual Lovable app URL):
https://YOUR-APP-NAME.lovable.app/auth/mobile-callback
https://YOUR-APP-NAME.lovable.app/auth/web-callback
https://YOUR-APP-NAME.lovable.app/auth/finish
Finding your app URL: Look in your Lovable project settings under "Deployment" or "Domain".
- Click "Save"
Deploy
-
In Lovable, click "Update" (top right)
-
Wait for deployment to complete (1-2 minutes)
-
Your app is now live with Google sign-in
Test Your App
Test on iPhone:
-
Open your app on an iPhone
-
Tap "Sign in with Google"
-
Google sign-in page appears
-
Enter your Google credentials
-
App opens automatically, you're logged in
Test on Android:
-
Open your app on an Android phone
-
Tap "Sign in with Google"
-
Chrome opens with Google sign-in
-
Enter your Google credentials
-
App opens automatically, you're logged in
Important: Test on actual phones, not simulators.
About the implementation: This setup uses a battle-tested OAuth flow that handles platform differences automatically. iOS uses deep links because of how its in-app browser works. Android uses App Links for seamless return to the app. Both provide the same smooth user experience.
Security: All authentication goes through Google's secure OAuth system and Supabase's authentication service. Tokens are handled securely and sessions persist correctly on both platforms.
Maintenance: Once set up, this requires no ongoing maintenance. Users can sign in reliably on all platforms.