Skip to content
Merged
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
42 changes: 42 additions & 0 deletions .github/workflows/android-dev-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,48 @@ jobs:
printf '%s' "$ANDROID_DEBUG_KEYSTORE_BASE64" | base64 --decode > android/app/debug.keystore
chmod 600 android/app/debug.keystore

- name: Verify Android App Link certificate
shell: bash
working-directory: apps/mobile
run: |
actual_fingerprint="$(
keytool -exportcert \
-alias androiddebugkey \
-keystore android/app/debug.keystore \
-storepass android \
-rfc \
| openssl x509 -noout -fingerprint -sha256 \
| cut -d= -f2
)"
expected_fingerprints="$(
node -e '
const { readFileSync } = require("node:fs");
const associations = JSON.parse(
readFileSync("../web/public/.well-known/assetlinks.json", "utf8"),
);
const developmentFingerprints = associations
.filter(
({ relation, target }) =>
relation.includes(
"delegate_permission/common.handle_all_urls",
) &&
target.namespace === "android_app" &&
target.package_name ===
"com.padocorp.clubhouse.applicationId.debug",
)
.flatMap(({ target }) => target.sha256_cert_fingerprints);
if (developmentFingerprints.length === 0) process.exit(1);
process.stdout.write(
[...new Set(developmentFingerprints)].join("\n"),
);
'
)"

if ! printf '%s\n' "$expected_fingerprints" | grep -Fxq "$actual_fingerprint"; then
echo "Restored Android certificate is not registered in assetlinks.json"
exit 1
fi

- name: Build standalone dev APK
working-directory: apps/mobile
run: |
Expand Down
266 changes: 181 additions & 85 deletions apps/mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import {
QueryClientProvider,
} from "@tanstack/react-query";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { AppState, StyleSheet, Text, View } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Linking, StyleSheet, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import {
initialWindowMetrics,
SafeAreaProvider,
} from "react-native-safe-area-context";
import Toast, { type ToastConfig } from "react-native-toast-message";
import { linking } from "@/config/linking";
import {
createAppLinking,
createBufferedUrlSubscription,
} from "@/config/linking";
import AnnouncementEditScreen from "@/features/club/screens/AnnouncementEditScreen";
import AnnouncementRegistrationScreen from "@/features/club/screens/AnnouncementRegistrationScreen";
import ManagerTransferAcceptanceScreen from "@/features/club/screens/ManagerTransferAcceptanceScreen";
Expand All @@ -37,15 +40,26 @@ import {
SCREEN_TYPE,
} from "@/shared/constants/screen";
import { typography } from "@/shared/constants/typography";
import {
AppModalFlowProvider,
useAppModalFlow,
} from "@/shared/contexts/appModalFlowContext";
import { LoginBottomSheetProvider } from "@/shared/contexts/loginBottomSheetContext";
import { ManageClubBottomSheetProvider } from "@/shared/contexts/manageClubBottomSheet";
import { ProfileProvider } from "@/shared/contexts/profileContext";
import { serviceContext } from "@/shared/contexts/serviceContext";
import { UserVoiceBottomSheetProvider } from "@/shared/contexts/userVoiceBottomSheetContext";
import { initToken } from "@/shared/utils/api";
import { shouldShowGlobalAppModals } from "@/shared/utils/entrySplash";
import {
getNavigationInitialUrl,
type PendingEntryIntent,
parsePendingEntryIntent,
replacePendingEntryIntent,
resolveExecutableEntryIntent,
} from "@/shared/utils/entryIntent";
import {
_navigationRef,
navigation,
setIsNavigationReady,
} from "@/shared/utils/navigation";
import { ms, s, vs } from "@/shared/utils/scale";
Expand Down Expand Up @@ -73,70 +87,146 @@ const queryClient = new QueryClient({
},
});

type PendingEntryIntentHandlerProps = {
pendingEntryIntent: PendingEntryIntent;
isNavigationReady: boolean;
onConsume: () => void;
};

