Commit b93b2948 authored by DatHV's avatar DatHV
Browse files

update logic handle dynamic branch link

parent e9cf8244
...@@ -44,6 +44,18 @@ android { ...@@ -44,6 +44,18 @@ android {
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders.putAll(
mapOf(
"branch_key_live" to "key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw",
"branch_key_test" to "key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV",
"branch_uri_scheme" to "mypointapp",
"branch_app_domain" to "mypoint.app.link",
"branch_app_domain_test" to "mypoint.test-app.link",
"branch_alt_domain" to "mypoint-alternate.app.link",
"branch_alt_domain_test" to "mypoint-alternate.test-app.link",
"branch_test_mode" to "false"
)
)
} }
signingConfigs { signingConfigs {
...@@ -97,6 +109,7 @@ android { ...@@ -97,6 +109,7 @@ android {
buildConfigField("boolean", "ENABLE_LOGGING", "${env["enableLogging"]}") buildConfigField("boolean", "ENABLE_LOGGING", "${env["enableLogging"]}")
applicationIdSuffix = ".dev" applicationIdSuffix = ".dev"
versionNameSuffix = "-dev" versionNameSuffix = "-dev"
manifestPlaceholders["branch_test_mode"] = "true"
} }
create("stg") { create("stg") {
dimension = "environment" dimension = "environment"
......
# Branch SDK
-keep class io.branch.** { *; }
-dontwarn io.branch.**
# Flutter Branch SDK uses reflection on Branch plugin classes
-keep class flutter.plugins.flutter_branch_sdk.** { *; }
# Retain Kotlin metadata
-keepclassmembers class kotlin.Metadata { *; }
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mypointapp" /> <data android:scheme="mypointapp" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
...@@ -59,6 +59,30 @@ ...@@ -59,6 +59,30 @@
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel" android:value="default_channel"
/> />
<meta-data
android:name="io.branch.sdk.BranchKey"
android:value="${branch_key_live}" />
<meta-data
android:name="io.branch.sdk.BranchKey.test"
android:value="${branch_key_test}" />
<meta-data
android:name="io.branch.sdk.BranchAppDomain"
android:value="${branch_app_domain}" />
<meta-data
android:name="io.branch.sdk.BranchAppDomain.test"
android:value="${branch_app_domain_test}" />
<meta-data
android:name="io.branch.sdk.BranchAlternateAppDomain"
android:value="${branch_alt_domain}" />
<meta-data
android:name="io.branch.sdk.BranchAlternateAppDomain.test"
android:value="${branch_alt_domain_test}" />
<meta-data
android:name="io.branch.sdk.BranchUriScheme"
android:value="${branch_uri_scheme}" />
<meta-data
android:name="io.branch.sdk.TestMode"
android:value="${branch_test_mode}" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
......
...@@ -76,5 +76,19 @@ ...@@ -76,5 +76,19 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>branch_key</key>
<dict>
<key>live</key>
<string>key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw</string>
<key>test</key>
<string>key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV</string>
</dict>
<key>branch_universal_link_domains</key>
<array>
<string>mypoint.app.link</string>
<string>mypoint-alternate.app.link</string>
<string>mypoint.test-app.link</string>
<string>mypoint-alternate.test-app.link</string>
</array>
</dict> </dict>
</plist> </plist>
...@@ -12,6 +12,8 @@ ...@@ -12,6 +12,8 @@
<string>applinks:mypointapp.page.link</string> <string>applinks:mypointapp.page.link</string>
<string>applinks:mypoint-alternate.app.link</string> <string>applinks:mypoint-alternate.app.link</string>
<string>applinks:mypoint.app.link</string> <string>applinks:mypoint.app.link</string>
<string>applinks:mypoint-alternate.test-app.link</string>
<string>applinks:mypoint.test-app.link</string>
</array> </array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
......
...@@ -11,6 +11,7 @@ import 'package:mypoint_flutter_app/firebase/push_setup.dart'; ...@@ -11,6 +11,7 @@ import 'package:mypoint_flutter_app/firebase/push_setup.dart';
import 'package:mypoint_flutter_app/base/app_loading.dart'; import 'package:mypoint_flutter_app/base/app_loading.dart';
import 'package:mypoint_flutter_app/env_loader.dart'; import 'package:mypoint_flutter_app/env_loader.dart';
import 'package:mypoint_flutter_app/web/web_helper.dart'; import 'package:mypoint_flutter_app/web/web_helper.dart';
import 'package:mypoint_flutter_app/core/deep_link_service.dart';
/// Main app initialization and setup /// Main app initialization and setup
class AppInitializer { class AppInitializer {
...@@ -31,6 +32,8 @@ class AppInitializer { ...@@ -31,6 +32,8 @@ class AppInitializer {
await _fetchUserPointIfLoggedIn(); await _fetchUserPointIfLoggedIn();
// Initialize web-specific features (including x-app-sdk) // Initialize web-specific features (including x-app-sdk)
await _initializeWebFeatures(); await _initializeWebFeatures();
// Initialize deep link handlers (Branch, URI schemes)
await DeepLinkService().initialize();
print('✅ App initialization completed'); print('✅ App initialization completed');
} }
...@@ -105,7 +108,9 @@ class AppInitializer { ...@@ -105,7 +108,9 @@ class AppInitializer {
static Future<void> _handleInitialNotificationLaunch() async { static Future<void> _handleInitialNotificationLaunch() async {
try { try {
final initial = await FirebaseMessaging.instance.getInitialMessage(); final initial = await FirebaseMessaging.instance.getInitialMessage();
print('Checking initial message for app launch from terminated state...$initial'); print(
'Checking initial message for app launch from terminated state...$initial',
);
if (initial == null) return; if (initial == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 1), () { Future.delayed(const Duration(seconds: 1), () {
......
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_branch_sdk/flutter_branch_sdk.dart';
import 'package:mypoint_flutter_app/extensions/string_extension.dart';
import 'package:uni_links/uni_links.dart'; import 'package:uni_links/uni_links.dart';
import 'package:mypoint_flutter_app/directional/directional_screen.dart'; import 'package:mypoint_flutter_app/directional/directional_screen.dart';
import 'package:mypoint_flutter_app/extensions/crypto.dart' as mycrypto; import 'package:mypoint_flutter_app/extensions/crypto.dart' as mycrypto;
import '../directional/directional_action_type.dart';
class DeepLinkService { class DeepLinkService {
DeepLinkService._internal(); DeepLinkService._internal();
static final DeepLinkService _instance = DeepLinkService._internal(); static final DeepLinkService _instance = DeepLinkService._internal();
factory DeepLinkService() => _instance; factory DeepLinkService() => _instance;
StreamSubscription? _linkSub; StreamSubscription? _linkSub;
StreamSubscription<Map>? _branchSub;
bool _initialized = false; bool _initialized = false;
Future<void> initialize() async { Future<void> initialize() async {
...@@ -17,6 +22,7 @@ class DeepLinkService { ...@@ -17,6 +22,7 @@ class DeepLinkService {
_initialized = true; _initialized = true;
if (kDebugMode) print('🔗 Initializing DeepLinkService...'); if (kDebugMode) print('🔗 Initializing DeepLinkService...');
await _initBranchSdk();
await _handleInitialLink(); await _handleInitialLink();
_listenLinkStream(); _listenLinkStream();
} }
...@@ -24,9 +30,32 @@ class DeepLinkService { ...@@ -24,9 +30,32 @@ class DeepLinkService {
Future<void> dispose() async { Future<void> dispose() async {
await _linkSub?.cancel(); await _linkSub?.cancel();
_linkSub = null; _linkSub = null;
await _branchSub?.cancel();
_branchSub = null;
_initialized = false; _initialized = false;
} }
Future<void> _initBranchSdk() async {
try {
await FlutterBranchSdk.init(enableLogging: kDebugMode);
if (kDebugMode) {
print('🌿 Branch SDK init ');
}
_branchSub = FlutterBranchSdk.listSession().listen(
_handleBranchSession,
onError: (error) {
if (kDebugMode) {
print('❌ Branch session stream error: $error');
}
},
);
} catch (e) {
if (kDebugMode) {
print('❌ Failed to initialize Branch SDK: $e');
}
}
}
Future<void> _handleInitialLink() async { Future<void> _handleInitialLink() async {
try { try {
final initial = await getInitialLink(); final initial = await getInitialLink();
...@@ -57,22 +86,56 @@ class DeepLinkService { ...@@ -57,22 +86,56 @@ class DeepLinkService {
final cipherHex = uri.queryParameters['key']; final cipherHex = uri.queryParameters['key'];
if (cipherHex != null && cipherHex.isNotEmpty) { if (cipherHex != null && cipherHex.isNotEmpty) {
// Try multiple known secrets (match iOS CommonAPI.schemeCryptKey variants) // Try multiple known secrets (match iOS CommonAPI.schemeCryptKey variants)
const candidates = <String>[ const candidates = <String>['mypointdeeplinkk', 'PVt3FWQibsB7xaLx'];
'mypointdeeplinkk',
'PVt3FWQibsB7xaLx',
];
for (final secret in candidates) { for (final secret in candidates) {
final phone = mycrypto.Crypto(cipherHex: cipherHex, secretKey: secret).decryption(); final phone = mycrypto.Crypto(cipherHex: cipherHex, secretKey: secret).decryption().orEmpty;
if (phone != null && phone.isNotEmpty) { if (phone.isNotEmpty) {
if (kDebugMode) print('🔐 Decrypted phone from key: $phone'); if (kDebugMode) print('🔐 Decrypted phone from key: $phone');
break; // Use if you need to attach to userInfo later final direction = DirectionalScreen.buildByName(
name: DirectionalScreenName.linkMBPAccount,
clickActionParam: phone
);
direction?.extraData = {
'password': param,
};
direction?.begin();
return; // Use if you need to attach to userInfo later
} }
} }
} }
final screen = DirectionalScreen.build(clickActionType: type, clickActionParam: param); final screen = DirectionalScreen.build(clickActionType: type, clickActionParam: param);
screen?.begin(); screen?.begin();
} }
}
void _handleBranchSession(Map<dynamic, dynamic> data) {
if (kDebugMode) {
print('🌿 Branch session data: $data');
}
final dynamic clickedLink = data["+clicked_branch_link"];
if (clickedLink != true && clickedLink != "true") {
return;
}
final type = _stringOrNull(data[Defines.actionType]) ?? _stringOrNull(data['action_type']);
final param = _stringOrNull(data[Defines.actionParams]) ?? _stringOrNull(data['action_param']);
if (type != null) {
final screen = DirectionalScreen.build(clickActionType: type, clickActionParam: param);
if (screen != null) {
screen.begin();
return;
}
}
final fallbackLink =
_stringOrNull(data['~referring_link']) ?? _stringOrNull(data['+url']) ?? _stringOrNull(data['deeplink']);
if (fallbackLink != null) {
_routeFromUriString(fallbackLink);
}
}
String? _stringOrNull(dynamic value) {
if (value is String && value.isNotEmpty) return value;
return null;
}
}
...@@ -98,6 +98,7 @@ enum DirectionalScreenName { ...@@ -98,6 +98,7 @@ enum DirectionalScreenName {
unknown, unknown,
transactionHistories, transactionHistories,
qrCode, qrCode,
linkMBPAccount,
} }
extension DirectionalScreenRouterExtension on DirectionalScreenName { extension DirectionalScreenRouterExtension on DirectionalScreenName {
...@@ -320,6 +321,8 @@ extension DirectionalScreenNameExtension on DirectionalScreenName { ...@@ -320,6 +321,8 @@ extension DirectionalScreenNameExtension on DirectionalScreenName {
return "APP_SCREEN_TRANSACTION_HISTORIES"; return "APP_SCREEN_TRANSACTION_HISTORIES";
case DirectionalScreenName.qrCode: case DirectionalScreenName.qrCode:
return "APP_SCREEN_QR_CODE"; return "APP_SCREEN_QR_CODE";
case DirectionalScreenName.linkMBPAccount:
return "APP_SCREEN_LINK_MBP_ACCOUNT";
} }
} }
......
...@@ -11,10 +11,13 @@ import 'package:uuid/uuid.dart'; ...@@ -11,10 +11,13 @@ import 'package:uuid/uuid.dart';
import '../configs/constants.dart'; import '../configs/constants.dart';
import '../base/app_navigator.dart'; import '../base/app_navigator.dart';
import '../networking/restful_api_viewmodel.dart'; import '../networking/restful_api_viewmodel.dart';
import '../resources/base_color.dart';
import '../screen/pageDetail/model/detail_page_rule_type.dart'; import '../screen/pageDetail/model/detail_page_rule_type.dart';
import '../screen/pipi/pipi_detail_screen.dart'; import '../screen/pipi/pipi_detail_screen.dart';
import '../screen/webview/web_view_screen.dart'; import '../screen/webview/web_view_screen.dart';
import '../services/logout_service.dart';
import '../shared/router_gage.dart'; import '../shared/router_gage.dart';
import '../widgets/alert/data_alert_model.dart';
import 'directional_action_type.dart'; import 'directional_action_type.dart';
class Defines { class Defines {
...@@ -26,8 +29,9 @@ class DirectionalScreen { ...@@ -26,8 +29,9 @@ class DirectionalScreen {
final String? clickActionType; final String? clickActionType;
final String? clickActionParam; final String? clickActionParam;
final PopupDataModel? popup; final PopupDataModel? popup;
Map<String, dynamic>? extraData = {};
const DirectionalScreen._({this.clickActionType, this.clickActionParam, this.popup}); DirectionalScreen._({this.clickActionType, this.clickActionParam, this.popup});
factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._( factory DirectionalScreen.fromJson(Map<String, dynamic> json) => DirectionalScreen._(
clickActionType: json['click_action_type'] as String?, clickActionType: json['click_action_type'] as String?,
...@@ -307,11 +311,52 @@ class DirectionalScreen { ...@@ -307,11 +311,52 @@ class DirectionalScreen {
); );
}(); }();
return true; return true;
case DirectionalScreenName.linkMBPAccount:
if ((clickActionParam ?? '').isEmpty) return false;
_handleLinkMBPAccount();
return true;
default: default:
print("Không nhận diện được action type: $clickActionType"); print("Không nhận diện được action type: $clickActionType");
return false; return false;
} }
} }
void _handleLinkMBPAccount() {
final phone = clickActionParam.orEmpty;
if (phone.isEmpty) return;
final password = extraData?['password'] as String? ?? '';
if (!DataPreference.instance.logged) {
_gotoLoginScreen(phone, password);
return;
}
final currentPhone = DataPreference.instance.phone.orEmpty;
if (phone == currentPhone || currentPhone.isEmpty) return;
final dataAlert = DataAlertModel(
title: "Xác nhận",
description: "Bạn muốn đăng xuất để login tài khoản($phone) vừa liên kết không?",
localHeaderImage: "assets/images/ic_pipi_03.png",
buttons: [
AlertButton(
text: "Đồng ý",
onPressed: () async {
Get.back();
_gotoLoginScreen(phone, password);
print("Đồng ý đăng xuất để liên kết tài khoản $phone");
},
bgColor: BaseColor.primary500,
textColor: Colors.white,
),
AlertButton(text: "Huỷ", onPressed: () => Get.back(), bgColor: Colors.white, textColor: BaseColor.second500),
],
);
AppNavigator.showAlert(data: dataAlert, showCloseButton: false);
}
Future<void> _gotoLoginScreen(String phone, String password) async {
await LogoutService.logout();
await DataPreference.instance.clearData();
Get.offAllNamed(loginScreen, arguments: {"phone": phone, 'password': password});
}
} }
Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformDefault}) async { Future<bool> forceOpen({required Uri url, LaunchMode mode = LaunchMode.platformDefault}) async {
......
...@@ -30,9 +30,11 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState { ...@@ -30,9 +30,11 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
void initState() { void initState() {
super.initState(); super.initState();
final args = Get.arguments; final args = Get.arguments;
String autoPass = '';
if (args is Map) { if (args is Map) {
phoneNumber = args['phone']; phoneNumber = args['phone'];
fullName = args['fullName'] ?? 'Quý khách'; fullName = args['fullName'] ?? 'Quý khách';
autoPass = args['password'] ?? '';
} }
loginVM.onShowChangePass = (message) { loginVM.onShowChangePass = (message) {
Get.dialog( Get.dialog(
...@@ -101,6 +103,14 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState { ...@@ -101,6 +103,14 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
}); });
if (autoPass.isNotEmpty) {
_phoneController.text = autoPass;
loginVM.password.value = autoPass;
WidgetsBinding.instance.addPostFrameCallback((_) {
loginVM.onLoginPressed(phoneNumber);
});
}
} }
@override @override
...@@ -135,7 +145,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState { ...@@ -135,7 +145,7 @@ class _LoginScreenState extends BaseState<LoginScreen> with BasicState {
children: [ children: [
Text( Text(
"Đăng nhập", "Đăng nhập",
style: TextStyle(color: BaseColor.second600, fontSize: 24, fontWeight: FontWeight.bold), style: TextStyle(color: BaseColor.second600, fontSize: 30, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildWelcomeText(loginVM), _buildWelcomeText(loginVM),
......
...@@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; ...@@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../../base/base_screen.dart'; import '../../base/base_screen.dart';
import '../../base/basic_state.dart'; import '../../base/basic_state.dart';
import '../../directional/directional_screen.dart';
import '../../resources/base_color.dart'; import '../../resources/base_color.dart';
import '../../shared/router_gage.dart'; import '../../shared/router_gage.dart';
import '../../widgets/alert/data_alert_model.dart'; import '../../widgets/alert/data_alert_model.dart';
...@@ -46,6 +47,7 @@ enum PaymentProcess { ...@@ -46,6 +47,7 @@ enum PaymentProcess {
} }
} }
/// Data required to kick off a payment session.
class PaymentWebViewInput { class PaymentWebViewInput {
final String url; final String url;
final String orderId; final String orderId;
...@@ -69,9 +71,11 @@ class PaymentWebViewScreen extends BaseScreen { ...@@ -69,9 +71,11 @@ class PaymentWebViewScreen extends BaseScreen {
State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState(); State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
} }
/// Handles payment flows in an embedded WebView while listening for
/// provider callbacks and native schemes.
class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with BasicState { class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with BasicState {
late final PaymentWebViewInput input; late final PaymentWebViewInput input;
late final WebViewController _controller; WebViewController? _webViewController;
bool _isLoading = true; bool _isLoading = true;
final List<String> paymentSuccessUrls = [ final List<String> paymentSuccessUrls = [
...@@ -88,36 +92,49 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -88,36 +92,49 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (!_hydrateArguments()) return;
if (_handleWebPlatformLaunch()) return;
_initializeMobileWebView();
}
bool _hydrateArguments() {
final args = Get.arguments; final args = Get.arguments;
if (args is! PaymentWebViewInput) { if (args is PaymentWebViewInput) {
WidgetsBinding.instance.addPostFrameCallback((_) { input = args;
Get.back(); return true;
});
return;
} }
input = args; WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.key.currentState?.canPop() ?? false) {
// Web platform: mở URL trong tab mới và đóng màn hình ngay Get.back();
if (kIsWeb) { } else if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) async { Navigator.of(context).maybePop();
await _openUrlInBrowser(); }
if (mounted) { });
Get.back(); return false;
} }
});
return; bool _handleWebPlatformLaunch() {
} if (!kIsWeb) return false;
WidgetsBinding.instance.addPostFrameCallback((_) async {
// Mobile platform: khởi tạo WebView await _openUrlInBrowser();
_controller = if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return true;
}
void _initializeMobileWebView() {
_webViewController =
WebViewController() WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel( ..addJavaScriptChannel(
'MyPoint', 'MyPoint',
onMessageReceived: (JavaScriptMessage message) { onMessageReceived: (JavaScriptMessage message) {
final data = message.message; final data = message.message;
debugPrint('📩 JS Message: $data'); if (kDebugMode) {
// Expect JSON string with {"event":"payment_result","status":"success|failure"} debugPrint('📩 JS Message: $data');
}
if (data.contains('payment_result')) { if (data.contains('payment_result')) {
if (data.contains('success')) { if (data.contains('success')) {
_onPaymentResult(PaymentProcess.success); _onPaymentResult(PaymentProcess.success);
...@@ -129,20 +146,16 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -129,20 +146,16 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
) )
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( NavigationDelegate(
onPageStarted: (_) { onPageStarted: (_) => _setLoading(true),
setState(() { onPageFinished: (_) => _setLoading(false),
_isLoading = true;
});
},
onPageFinished: (_) {
setState(() {
_isLoading = false;
});
},
onNavigationRequest: _handleNavigation, onNavigationRequest: _handleNavigation,
onWebResourceError: (error) {
debugPrint('❌ WebView error: ${error.description}');
_onPaymentResult(PaymentProcess.failure);
},
), ),
) );
..loadRequest(Uri.parse(input.url)); _loadInitialPage();
} }
@override @override
...@@ -153,9 +166,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -153,9 +166,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
appBar: CustomNavigationBar( appBar: CustomNavigationBar(
title: "Thanh toán", title: "Thanh toán",
leftButtons: [ leftButtons: [
CustomBackButton( CustomBackButton(onPressed: () => Get.back()),
onPressed: () => Get.back(),
),
], ],
), ),
body: const Center( body: const Center(
...@@ -170,8 +181,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -170,8 +181,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
), ),
); );
} }
// Mobile platform: hiển thị WebView
return Scaffold( return Scaffold(
appBar: CustomNavigationBar( appBar: CustomNavigationBar(
title: "Thanh toán", title: "Thanh toán",
...@@ -190,7 +200,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -190,7 +200,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
body: Stack( body: Stack(
children: [ children: [
WebViewWidget( WebViewWidget(
controller: _controller, controller: _webViewController!,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{ gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{
Factory<VerticalDragGestureRecognizer>(VerticalDragGestureRecognizer.new), Factory<VerticalDragGestureRecognizer>(VerticalDragGestureRecognizer.new),
}, },
...@@ -201,9 +211,41 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -201,9 +211,41 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
); );
} }
void _loadInitialPage() {
final formatted = formatUrl(input.url);
final uri = Uri.tryParse(formatted);
if (uri == null) {
debugPrint('❌ Invalid payment URL: ${input.url}');
_onPaymentResult(PaymentProcess.failure);
return;
}
_webViewController?.loadRequest(uri);
}
void _setLoading(bool active) {
if (_isLoading == active) return;
if (!mounted) {
_isLoading = active;
return;
}
setState(() {
_isLoading = active;
});
}
String formatUrl(String rawUrl) {
if (rawUrl.isEmpty) return rawUrl;
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
return 'https://$rawUrl';
}
NavigationDecision _handleNavigation(NavigationRequest request) { NavigationDecision _handleNavigation(NavigationRequest request) {
final url = request.url; final url = request.url;
debugPrint("➡️ Navigating: $url"); if (kDebugMode) {
debugPrint("➡️ Navigating: $url");
}
if (paymentSuccessUrls.any((success) => url.startsWith(success))) { if (paymentSuccessUrls.any((success) => url.startsWith(success))) {
_onPaymentResult(PaymentProcess.success); _onPaymentResult(PaymentProcess.success);
return NavigationDecision.prevent; return NavigationDecision.prevent;
...@@ -219,6 +261,9 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -219,6 +261,9 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
launchUrl(uri, mode: LaunchMode.externalApplication); launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent; return NavigationDecision.prevent;
} }
if (kDebugMode) {
debugPrint("🔗 Handling URL scheme: ${uri?.scheme}");
}
// Xử lý chung mypointapp:// và các scheme ngoài http/https // Xử lý chung mypointapp:// và các scheme ngoài http/https
if (uri != null) { if (uri != null) {
// mypointapp://open?click_action_type=PAYMENT_SUCCESS|PAYMENT_FAIL // mypointapp://open?click_action_type=PAYMENT_SUCCESS|PAYMENT_FAIL
...@@ -232,9 +277,15 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -232,9 +277,15 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
_onPaymentResult(PaymentProcess.failure); _onPaymentResult(PaymentProcess.failure);
return NavigationDecision.prevent; return NavigationDecision.prevent;
} }
// Các action khác: cố mở ngoài ứng dụng final direction = DirectionalScreen.build(
launchUrl(uri, mode: LaunchMode.externalApplication); clickActionType: action,
return NavigationDecision.prevent; clickActionParam: uri.queryParameters['click_action_param'] ?? '',
);
final directionSuccess = direction?.begin();
if (directionSuccess != true) {
launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
} }
// Bất kỳ scheme không phải http/https: cố gắng mở ngoài // Bất kỳ scheme không phải http/https: cố gắng mở ngoài
if (uri.scheme.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https') { if (uri.scheme.isNotEmpty && uri.scheme != 'http' && uri.scheme != 'https') {
...@@ -267,7 +318,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -267,7 +318,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
/// Mở URL trong browser (web platform) /// Mở URL trong browser (web platform)
Future<void> _openUrlInBrowser() async { Future<void> _openUrlInBrowser() async {
try { try {
final uri = Uri.parse(input.url); final uri = Uri.parse(formatUrl(input.url));
await launchUrl( await launchUrl(
uri, uri,
mode: LaunchMode.externalApplication, mode: LaunchMode.externalApplication,
...@@ -277,7 +328,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -277,7 +328,7 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
// Fallback: mở trong tab hiện tại // Fallback: mở trong tab hiện tại
try { try {
await launchUrl( await launchUrl(
Uri.parse(input.url), Uri.parse(formatUrl(input.url)),
mode: LaunchMode.platformDefault, mode: LaunchMode.platformDefault,
); );
} catch (e2) { } catch (e2) {
...@@ -301,7 +352,10 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba ...@@ -301,7 +352,10 @@ class _PaymentWebViewScreenState extends BaseState<PaymentWebViewScreen> with Ba
AlertButton( AlertButton(
text: "Dừng thanh toán", text: "Dừng thanh toán",
onPressed: () { onPressed: () {
Get.offNamed(transactionHistoryDetailScreen, arguments: {"orderId": input.orderId ?? "", "canBack": false}); Get.offNamed(
transactionHistoryDetailScreen,
arguments: {"orderId": input.orderId, "canBack": false},
);
}, },
bgColor: Colors.white, bgColor: Colors.white,
textColor: BaseColor.second500, textColor: BaseColor.second500,
......
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:mypoint_flutter_app/widgets/back_button.dart'; import 'package:mypoint_flutter_app/widgets/back_button.dart';
import 'package:mypoint_flutter_app/widgets/custom_toast_message.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../base/app_loading.dart'; import '../../base/app_loading.dart';
import '../../base/base_screen.dart'; import '../../base/base_screen.dart';
import '../../base/basic_state.dart'; import '../../base/basic_state.dart';
...@@ -13,6 +18,7 @@ import '../../widgets/custom_navigation_bar.dart'; ...@@ -13,6 +18,7 @@ import '../../widgets/custom_navigation_bar.dart';
import '../../preference/data_preference.dart'; import '../../preference/data_preference.dart';
import '../../preference/package_info.dart'; import '../../preference/package_info.dart';
/// Payload for launching [BaseWebViewScreen].
class BaseWebViewInput { class BaseWebViewInput {
final String? title; final String? title;
final String url; final String url;
...@@ -32,10 +38,12 @@ class BaseWebViewScreen extends BaseScreen { ...@@ -32,10 +38,12 @@ class BaseWebViewScreen extends BaseScreen {
State<BaseWebViewScreen> createState() => _BaseWebViewScreenState(); State<BaseWebViewScreen> createState() => _BaseWebViewScreenState();
} }
/// Hosts a platform-aware WebView that mirrors native behaviour on iOS/Android
/// while delegating to the browser on Flutter web builds.
class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
with BasicState { with BasicState {
late final BaseWebViewInput input; late final BaseWebViewInput input;
WebViewController? _controller; // Nullable cho web platform WebViewController? _webViewController; // Null khi chạy Flutter web
String? _dynamicTitle; String? _dynamicTitle;
Map<String, String>? _authHeaders; Map<String, String>? _authHeaders;
bool _isReissuingNavigation = false; bool _isReissuingNavigation = false;
...@@ -43,32 +51,42 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -43,32 +51,42 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (!_hydrateArguments()) return;
if (_handleWebPlatformLaunch()) return;
_initializeMobileController();
}
bool _hydrateArguments() {
final args = Get.arguments; final args = Get.arguments;
if (args is BaseWebViewInput && args.url.isNotEmpty) { if (args is BaseWebViewInput && args.url.isNotEmpty) {
input = args; input = args;
} else { return true;
WidgetsBinding.instance.addPostFrameCallback((_) {
Get.back();
});
return;
}
// Web platform: mở URL trong tab mới và đóng màn hình ngay
if (kIsWeb) {
AppLoading().hide();
Future.microtask(() async {
await _openUrlInBrowser();
if (mounted) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
});
return;
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
if (Get.key.currentState?.canPop() ?? false) {
Get.back();
} else if (mounted) {
Navigator.of(context).maybePop();
}
});
return false;
}
// Mobile platform: khởi tạo WebView bool _handleWebPlatformLaunch() {
if (!kIsWeb) return false;
AppLoading().hide();
Future.microtask(() async {
await _openUrlInBrowser();
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return true;
}
void _initializeMobileController() {
AppLoading().show(); AppLoading().show();
_controller = _webViewController =
WebViewController() WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent) ..setBackgroundColor(Colors.transparent)
...@@ -76,7 +94,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -76,7 +94,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
NavigationDelegate( NavigationDelegate(
onPageFinished: (_) async { onPageFinished: (_) async {
AppLoading().hide(); AppLoading().hide();
final title = await _controller!.getTitle(); final title = await _webViewController!.getTitle();
setState(() { setState(() {
_dynamicTitle = title; _dynamicTitle = title;
}); });
...@@ -132,7 +150,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -132,7 +150,7 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
children: [ children: [
SafeArea( SafeArea(
child: WebViewWidget( child: WebViewWidget(
controller: _controller!, controller: _webViewController!,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{ gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{
Factory<VerticalDragGestureRecognizer>( Factory<VerticalDragGestureRecognizer>(
VerticalDragGestureRecognizer.new, VerticalDragGestureRecognizer.new,
...@@ -165,8 +183,8 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -165,8 +183,8 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
} }
// Mobile: kiểm tra WebView có thể go back không // Mobile: kiểm tra WebView có thể go back không
if (_controller != null && await _controller!.canGoBack()) { if (_webViewController != null && await _webViewController!.canGoBack()) {
_controller!.goBack(); _webViewController!.goBack();
} else { } else {
if (context.mounted) Navigator.of(context).pop(); if (context.mounted) Navigator.of(context).pop();
} }
...@@ -195,6 +213,10 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -195,6 +213,10 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
if (url.isEmpty || url == 'about:blank') { if (url.isEmpty || url == 'about:blank') {
return NavigationDecision.prevent; return NavigationDecision.prevent;
} }
if (_isDataImageUrl(url)) {
_processDataImageUrl(url);
return NavigationDecision.prevent;
}
if (url.startsWith('itms-apps://')) { if (url.startsWith('itms-apps://')) {
openStringUrlExternally(url); openStringUrlExternally(url);
return NavigationDecision.prevent; return NavigationDecision.prevent;
...@@ -204,13 +226,18 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -204,13 +226,18 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
launchUrl(uri); launchUrl(uri);
return NavigationDecision.prevent; return NavigationDecision.prevent;
} }
if (kDebugMode) {
print('🔗 Handling navigation to URL: $url');
}
if (_isReissuingNavigation) { if (_isReissuingNavigation) {
_isReissuingNavigation = false; _isReissuingNavigation = false;
return NavigationDecision.navigate; return NavigationDecision.navigate;
} }
if (_shouldAttachHeaders(url)) { if (_shouldAttachHeaders(url)) {
if (kDebugMode) {
print('🔄 Reissuing navigation with headers to URL: $url');
}
try { try {
final target = Uri.parse(url); final target = Uri.parse(url);
_loadWithHeaders(target); _loadWithHeaders(target);
...@@ -221,10 +248,36 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -221,10 +248,36 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
} }
} }
} }
final uri = Uri.tryParse(url);
if (uri != null) {
if (uri.scheme == 'mypointapp') {
final action = uri.queryParameters['click_action_type'] ?? '';
final direction = DirectionalScreen.build(
clickActionType: action,
clickActionParam: '',
);
final directionSuccess = direction?.begin();
if (directionSuccess != true) {
launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
}
// Bất kỳ scheme không phải http/https: cố gắng mở ngoài
if (uri.scheme.isNotEmpty &&
uri.scheme != 'http' &&
uri.scheme != 'https') {
launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
}
if (kDebugMode) {
print('✅ Allowing navigation to URL: $url');
}
return NavigationDecision.navigate; return NavigationDecision.navigate;
} }
/// Performs the first load by cleaning stale cookies, attaching auth headers,
/// and retrying without headers when the secure load fails (e.g., CORS).
Future<void> _prepareInitialLoad() async { Future<void> _prepareInitialLoad() async {
await _clearCookies(); await _clearCookies();
final formattedUrl = formatUrl(input.url); final formattedUrl = formatUrl(input.url);
...@@ -252,10 +305,11 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -252,10 +305,11 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
'WebView load with headers failed: $e. Retrying without headers.', 'WebView load with headers failed: $e. Retrying without headers.',
); );
} }
await _controller?.loadRequest(uri); await _webViewController?.loadRequest(uri);
} }
} }
/// Synchronises token cookies with the WebView so native and web share a session.
Future<void> _syncAuthCookie(Uri uri) async { Future<void> _syncAuthCookie(Uri uri) async {
if (!DataPreference.instance.logged) return; if (!DataPreference.instance.logged) return;
final token = DataPreference.instance.token?.trim(); final token = DataPreference.instance.token?.trim();
...@@ -283,21 +337,22 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -283,21 +337,22 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
'Cookie': 'access_token=$token' 'Cookie': 'access_token=$token'
}; };
} catch (_) { } catch (_) {
return { return {'access_token': token, 'Cookie': 'access_token=$token'};
'access_token': token,
'Cookie': 'access_token=$token'
};
} }
} }
Future<void> _loadWithHeaders(Uri uri) async { Future<void> _loadWithHeaders(Uri uri) async {
if (_authHeaders == null || _authHeaders!.isEmpty) { if (_authHeaders == null || _authHeaders!.isEmpty) {
await _controller?.loadRequest(uri); await _webViewController?.loadRequest(uri);
return; return;
} }
_isReissuingNavigation = true; _isReissuingNavigation = true;
print('➡️ Loading with headers: ${uri.toString()}, headers: $_authHeaders'); if (kDebugMode) {
await _controller?.loadRequest(uri, headers: _authHeaders!); print(
'➡️ Loading with headers: ${uri.toString()}, headers: $_authHeaders',
);
}
await _webViewController?.loadRequest(uri, headers: _authHeaders!);
} }
bool _shouldAttachHeaders(String url) { bool _shouldAttachHeaders(String url) {
...@@ -306,4 +361,77 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen> ...@@ -306,4 +361,77 @@ class _BaseWebViewScreenState extends BaseState<BaseWebViewScreen>
final lower = url.toLowerCase(); final lower = url.toLowerCase();
return lower.startsWith('http://') || lower.startsWith('https://'); return lower.startsWith('http://') || lower.startsWith('https://');
} }
bool _isDataImageUrl(String url) => url.startsWith('data:image');
void _processDataImageUrl(String url) {
Future.microtask(() => _saveBase64Image(url));
}
Future<void> _saveBase64Image(String url) async {
if (!mounted || kIsWeb) return;
final payload = _extractBase64Payload(url);
if (payload == null) {
_showSnack('Không thể đọc dữ liệu ảnh.');
return;
}
final hasPermission = await _ensureMediaPermission();
if (!hasPermission) {
_showSnack(
'Ứng dụng chưa có quyền lưu ảnh. Vui lòng cấp quyền trong cài đặt.',
);
return;
}
try {
final Uint8List bytes = base64Decode(payload);
final result = await ImageGallerySaver.saveImage(
bytes,
quality: 100,
name: 'mypoint_${DateTime.now().millisecondsSinceEpoch}',
);
final success =
(result['isSuccess'] ?? result['success'] ?? result['status']) ==
true;
_showSnack(
success ? 'Ảnh đã được lưu vào thư viện.' : 'Không thể lưu ảnh.',
);
} catch (e) {
if (kDebugMode) {
print('Failed to save base64 image: $e');
}
_showSnack('Không thể lưu ảnh.');
}
}
String? _extractBase64Payload(String url) {
final marker = 'base64,';
final index = url.indexOf(marker);
if (index == -1) return null;
return url.substring(index + marker.length).trim();
}
Future<bool> _ensureMediaPermission() async {
if (kIsWeb) return false;
PermissionStatus status;
if (defaultTargetPlatform == TargetPlatform.iOS) {
status = await Permission.photosAddOnly.request();
if (status.isGranted) return true;
status = await Permission.photos.request();
if (status.isGranted) return true;
} else {
status = await Permission.photos.request();
if (status.isGranted) return true;
status = await Permission.storage.request();
if (status.isGranted || status.isLimited) return true;
}
if (status.isPermanentlyDenied && kDebugMode) {
print('Media permission permanently denied.');
}
return false;
}
void _showSnack(String message) {
if (!mounted) return;
showToastMessage(message);
}
} }
...@@ -58,8 +58,10 @@ dependencies: ...@@ -58,8 +58,10 @@ dependencies:
permission_handler: ^12.0.1 permission_handler: ^12.0.1
share_plus: ^12.0.0 share_plus: ^12.0.0
file_saver: ^0.3.1 file_saver: ^0.3.1
flutter_branch_sdk: ^8.0.1
month_picker_dialog: month_picker_dialog:
marquee: ^2.2.3 marquee: ^2.2.3
image_gallery_saver: ^2.0.3
fl_chart: ^1.1.0 fl_chart: ^1.1.0
mobile_scanner: ^7.0.1 mobile_scanner: ^7.0.1
encrypt: ^5.0.1 encrypt: ^5.0.1
......
...@@ -85,6 +85,16 @@ ...@@ -85,6 +85,16 @@
try { mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['src'] }); } catch (e) {} try { mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['src'] }); } catch (e) {}
})(); })();
</script> </script>
<script>
(function(b,r,a,n,c,h,_,s,d,k){if(!b[n]||!b[n]._q){for(;s<_.length;)c(h,_[s++]);d=r.createElement(a);d.async=1;d.src="https://cdn.branch.io/branch-latest.min.js";k=r.getElementsByTagName(a)[0];k.parentNode.insertBefore(d,k);b[n]=h}})(window,document,"script","branch",function(b,r){b[r]=function(){b._q.push([r,arguments])}},{_q:[],_v:1},"addListener banner closeBanner closeJourney data deepview deepviewCta first init link logout removeListener setBranchViewData setIdentity track trackCommerceEvent logEvent disableTracking getBrowserFingerprintId crossPlatformIds lastAttributedTouchData setAPIResponseCallback qrCode setRequestMetaData setAPIUrl getAPIUrl setDMAParamsForEEA".split(" "),0);
(function(){
var host = (window.location && window.location.hostname || '').toLowerCase();
var isLocal = host === 'localhost' || host === '127.0.0.1';
var isTestDomain = host.indexOf('test-app.link') !== -1;
var branchKey = (isLocal || isTestDomain) ? 'key_test_mqEkGCao05wFFO4UwPw6GfglyzeZfuIV' : 'key_live_jzBfMtoh49vCAG0GzGrzHdoiFFh7oyKw';
try { branch.init(branchKey); } catch (e) { console.error('Branch init failed', e); }
})();
</script>
<script> <script>
(function () { (function () {
var textDecoder = typeof TextDecoder === 'function' ? new TextDecoder('utf-8') : null; var textDecoder = typeof TextDecoder === 'function' ? new TextDecoder('utf-8') : null;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment