Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ React Compiler is enabled repo-wide, and compiler diagnostics are treated as har

### Running the app locally

#### Local account sign-in

Start the restored database and User Service from the sibling API repository for local sign-in and
favorites sync. Run the catalog API as well when testing best-effort hydration for an active favorite
whose catalog object is missing from Realm. Catalog resolver failure does not block favorite or
unfavorite synchronization.

```bash
# Terminal 1
cd ../RelistenApi
./start-local-databases.sh
# Optional: best-effort favorite metadata hydration
dotnet run --project RelistenApi/RelistenApi.csproj

# Terminal 2
cd ../RelistenApi
dotnet run --project RelistenUserService/RelistenUserService.csproj
```

The iOS Simulator can reach the catalog API at `http://localhost:3823` and the User Service at
`http://localhost:5443`. For an Android emulator, forward both loopback addresses before starting
the app:

```bash
adb reverse tcp:3823 tcp:3823
adb reverse tcp:5443 tcp:5443
```

In local Development, the Apple and Google buttons open a page of fixed test personas. The rest of
the sign-in path is real: OpenID Connect authorization code, S256 PKCE, token exchange, protected
credential storage, and the normal account API. Local sign-in needs no Apple or Google secrets.

A physical phone cannot reach the Mac through its own `localhost`. Use the configured preview
issuer for normal physical-device account testing, and point
`EXPO_PUBLIC_RELISTEN_AUTH_ISSUER`, `EXPO_PUBLIC_RELISTEN_ACCOUNTS_ORIGIN`, and
`EXPO_PUBLIC_RELISTEN_CATALOG_ORIGIN` at that environment.
A laptop tunnel works only when the User Service is also configured with the same public HTTPS
issuer, allowed hosts, and provider callbacks; changing the mobile variables alone is not
enough. Fixed development personas intentionally work only with a loopback issuer. The simulator
and emulator paths above are the default zero-secret workflow.

#### Mac + iOS Simulator

If you are on a Mac and would like to get the app running locally on iOS simulator, please:
Expand Down
8 changes: 7 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="relisten"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="net.relisten.mobile"/>
</intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https" android:host="*.relisten.net" android:pathPrefix="/"/>
Expand All @@ -51,4 +57,4 @@
</intent-filter>
</activity>
</application>
</manifest>
</manifest>
5 changes: 4 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"scheme": "relisten",
"scheme": [
"relisten",
"net.relisten.mobile"
],
"assetBundlePatterns": [
"**/*"
],
Expand Down
118 changes: 70 additions & 48 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { RelistenBlue } from '@/relisten/relisten_blue';
import { StatusBar } from 'expo-status-bar';

import '@/relisten/util/dayjs_setup';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useCacheAssets from './useCacheAssets';

import { RelistenPlayerProvider } from '@/relisten/player/relisten_player_hooks';
Expand All @@ -46,13 +46,17 @@ import {
useRootUserSettingsStore,
} from '@/relisten/realm/root_services';
import { routingQueue } from 'expo-router/build/global-state/routing';
import { AccountProvider, useAccountScope } from '@/relisten/accounts/account_context';
import { FavoriteSyncCoordinator } from '@/relisten/library/favorite_sync_coordinator';
import {
createRouteDebugSignature,
describeNavigationStack,
describeRoutingQueueAction,
isVerboseProfileLoggingEnabled,
logRouteDebug,
} from '@/relisten/util/profile_logging';
import { PlaybackAccountTransitionEffects } from '@/relisten/player/playback_account_transition_effects';
import { RelistenPlayer } from '@/relisten/player/relisten_player';

// c.f. https://github.com/meliorence/react-native-render-html/issues/661#issuecomment-2453476566
LogBox.ignoreLogs([/Support for defaultProps will be removed/]);
Expand Down Expand Up @@ -110,13 +114,28 @@ function RealmBridge() {
return null;
}

function AccountProviderBridge({ children }: { children: React.ReactNode }) {
const realm = useRealm();
const transitionEffects = useMemo(
() => new PlaybackAccountTransitionEffects(RelistenPlayer.DEFAULT_INSTANCE, realm),
[realm]
);

return (
<AccountProvider realm={realm} transitionEffects={transitionEffects}>
{children}
</AccountProvider>
);
}