const PendingEntryIntentHandler = ({
pendingEntryIntent,
isNavigationReady,
onConsume,
}: PendingEntryIntentHandlerProps) => {
const { state: appModalFlowState } = useAppModalFlow();

useEffect(() => {
const executableIntent = resolveExecutableEntryIntent({
pendingIntent: pendingEntryIntent,
isNavigationReady,
appModalFlowState,
});
if (!executableIntent) return;

onConsume();
navigation.replace(SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE, {
token: executableIntent.token,
});
}, [appModalFlowState, isNavigationReady, onConsume, pendingEntryIntent]);

return null;
};

function App(): React.JSX.Element {
const { Provider: ServiceProvider } = serviceContext;
const services = useMemo(() => {
const announcementRepository = getAnnouncementRepository();
const appVersionRepository = getAppVersionRepository();
const authRepository = getAuthRepository();
const categoryRepository = getCategoryRepository();
const clubRepository = getClubRepository();
const recentSearchRepository = getRecentSearchRepository();
const recruitmentRepository = getRecruitmentRepository();
const reviewRepository = getReviewRepository();
const termRepository = getTermRepository();
const userRepository = getUserRepository();

const announcementRepository = getAnnouncementRepository();
const appVersionRepository = getAppVersionRepository();
const authRepository = getAuthRepository();
const categoryRepository = getCategoryRepository();
const clubRepository = getClubRepository();
const recentSearchRepository = getRecentSearchRepository();
const recruitmentRepository = getRecruitmentRepository();
const reviewRepository = getReviewRepository();
const termRepository = getTermRepository();
const userRepository = getUserRepository();

const announcementService = getAnnouncementService({
repositories: [announcementRepository],
});
const appVersionService = getAppVersionService({
repositories: [appVersionRepository],
});
const authService = getAuthService({ repositories: [authRepository] });
const categoryService = getCategoryService({
repositories: [categoryRepository],
});
const clubService = getClubService({ repositories: [clubRepository] });
const eventLogService = getEventLogService();
const recentSearchService = getRecentSearchService({
repositories: [recentSearchRepository],
});
const recruitmentService = getRecruitmentService({
repositories: [recruitmentRepository],
});
const reviewService = getReviewService({ repositories: [reviewRepository] });
const termService = getTermService({ repositories: [termRepository] });
const userService = getUserService({ repositories: [userRepository] });

const services = {
announcementService,
appVersionService,
authService,
categoryService,
clubService,
eventLogService,
recentSearchService,
recruitmentService,
reviewService,
termService,
userService,
};
return {
announcementService: getAnnouncementService({
repositories: [announcementRepository],
}),
appVersionService: getAppVersionService({
repositories: [appVersionRepository],
}),
authService: getAuthService({ repositories: [authRepository] }),
categoryService: getCategoryService({
repositories: [categoryRepository],
}),
clubService: getClubService({ repositories: [clubRepository] }),
eventLogService: getEventLogService(),
recentSearchService: getRecentSearchService({
repositories: [recentSearchRepository],
}),
recruitmentService: getRecruitmentService({
repositories: [recruitmentRepository],
}),
reviewService: getReviewService({ repositories: [reviewRepository] }),
termService: getTermService({ repositories: [termRepository] }),
userService: getUserService({ repositories: [userRepository] }),
};
}, []);
const urlEvents = useMemo(() => createBufferedUrlSubscription(), []);

// 토큰을 메모리로 복원한 뒤에야 인증 요청을 하는 트리(ProfileProvider 등)를 마운트한다.
// 그렇지 않으면 _token이 채워지기 전에 /v2/users/me가 게스트로 나가 자동로그인이 깨진다.
const [isBootstrapped, setIsBootstrapped] = useState(false);
const [entryFlowComplete, setEntryFlowComplete] = useState(false);
const [initialNavigationUrl, setInitialNavigationUrl] = useState<
string | null
>(null);
const [pendingEntryIntent, setPendingEntryIntent] =
useState<PendingEntryIntent>(null);
const [isNavigationReady, setNavigationReady] = useState(false);
const handleEntryComplete = useCallback(() => setEntryFlowComplete(true), []);
const handlePendingEntryIntent = useCallback(
(intent: NonNullable<PendingEntryIntent>) => {
setPendingEntryIntent((currentIntent) =>
replacePendingEntryIntent(currentIntent, intent),
);
},
[],
);
const handleConsumePendingEntryIntent = useCallback(
() => setPendingEntryIntent(null),
[],
);
const handleNavigationReady = useCallback(() => {
setIsNavigationReady(true);
setNavigationReady(true);
}, []);
const appLinking = useMemo(
() =>
createAppLinking({
initialUrl: initialNavigationUrl,
onPendingEntryIntent: handlePendingEntryIntent,
subscribeToUrls: urlEvents.subscribe,
}),
[handlePendingEntryIntent, initialNavigationUrl, urlEvents.subscribe],
);

useEffect(() => {
let isCancelled = false;
const subscription = Linking.addEventListener("url", ({ url }) => {
urlEvents.publish(url);
});
const bootstrap = async () => {
await initToken();
setIsNavigationReady(true);
const [, initialUrl] = await Promise.all([
initToken(),
Linking.getInitialURL().catch(() => null),
]);
if (isCancelled) return;

if (!urlEvents.hasPublishedUrl()) {
const initialEntryIntent = parsePendingEntryIntent(initialUrl);
setPendingEntryIntent(initialEntryIntent);
setInitialNavigationUrl(getNavigationInitialUrl(initialUrl));
}
setIsBootstrapped(true);
};
bootstrap();
}, []);

return () => {
isCancelled = true;
subscription.remove();
};
}, [urlEvents]);

