Skip to content
8 changes: 7 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
Expand All @@ -28,6 +28,12 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</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="https" android:host="www.grimity.com" />
</intent-filter>

<!-- 카카오톡 공유, 카카오톡 메시지 커스텀 URL 스킴 설정 -->
<intent-filter android:label="kakao_link">
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.grimity.com</string>
</array>
</dict>
</plist>
6 changes: 6 additions & 0 deletions lib/app/config/app_analytics.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'package:firebase_analytics/firebase_analytics.dart';

abstract final class AppAnalytics {
static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
static final FirebaseAnalyticsObserver observer = FirebaseAnalyticsObserver(analytics: _analytics);
}
80 changes: 53 additions & 27 deletions lib/app/config/app_router.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:grimity/app/config/app_analytics.dart';
import 'package:grimity/app/enum/report.enum.dart';
import 'package:grimity/app/linking/external_link.dart';
import 'package:grimity/app/linking/external_link_parser.dart';
import 'package:grimity/app/linking/initialize_app_provider.dart';
import 'package:grimity/domain/entity/album.dart';
import 'package:grimity/domain/entity/feed.dart';
import 'package:grimity/domain/entity/post.dart';
Expand Down Expand Up @@ -40,21 +41,22 @@ import 'package:grimity/presentation/sign_in/sign_in_page.dart';
import 'package:grimity/presentation/sign_up/sign_up_page.dart';
import 'package:grimity/presentation/splash/splash_page.dart';
import 'package:grimity/presentation/storage/storage_page.dart';
import 'package:grimity/app/linking/pending_deep_link_provider.dart';
import 'package:grimity/presentation/common/provider/user_auth_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'app_router.g.dart';

final rootNavigatorKey = GlobalKey<NavigatorState>();
final shellNavigatorKey = GlobalKey<NavigatorState>();

