import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; import { AuthServiceBase } from './AuthServiceBase'; WebBrowser.maybeCompleteAuthSession(); // Web client ID — used for token exchange (authorization code → access token). // Created in Google Cloud Console as "Web application" type. // No redirect URIs need to be registered for this client. const WEB_CLIENT_ID = '313090284964-rgq1u7np6ogucu9o97s134n5nc6nj7kf.apps.googleusercontent.com'; // Android client ID — used for the authorization request on Android. // Created in Google Cloud Console as "Android" type with: // Package name: com.bobthebob.massphotoapp // SHA-1 fingerprint: //REMOVE LATER. 59:1C:1A:B2:61:C9:8D:80:C8:E1:96:FE:CA:44:18:CE:91:5E:38:63 IS THE SHA-1 FINGERPRINT FOR THE APP // Android clients verify by package + SHA-1, no redirect URI registration needed. const ANDROID_CLIENT_ID = '313090284964-pa9p2rs7g60l9t8hr6haee3qbn2a7vl9.apps.googleusercontent.com'; const GOOGLE_DISCOVERY = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token', revocationEndpoint: 'https://oauth2.googleapis.com/revoke', }; const SCOPES = [ 'openid', 'https://www.googleapis.com/auth/photoslibrary', 'https://www.googleapis.com/auth/photoslibrary.appendonly', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', ]; // Redirect URI uses the Android package name as scheme, matching what // expo-auth-session/providers/google generates for installed Android apps. // Result: com.bobthebob.massphotoapp:/oauthredirect const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; class GoogleAUTH extends AuthServiceBase { constructor() { super(); this.accessToken = null; this.refreshToken = null; this.expiresAt = null; this.email = null; this.idToken = null; } async authenticate() { try { if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { console.warn('Google Auth: no Android client ID — using placeholder mode'); this.accessToken = 'placeholder_google_token_' + Date.now(); this.refreshToken = 'placeholder_refresh_' + Date.now(); this.expiresAt = Date.now() + 3600000; this.email = 'google-user@placeholder.com'; return { success: true, accessToken: this.accessToken, refreshToken: this.refreshToken, email: this.email, }; } console.log('OAuth redirect URI:', REDIRECT_URI); // Auth request uses the Android client ID. // Android OAuth clients are verified by package name + SHA-1 signing cert, // so Google accepts the package-name scheme redirect without URI registration. const authRequest = new AuthSession.AuthRequest({ clientId: ANDROID_CLIENT_ID, scopes: SCOPES, redirectUri: REDIRECT_URI, responseType: AuthSession.ResponseType.Code, usePKCE: true, extraParams: { access_type: 'offline', prompt: 'consent' }, }); const result = await authRequest.promptAsync(GOOGLE_DISCOVERY); if (result.type !== 'success') { throw new Error('Google authentication was cancelled or failed'); } // Exchange authorization code for tokens using the Web client ID. // Android public clients don't have a client secret — the code_verifier // from PKCE is used to verify the exchange instead. const tokenBody = new URLSearchParams({ client_id: WEB_CLIENT_ID, code: result.params.code, code_verifier: authRequest.codeVerifier, grant_type: 'authorization_code', redirect_uri: REDIRECT_URI, }); const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenBody.toString(), }); const tokenData = await tokenResponse.json(); if (tokenData.error) { throw new Error(tokenData.error_description || tokenData.error); } this.accessToken = tokenData.access_token; this.refreshToken = tokenData.refresh_token || null; this.idToken = tokenData.id_token || null; this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; const userInfo = await this.fetchUserInfo(); this.email = userInfo?.email || 'google-user'; return { success: true, accessToken: this.accessToken, refreshToken: this.refreshToken, email: this.email, }; } catch (error) { console.error('Google authentication error:', error); throw error; } } async fetchUserInfo() { try { const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${this.accessToken}` }, }); if (!response.ok) return null; return await response.json(); } catch { return null; } } async getAccessToken() { if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken; if (this.refreshToken) return await this.refreshAccessToken(); throw new Error('No valid Google access token'); } async refreshAccessToken() { try { const body = new URLSearchParams({ client_id: WEB_CLIENT_ID, refresh_token: this.refreshToken, grant_type: 'refresh_token', }); const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = await response.json(); if (data.error) throw new Error(data.error_description || data.error); this.accessToken = data.access_token; this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000; return this.accessToken; } catch (error) { console.error('Token refresh error:', error); throw error; } } async revokeAccess() { try { if (this.accessToken) { await fetch(`${GOOGLE_DISCOVERY.revocationEndpoint}?token=${this.accessToken}`, { method: 'POST', }); } } catch (err) { console.error('Revoke error:', err); } this.accessToken = null; this.refreshToken = null; this.expiresAt = null; this.email = null; this.idToken = null; return { success: true }; } async getUserInfo() { const info = await this.fetchUserInfo(); return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' }; } async getPhotos(pageSize = 50, pageToken = null) { try { const token = await this.getAccessToken(); const params = new URLSearchParams({ pageSize: String(pageSize) }); if (pageToken) params.append('pageToken', pageToken); const response = await fetch( `https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!response.ok) { console.error('Google Photos API error:', response.status); return { mediaItems: [], nextPageToken: null }; } const data = await response.json(); return { mediaItems: (data.mediaItems || []).map((item) => ({ source_service: 'GOOGLE_PHOTOS', source_id: item.id, title: item.filename || 'Untitled', description: item.description || '', local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null, remote_path: item.productUrl || '', media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo', created_date: item.mediaMetadata?.creationTime || null, })), nextPageToken: data.nextPageToken || null, }; } catch (error) { console.error('Get photos error:', error); return { mediaItems: [], nextPageToken: null }; } } async getAlbums(pageSize = 50, pageToken = null) { try { const token = await this.getAccessToken(); const params = new URLSearchParams({ pageSize: String(pageSize) }); if (pageToken) params.append('pageToken', pageToken); const response = await fetch( `https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!response.ok) return { albums: [], nextPageToken: null }; const data = await response.json(); return { albums: data.albums || [], nextPageToken: data.nextPageToken || null }; } catch (error) { console.error('Get albums error:', error); return { albums: [], nextPageToken: null }; } } async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') { try { const token = await this.getAccessToken(); const uploadResponse = await fetch('https://photoslibrary.googleapis.com/v1/uploads', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream', 'X-Goog-Upload-File-Name': filename, 'X-Goog-Upload-Protocol': 'raw', }, body: await fetch(filePath).then((r) => r.blob()), }); if (!uploadResponse.ok) throw new Error('Upload bytes failed'); const uploadToken = await uploadResponse.text(); const createResponse = await fetch( 'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }], }), } ); if (!createResponse.ok) throw new Error('Create media item failed'); return await createResponse.json(); } catch (error) { console.error('Upload photo error:', error); throw error; } } } export default GoogleAUTH;