useEffect(
() => () => {
setIsNavigationReady(false);
},
[],
);

useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
Expand All @@ -159,40 +249,46 @@ function App(): React.JSX.Element {
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
<LoginBottomSheetProvider>
<UserVoiceBottomSheetProvider>
<ManageClubBottomSheetProvider>
<ForceUpdateGate onEntryComplete={handleEntryComplete}>
<NavigationContainer
ref={_navigationRef}
linking={linking}
>
<RootStack.Navigator
screenOptions={{ headerShown: false }}
<AppModalFlowProvider active={entryFlowComplete}>
<UserVoiceBottomSheetProvider>
<ManageClubBottomSheetProvider>
<ForceUpdateGate onEntryComplete={handleEntryComplete}>
<NavigationContainer
ref={_navigationRef}
linking={appLinking}
onReady={handleNavigationReady}
>
<RootStack.Screen
name="Main"
component={TabNavigator}
/>
<RootStack.Screen
name={SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION}
component={AnnouncementRegistrationScreen}
/>
<RootStack.Screen
name={SCREEN_TYPE.ANNOUNCEMENT_EDIT}
component={AnnouncementEditScreen}
/>
<RootStack.Screen
name={SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE}
component={ManagerTransferAcceptanceScreen}
/>
</RootStack.Navigator>
</NavigationContainer>
{shouldShowGlobalAppModals({ entryFlowComplete }) && (
<RootStack.Navigator
screenOptions={{ headerShown: false }}
>
<RootStack.Screen
name="Main"
component={TabNavigator}
/>
<RootStack.Screen
name={SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION}
component={AnnouncementRegistrationScreen}
/>
<RootStack.Screen
name={SCREEN_TYPE.ANNOUNCEMENT_EDIT}
component={AnnouncementEditScreen}
/>
<RootStack.Screen
name={SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE}
component={ManagerTransferAcceptanceScreen}
/>
</RootStack.Navigator>
</NavigationContainer>
<PendingEntryIntentHandler
pendingEntryIntent={pendingEntryIntent}
isNavigationReady={isNavigationReady}
onConsume={handleConsumePendingEntryIntent}
/>
<AppModalManager />
)}
</ForceUpdateGate>
</ManageClubBottomSheetProvider>
</UserVoiceBottomSheetProvider>
</ForceUpdateGate>
</ManageClubBottomSheetProvider>
</UserVoiceBottomSheetProvider>
</AppModalFlowProvider>
</LoginBottomSheetProvider>
</BottomSheetModalProvider>
</GestureHandlerRootView>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="all-clear.cc" />
<data android:scheme="https" android:host="all-clear.cc" />
</intent-filter>

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="dev.all-clear.cc" />
<data android:scheme="https" android:host="dev.all-clear.cc" />
</intent-filter>
</activity>
Expand Down
Loading
Loading