abstract final class AppRouter {
static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
static final FirebaseAnalyticsObserver _observer = FirebaseAnalyticsObserver(analytics: _analytics);

static final GoRouter _router = GoRouter(
@riverpod
GoRouter router(Ref ref) {
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: SplashRoute.path,
routes: $appRoutes,
observers: [_observer],
observers: [AppAnalytics.observer],
// FIX: 카카오톡 App 로그인 시의 Routing 관련 문제 수정
// Ref: https://github.com/kakao/kakao_flutter_sdk/issues/200
redirect: (context, state) {
Expand All @@ -64,29 +66,47 @@ abstract final class AppRouter {
return SignInRoute.path;
}

final isWebLink = uri.scheme == 'http' || uri.scheme == 'https';

// 웹 링크(딥링크)인 경우 redirect 처리
if (isWebLink) {
final parsed = ExternalLinkParser.parse(uri.toString());
final isLogin = ref.read(userAuthProvider) != null;
final initializeApp = ref.read(initializeAppProvider);
final pendingDeepLinkNotifier = ref.read(pendingDeepLinkProvider.notifier);

/// [ColdStart]인 경우
if (initializeApp == false) {
// 유효하지 않은 경로는 딥링크 세팅 없이 스플래시 페이지 이동 처리
if (parsed.type == ExternalLinkType.unknown) {
return SplashRoute.path;
}

// 유효한 경로는 딥링크 세팅 후 스플래시 페이지 이동 처리
Future.microtask(() => pendingDeepLinkNotifier.setLink(parsed.location));
return SplashRoute.path;
}
/// [WarmStart]인 경우
else {
// 유효하지 않은 경로는 딥링크 세팅 없이 로그인 여부에 따라
if (parsed.type == ExternalLinkType.unknown) {
return isLogin ? HomeRoute.path : SignInRoute.path;
}

if (isLogin) {
// 로그인 된 사용자의 경우 딥링크 세팅 후 홈 페이지로 이동
pendingDeepLinkNotifier.setLink(parsed.location);
return HomeRoute.path;
} else {
// 로그인되지 않은 사용자의 경우 딥링크 세팅 없이 회원가입 페이지로 이동
return SignInRoute.path;
}
}
}

return null;
},
);

static GoRouter router(WidgetRef ref) => _router;

// URL을 내부 라우팅으로 이동
static void handleServerUrl(BuildContext context, String url, {String? myUrl}) {
final parsed = ExternalLinkParser.parse(url);
switch (parsed.type) {
case ExternalLinkType.profile:
ProfileRoute(url: parsed.url!).push(context);
break;
case ExternalLinkType.post:
context.push('/posts/${parsed.id}');
break;
case ExternalLinkType.feed:
context.push('/feeds/${parsed.id}');
break;
case ExternalLinkType.unknown:
break;
}
}
}

@TypedStatefulShellRoute<AppShellRoute>(
Expand Down Expand Up @@ -250,6 +270,8 @@ class ProfileRoute extends GoRouteData {
static const String path = '/profile/:url';
static const String name = 'userProfile';

static String makePath(String url) => '/profile/$url';

@override
Widget build(BuildContext context, GoRouterState state) => ProfilePage(url: url);
}
Expand Down Expand Up @@ -415,6 +437,8 @@ class FeedDetailRoute extends GoRouteData {
static const String path = '/feeds/:id';
static const String name = 'feed-detail';

static String makePath(String id) => '/feeds/$id';

@override
Widget build(BuildContext context, GoRouterState state) {
return FeedDetailPage(feedId: id);
Expand Down Expand Up @@ -447,6 +471,8 @@ class PostDetailRoute extends GoRouteData {
static const String path = '/posts/:id';
static const String name = 'post-detail';

static String makePath(String id) => '/posts/$id';

@override
Widget build(BuildContext context, GoRouterState state) {
return PostDetailPage(postId: id);
Expand Down
13 changes: 13 additions & 0 deletions lib/app/extension/string_extension.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/widgets.dart';
import 'package:grimity/app/environment/flavor.dart';
import 'package:grimity/app/util/validator_util.dart';

extension StringExtension on String {
/// 리사이즈를 지원하는 이미지 크기(너비 기준) 목록.
Expand Down Expand Up @@ -58,4 +59,16 @@ extension StringExtension on String {

return Size(width.toDouble(), height.toDouble());
}

String? getUrlCheckMessage() {
if (!ValidatorUtil.isValidUrl(this)) {
return '숫자, 영문(소문자), 언더바(_)만 입력 가능합니다.';
}

if (ValidatorUtil.isForbiddenUrl(this)) {
return '사용할 수 없는 URL입니다.';
}

return null;
}
}
22 changes: 20 additions & 2 deletions lib/app/linking/external_link.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
enum ExternalLinkType { profile, post, feed, unknown }
import 'package:grimity/app/config/app_router.dart';

enum ExternalLinkType {
profile,
post,
feed,
unknown,
}

class ExternalLink {
const ExternalLink(this.type, {this.url, this.id});
const ExternalLink(
this.type, {
this.url,
this.id,
});

final ExternalLinkType type;
final String? url;
final String? id;

String get location => switch (type) {
ExternalLinkType.profile => ProfileRoute.makePath(url!),
ExternalLinkType.post => PostDetailRoute.makePath(id!),
ExternalLinkType.feed => FeedDetailRoute.makePath(id!),
ExternalLinkType.unknown => throw UnimplementedError('unknown은 location을 사용할 수 없습니다.'),
};
}
4 changes: 2 additions & 2 deletions lib/app/linking/external_link_parser.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:grimity/app/config/app_config.dart';
import 'package:grimity/app/util/validator_util.dart';

import 'external_link.dart';

Expand Down Expand Up @@ -34,8 +35,7 @@ class ExternalLinkParser {
// /:userPath (예약어 충돌 방지)
if (segs.length == 1) {
final url = segs.first;
const reserved = {'posts', 'feeds'};
if (!reserved.contains(url)) {
if (!ValidatorUtil.forbiddenUrls.contains(url)) {
return ExternalLink(ExternalLinkType.profile, url: url);
}
}
Expand Down
15 changes: 15 additions & 0 deletions lib/app/linking/initialize_app_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'initialize_app_provider.g.dart';

/// 앱 초기화 여부를 확인하기 위한 Provider.
/// 딥 링크로 앱이 켜졌을 때 ColdStart/WramStart를 구분하기 위해 사용합니다.
@Riverpod(keepAlive: true)
class InitializeApp extends _$InitializeApp {
@override
bool build() => false;

void set(bool value) {
state = value;
}
}
19 changes: 19 additions & 0 deletions lib/app/linking/pending_deep_link_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'pending_deep_link_provider.g.dart';

@Riverpod(keepAlive: true)
class PendingDeepLink extends _$PendingDeepLink {
@override
String? build() => null;

void setLink(String link) {
state = link;
}

String? consume() {
final link = state;
state = null;
return link;
}
}
21 changes: 21 additions & 0 deletions lib/app/linking/url_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:grimity/app/linking/external_link.dart';
import 'package:grimity/app/linking/external_link_parser.dart';

abstract class UrlHandler {
// URL을 내부 라우팅으로 이동
static void handleServerUrl(BuildContext context, String url) {
final parsed = ExternalLinkParser.parse(url);

switch (parsed.type) {
case ExternalLinkType.profile:
case ExternalLinkType.post:
case ExternalLinkType.feed:
context.push(parsed.location);
break;
case ExternalLinkType.unknown:
break;
}
}
}
43 changes: 43 additions & 0 deletions lib/app/util/validator_util.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,47 @@
class ValidatorUtil {
static List<String> forbiddenUrls = [
"popular",
"board",
"following",
"search",
"write",
"posts",
"feeds",
"mypage",
"ranking",
"direct",
"admin",
"home",
"sign-in",
"sign-up",
"splash",
"setting",
"notification",
"report",
"storage",
"follow",
"album-edit",
"profile-edit",
"crop-image",
"feed-upload",
"post-upload",
"photo-select",
"image-viewer",
"album-organize",
"blocked-users",
"chatMessage",
"newChat",
"boardSearch",
];

static bool isAvailableUrl(String url) {
return isValidUrl(url) && !isForbiddenUrl(url);
}

static bool isForbiddenUrl(String url) {
return forbiddenUrls.contains(url);
}

static bool isValidUrl(String url) {
// 숫자, 영문(소문자), 언더바(_), 2 ~ 12자
return RegExp(r'^[a-z0-9_]{2,12}$').hasMatch(url);
Expand Down
19 changes: 18 additions & 1 deletion lib/presentation/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:grimity/app/config/app_router.dart';
import 'package:grimity/app/config/app_theme.dart';
import 'package:grimity/app/environment/flavor.dart';
import 'package:grimity/app/linking/initialize_app_provider.dart';
import 'package:grimity/app/linking/pending_deep_link_provider.dart';
import 'package:grimity/app/static/push_notification.dart';
import 'package:grimity/presentation/common/provider/user_auth_provider.dart';
import 'package:talker_flutter/talker_flutter.dart';
Expand Down Expand Up @@ -79,14 +81,29 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {

@override
Widget build(BuildContext context) {
// [WarmStart] 딥링크 이벤트 처리
ref.listen(pendingDeepLinkProvider, (previous, next) {
// initializeApp 플래그를 통해 [ColdStart] 처리는 여기서 하지 않음
final initializeApp = ref.read(initializeAppProvider);

if (next != null && initializeApp == true) {
final link = ref.read(pendingDeepLinkProvider.notifier).consume();
if (link != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(routerProvider).push(link);
});
}
}
});

return MaterialApp.router(
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
routerConfig: AppRouter.router(ref),
routerConfig: ref.watch(routerProvider),
theme: AppTheme.appTheme,
builder: routerBuilder,
);
Expand Down
Loading