diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 40b74821..41cbd245 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -22,7 +22,7 @@ react { // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] - + debuggableVariants = ["debug"] /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. // nodeExecutableAndArgs = ["node"] diff --git a/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt index a7b8f9f2..a8bffaa8 100644 --- a/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt +++ b/apps/mobile/android/app/src/main/java/com/devcard/app/MainApplication.kt @@ -4,24 +4,34 @@ import android.app.Application import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost -import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative class MainApplication : Application(), ReactApplication { - override val reactHost: ReactHost by lazy { - getDefaultReactHost( - context = applicationContext, - packageList = - PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) - }, - ) - } + override val reactNativeHost: ReactNativeHost = + object : DefaultReactNativeHost(this) { + + override fun getPackages(): List = + PackageList(this).packages + + override fun getJSMainModuleName(): String = "index" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + + override val reactHost: ReactHost + get() = getDefaultReactHost(applicationContext, reactNativeHost) override fun onCreate() { super.onCreate() loadReactNative(this) } } + diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts index 3ef038e2..9b7e374f 100644 --- a/apps/mobile/src/config.ts +++ b/apps/mobile/src/config.ts @@ -12,6 +12,12 @@ export const API_BASE_URL: string = __DEV__ ? `http://${DEV_HOST}:3000` : 'https://api.devcard.dev'; +// OAuth must use the same host for start and callback so browser cookies/state match. +// Android reaches the host machine via `adb reverse tcp:3000 tcp:3000`. +export const AUTH_BASE_URL: string = __DEV__ + ? 'http://localhost:3000' + : 'https://api.devcard.dev'; + export const APP_URL: string = __DEV__ ? 'http://localhost:5173' : 'https://devcard.dev'; @@ -20,3 +26,9 @@ export const APP_URL: string = __DEV__ export const DEEP_LINK_SCHEME = 'devcard'; export const OAUTH_REDIRECT_URI = `${DEEP_LINK_SCHEME}://oauth/callback`; + +// Backend OAuth placeholders. Replace these once backend routes are finalized. +export const BACKEND_AUTH_ROUTES = { + github: '/auth/github', + google: '/auth/google', +} as const; diff --git a/apps/mobile/src/services/backendAuth.ts b/apps/mobile/src/services/backendAuth.ts new file mode 100644 index 00000000..5deb69b8 --- /dev/null +++ b/apps/mobile/src/services/backendAuth.ts @@ -0,0 +1,36 @@ +import { Linking } from 'react-native'; +import { AUTH_BASE_URL, BACKEND_AUTH_ROUTES, OAUTH_REDIRECT_URI } from '../config'; + +type AuthProvider = keyof typeof BACKEND_AUTH_ROUTES; + +export function buildBackendAuthUrl(provider: AuthProvider) { + const route = BACKEND_AUTH_ROUTES[provider]; + const params = new URLSearchParams({ + client: 'mobile', + provider, + state: `mobile_${provider}`, + mobile_redirect_uri: OAUTH_REDIRECT_URI, + }); + + return `${AUTH_BASE_URL}${route}?${params.toString()}`; +} + +export async function startBackendOAuth(provider: AuthProvider) { + const url = buildBackendAuthUrl(provider); + const canOpen = await Linking.canOpenURL(url); + + if (!canOpen) { + throw new Error(`Cannot open auth URL for ${provider}.`); + } + + await Linking.openURL(url); +} + +export function getJwtFromCallbackUrl(url: string) { + if (!url.startsWith(OAUTH_REDIRECT_URI)) return null; + + const queryString = url.includes('#') ? url.split('#')[1] : url.split('?')[1] || ''; + const params = new URLSearchParams(queryString); + + return params.get('token') || params.get('jwt') || params.get('access_token'); +} diff --git a/packages/shared/src/__tests__/cards.test.ts b/packages/shared/src/__tests__/cards.test.ts index 0c1a6d1e..20c3e52c 100644 --- a/packages/shared/src/__tests__/cards.test.ts +++ b/packages/shared/src/__tests__/cards.test.ts @@ -69,4 +69,4 @@ describe('diffCardPlatforms', () => { expect(diff.removed).toEqual([]); expect(diff.unchanged).toEqual(['github']); }); -}); \ No newline at end of file +}); diff --git a/packages/shared/src/__tests__/platforms-url.test.ts b/packages/shared/src/__tests__/platforms-url.test.ts index cbfac373..2ddb7c2d 100644 --- a/packages/shared/src/__tests__/platforms-url.test.ts +++ b/packages/shared/src/__tests__/platforms-url.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from 'vitest'; import { getProfileUrl, getWebViewUrl, getDeepLinkUrl } from '../platforms'; -// ─── getProfileUrl Tests ─── - describe('getProfileUrl', () => { it('should return the correct GitHub profile URL', () => { expect(getProfileUrl('github', 'octocat')).toBe('https://github.com/octocat'); @@ -21,8 +19,6 @@ describe('getProfileUrl', () => { }); }); -// ─── getWebViewUrl Tests ─── - describe('getWebViewUrl', () => { it('should return the correct LinkedIn webview URL', () => { expect(getWebViewUrl('linkedin', 'john')).toBe('https://www.linkedin.com/in/john'); @@ -41,8 +37,6 @@ describe('getWebViewUrl', () => { }); }); -// ─── getDeepLinkUrl Tests ─── - describe('getDeepLinkUrl', () => { it('should return the correct Twitter deep link URL', () => { expect(getDeepLinkUrl('twitter', 'john')).toBe('twitter://user?screen_name=john'); diff --git a/packages/shared/src/cards.ts b/packages/shared/src/cards.ts index d9fa5130..0405d7e5 100644 --- a/packages/shared/src/cards.ts +++ b/packages/shared/src/cards.ts @@ -47,4 +47,4 @@ export function diffCardPlatforms( removed: oldCard.filter(p => !newSet.has(p)), unchanged: oldCard.filter(p => newSet.has(p)), }; -} \ No newline at end of file +} diff --git a/packages/shared/src/platforms.test.ts b/packages/shared/src/platforms.test.ts index 6ce07a0b..73ce0e33 100644 --- a/packages/shared/src/platforms.test.ts +++ b/packages/shared/src/platforms.test.ts @@ -8,8 +8,6 @@ import { getDeepLinkUrl, } from './platforms'; -// ─── StackOverflow Platform Tests ─── - describe('stackoverflow platform', () => { it('should exist in PLATFORMS registry', () => { expect(PLATFORMS.stackoverflow).toBeDefined(); @@ -44,9 +42,7 @@ describe('stackoverflow platform', () => { }); }); -// ─── getProfileUrl Tests for StackOverflow ─── - -describe('getProfileUrl – stackoverflow', () => { +describe('getProfileUrl - stackoverflow', () => { it('should generate correct URL with user ID and display name', () => { const url = getProfileUrl('stackoverflow', '1234/user'); expect(url).toBe('https://stackoverflow.com/users/1234/user'); @@ -63,9 +59,7 @@ describe('getProfileUrl – stackoverflow', () => { }); }); -// ─── getWebViewUrl / getDeepLinkUrl for StackOverflow ─── - -describe('getWebViewUrl / getDeepLinkUrl – stackoverflow', () => { +describe('getWebViewUrl / getDeepLinkUrl - stackoverflow', () => { it('should return null for webViewUrl (not supported)', () => { expect(getWebViewUrl('stackoverflow', '1234/user')).toBeNull(); }); @@ -75,15 +69,12 @@ describe('getWebViewUrl / getDeepLinkUrl – stackoverflow', () => { }); }); -// ─── validationRegex Tests ─── - describe('validationRegex logic', () => { it('should correctly validate github usernames', () => { const regex = PLATFORMS.github.validationRegex!; expect(regex.test('valid-user')).toBe(true); expect(regex.test('a')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid expect(regex.test('-invalid')).toBe(false); expect(regex.test('invalid-')).toBe(false); expect(regex.test('in--valid')).toBe(false); @@ -94,8 +85,7 @@ describe('validationRegex logic', () => { const regex = PLATFORMS.linkedin.validationRegex!; expect(regex.test('valid-user')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid - expect(regex.test('ab')).toBe(false); // Too short + expect(regex.test('ab')).toBe(false); expect(regex.test('user name')).toBe(false); }); @@ -103,9 +93,8 @@ describe('validationRegex logic', () => { const regex = PLATFORMS.twitter.validationRegex!; expect(regex.test('valid_user')).toBe(true); expect(regex.test('user123')).toBe(true); - // Invalid - expect(regex.test('user-name')).toBe(false); // Hyphens not allowed - expect(regex.test('this_is_a_very_long_name_indeed')).toBe(false); // Too long + expect(regex.test('user-name')).toBe(false); + expect(regex.test('this_is_a_very_long_name_indeed')).toBe(false); expect(regex.test('user name')).toBe(false); }); }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4a4a9dcc..3847a64e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,5 +1,3 @@ -// ─── User Types ─── - export interface User { id: string; email: string; @@ -24,8 +22,6 @@ export interface UpdateProfilePayload { accentColor?: string; } -// ─── Platform Link Types ─── - export interface PlatformLink { id: string; platform: string; @@ -44,11 +40,16 @@ export interface ReorderLinksPayload { links: Array<{ id: string; displayOrder: number }>; } -// ─── Card Types ─── +export type CardVisibility = 'PUBLIC' | 'UNLISTED' | 'PRIVATE'; export interface Card { id: string; title: string; + description?: string | null; + slug?: string; + visibility?: CardVisibility; + qrEnabled?: boolean; + viewCount?: number; isDefault: boolean; links: PlatformLink[]; } @@ -56,15 +57,18 @@ export interface Card { export interface CreateCardPayload { title: string; linkIds: string[]; + description?: string; + visibility?: CardVisibility; } export interface UpdateCardPayload { title?: string; linkIds?: string[]; + description?: string; + visibility?: CardVisibility; + qrEnabled?: boolean; } -// ─── Public Profile Types ─── - export interface PublicProfile { username: string; displayName: string; @@ -92,8 +96,6 @@ export interface PublicCard { links: PlatformLink[]; } -// ─── Follow Engine Types ─── - export type FollowStatus = 'idle' | 'loading' | 'success' | 'error'; export interface FollowResult { @@ -103,8 +105,6 @@ export interface FollowResult { message?: string; } -// ─── Auth Types ─── - export interface AuthResponse { token: string; user: User;