function CarPlayBootstrap() {
const realm = useRealm();
const { apiClient } = useRelistenApi();
const libraryIndex = useRootLibraryIndex();
const userSettingsStore = useRootUserSettingsStore();
const { generation } = useAccountScope();

useCarPlaySetup(apiClient, realm, libraryIndex, userSettingsStore);
useCarPlaySetup(apiClient, realm, libraryIndex, userSettingsStore, generation);

return null;
}
Expand Down Expand Up @@ -199,52 +218,55 @@ function TabLayout() {

return (
<RealmProvider realmRef={realmRef} closeOnUnmount={false}>
<RootServicesProvider>
<RelistenApiProvider>
<RelistenPlayerProvider>
<RelistenCastProvider>
<PlaybackHistoryReporterComponent />
<LastFmReporterComponent />
<LastFmAuthListener />
<RealmBridge />
<AudioAdjustmentCoordinator />
<CarPlayBootstrap />
<ThemeProvider
value={{
dark: true,
colors: {
...DarkTheme.colors,
primary: 'rgb(0,157,193)',
background: RelistenBlue[900],
card: '#001114',
},
fonts: DefaultTheme.fonts,
}}
>
<RelistenPlayerBottomBarProvider>
<PlayerPresentationProvider>
<GestureHandlerRootView className="flex-1" onLayout={onLayoutRootView}>
<SafeAreaProvider>
{/* */}
<StatusBar style="light" />
<Slot />
<PlayerPresentationOverlay />
<View
className="absolute inset-0"
pointerEvents="box-none"
style={{ elevation: 2000, zIndex: 2000 }}
>
<FlashMessage position="top" />
</View>
</SafeAreaProvider>
</GestureHandlerRootView>
</PlayerPresentationProvider>
</RelistenPlayerBottomBarProvider>
</ThemeProvider>
</RelistenCastProvider>
</RelistenPlayerProvider>
</RelistenApiProvider>
</RootServicesProvider>
<AccountProviderBridge>
<RootServicesProvider>
<RelistenApiProvider>
<RelistenPlayerProvider>
<RelistenCastProvider>
<PlaybackHistoryReporterComponent />
<FavoriteSyncCoordinator />
<LastFmReporterComponent />
<LastFmAuthListener />
<RealmBridge />
<AudioAdjustmentCoordinator />
<CarPlayBootstrap />
<ThemeProvider
value={{
dark: true,
colors: {
...DarkTheme.colors,
primary: 'rgb(0,157,193)',
background: RelistenBlue[900],
card: '#001114',
},
fonts: DefaultTheme.fonts,
}}
>
<RelistenPlayerBottomBarProvider>
<PlayerPresentationProvider>
<GestureHandlerRootView className="flex-1" onLayout={onLayoutRootView}>
<SafeAreaProvider>
{/* */}
<StatusBar style="light" />
<Slot />
<PlayerPresentationOverlay />
<View
className="absolute inset-0"
pointerEvents="box-none"
style={{ elevation: 2000, zIndex: 2000 }}
>
<FlashMessage position="top" />
</View>
</SafeAreaProvider>
</GestureHandlerRootView>
</PlayerPresentationProvider>
</RelistenPlayerBottomBarProvider>
</ThemeProvider>
</RelistenCastProvider>
</RelistenPlayerProvider>
</RelistenApiProvider>
</RootServicesProvider>
</AccountProviderBridge>
</RealmProvider>
);
}
Expand Down
1 change: 1 addition & 0 deletions app/auth/mobile/android/callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AuthCallbackRoute as default } from '@/relisten/accounts/auth/auth_callback_route';
1 change: 1 addition & 0 deletions app/auth/mobile/ios/callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AuthCallbackRoute as default } from '@/relisten/accounts/auth/auth_callback_route';
1 change: 1 addition & 0 deletions app/oauth2redirect/android.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AuthCallbackRoute as default } from '@/relisten/accounts/auth/auth_callback_route';
1 change: 1 addition & 0 deletions app/oauth2redirect/ios.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AuthCallbackRoute as default } from '@/relisten/accounts/auth/auth_callback_route';
52 changes: 52 additions & 0 deletions app/relisten/account/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { RelistenBlue } from '@/relisten/relisten_blue';
import { Stack } from 'expo-router/stack';
import { useRouter } from 'expo-router';
import { MaterialIcons } from '@expo/vector-icons';
import { Pressable } from 'react-native';

function AccountBackButton() {
const router = useRouter();

const goBack = () => {
if (router.canGoBack()) {
router.back();
return;
}

// A cold account deep link has no caller to return to. My Library is the most
// useful stable destination because it also exposes sync state and account access.
router.replace('/relisten/tabs/(myLibrary)');
};

return (
<Pressable
accessibilityLabel="Back"
accessibilityRole="button"
className="pl-1 pr-3"
hitSlop={12}
onPress={goBack}
>
<MaterialIcons color="white" name="arrow-back-ios-new" size={22} />
</Pressable>
);
}

export default function AccountLayout() {
return (
<Stack
screenOptions={{
contentStyle: { backgroundColor: RelistenBlue['950'] },
headerBackButtonDisplayMode: 'minimal',
headerShadowVisible: false,
headerStyle: { backgroundColor: RelistenBlue['900'] },
headerTintColor: 'white',
}}
>
<Stack.Screen
name="index"
options={{ headerLeft: () => <AccountBackButton />, title: 'Account' }}
/>
<Stack.Screen name="username" options={{ title: 'Username' }} />
</Stack>
);
}
1 change: 1 addition & 0 deletions app/relisten/account/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AccountScreen as default } from '@/relisten/accounts/ui/account_screen';
1 change: 1 addition & 0 deletions app/relisten/account/username.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UsernameScreen as default } from '@/relisten/accounts/ui/username_screen';
Loading