Add Baron Safe app features through session block skeleton
ci / flutter-check (push) Successful in 4s

This commit is contained in:
2026-06-30 17:17:14 +09:00
parent d2f6af7657
commit 7c8e95e0a3
23 changed files with 1531 additions and 5 deletions
+17
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'features/home/home_screen.dart';
import 'features/session/session_screens.dart';
import 'features/webview/baron_safe_webview_screen.dart';
final _router = GoRouter(
@@ -16,6 +17,22 @@ final _router = GoRouter(
name: BaronSafeWebViewScreen.routeName,
builder: (context, state) => const BaronSafeWebViewScreen(),
),
GoRoute(
path: '/sessions',
name: SessionListScreen.routeName,
builder: (context, state) => const SessionListScreen(),
routes: [
GoRoute(
path: ':sessionId',
name: SessionDetailScreen.routeName,
builder: (context, state) {
return SessionDetailScreen(
sessionId: state.pathParameters['sessionId']!,
);
},
),
],
),
],
);
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'baron_safe_bridge_command.dart';
import 'baron_safe_bridge_models.dart';
import 'baron_safe_bridge_service.dart';
class BaronSafeBridgeMessageHandler {
const BaronSafeBridgeMessageHandler(this._bridgeService);
final BaronSafeBridgeService _bridgeService;
Future<String> handle(String rawMessage) async {
final decoded = _decode(rawMessage);
if (decoded == null) {
return _failure(
requestId: null,
code: 'invalid_json',
message: 'Bridge message must be a JSON object.',
);
}
final requestId = decoded['requestId'] as String?;
final commandName = decoded['command'] as String?;
if (commandName == null) {
return _failure(
requestId: requestId,
code: 'missing_command',
message: 'Bridge command is required.',
);
}
final command = BaronSafeBridgeCommand.tryParse(commandName);
if (command == null) {
return _failure(
requestId: requestId,
code: 'unsupported_command',
message: '$commandName is not an allowed bridge command.',
);
}
final payload = decoded['payload'];
if (payload != null && payload is! Map<String, Object?>) {
return _failure(
requestId: requestId,
code: 'invalid_payload',
message: 'Bridge payload must be an object.',
);
}
final response = await _bridgeService.handle(
BaronSafeBridgeRequest(
command: command,
payload: payload as Map<String, Object?>? ?? const <String, Object?>{},
),
);
if (!response.isSuccess) {
return _failure(
requestId: requestId,
code: response.error!.code,
message: response.error!.message,
);
}
return jsonEncode({
'requestId': requestId,
'ok': true,
'data': response.data,
});
}
Map<String, Object?>? _decode(String rawMessage) {
try {
final decoded = jsonDecode(rawMessage);
if (decoded is Map<String, Object?>) {
return decoded;
}
return null;
} on FormatException {
return null;
}
}
String _failure({
required String? requestId,
required String code,
required String message,
}) {
return jsonEncode({
'requestId': requestId,
'ok': false,
'error': {'code': code, 'message': message},
});
}
}
@@ -1,6 +1,7 @@
import 'baron_safe_bridge_command.dart';
import 'baron_safe_bridge_models.dart';
import '../push/baron_safe_push_service.dart';
import '../session/baron_safe_session_block_service.dart';
abstract interface class BaronSafeBridgeService {
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request);
@@ -19,11 +20,13 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
this.deviceInfoProvider = const PlaceholderBaronSafeDeviceInfoProvider(),
this.biometricPrompt = const PlaceholderBaronSafeBiometricPrompt(),
this.pushService = const PlaceholderBaronSafePushService(),
this.sessionBlockService,
});
final BaronSafeDeviceInfoProvider deviceInfoProvider;
final BaronSafeBiometricPrompt biometricPrompt;
final BaronSafePushService pushService;
final BaronSafeSessionBlockService? sessionBlockService;
@override
Future<BaronSafeBridgeResponse> handle(BaronSafeBridgeRequest request) async {
@@ -33,7 +36,7 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
request.payload,
),
BaronSafeBridgeCommand.registerPushToken => _registerPushToken(),
BaronSafeBridgeCommand.blockSession => _notImplemented(request.command),
BaronSafeBridgeCommand.blockSession => _blockSession(request.payload),
BaronSafeBridgeCommand.blockLinkedApp => _notImplemented(request.command),
BaronSafeBridgeCommand.openSettings => _notImplemented(request.command),
};
@@ -67,6 +70,31 @@ class PlaceholderBaronSafeBridgeService implements BaronSafeBridgeService {
final result = await pushService.registerCurrentToken();
return BaronSafeBridgeResponse.success(data: result.toJson());
}
Future<BaronSafeBridgeResponse> _blockSession(
Map<String, Object?> payload,
) async {
final service = sessionBlockService;
if (service == null) {
return _notImplemented(BaronSafeBridgeCommand.blockSession);
}
final sessionId = payload['sessionId'] as String?;
if (sessionId == null || sessionId.isEmpty) {
return const BaronSafeBridgeResponse.failure(
BaronSafeBridgeError(
code: 'invalid_payload',
message: 'blockSession requires a sessionId.',
),
);
}
final result = await service.blockSession(
sessionId: sessionId,
reason: payload['reason'] as String? ?? 'Confirm session block',
);
return BaronSafeBridgeResponse.success(data: result.toJson());
}
}
class PlaceholderBaronSafeDeviceInfoProvider
@@ -0,0 +1,34 @@
import '../push/baron_safe_push_models.dart';
class BaronSafeDeviceRegistrationRequest {
const BaronSafeDeviceRegistrationRequest({
required this.deviceId,
required this.platform,
required this.appVersion,
this.pushToken,
});
final String deviceId;
final BaronSafePushPlatform platform;
final String appVersion;
final String? pushToken;
Map<String, Object?> toJson() {
return {
'deviceId': deviceId,
'platform': platform.value,
'appVersion': appVersion,
'pushToken': pushToken,
};
}
}
class BaronSafeDeviceRegistrationResult {
const BaronSafeDeviceRegistrationResult({
required this.deviceId,
required this.registered,
});
final String deviceId;
final bool registered;
}
@@ -0,0 +1,22 @@
import 'baron_safe_device_models.dart';
abstract interface class BaronSafeDeviceRegistrationClient {
Future<BaronSafeDeviceRegistrationResult> registerDevice(
BaronSafeDeviceRegistrationRequest request,
);
}
class NoopBaronSafeDeviceRegistrationClient
implements BaronSafeDeviceRegistrationClient {
const NoopBaronSafeDeviceRegistrationClient();
@override
Future<BaronSafeDeviceRegistrationResult> registerDevice(
BaronSafeDeviceRegistrationRequest request,
) async {
return BaronSafeDeviceRegistrationResult(
deviceId: request.deviceId,
registered: true,
);
}
}
@@ -0,0 +1,60 @@
import '../push/baron_safe_push_models.dart';
import '../push/baron_safe_push_service.dart';
import '../secure_storage/baron_safe_secure_storage_service.dart';
import 'baron_safe_device_models.dart';
import 'baron_safe_device_registration_client.dart';
typedef BaronSafeDeviceIdGenerator = String Function();
class BaronSafeDeviceRegistrationService {
const BaronSafeDeviceRegistrationService({
required BaronSafeSecureStorageService secureStorage,
required BaronSafeDeviceRegistrationClient registrationClient,
BaronSafePushTokenProvider pushTokenProvider =
const PlaceholderBaronSafePushTokenProvider(),
BaronSafeDeviceIdGenerator deviceIdGenerator = _defaultDeviceIdGenerator,
BaronSafePushPlatform platform = BaronSafePushPlatform.unknown,
String appVersion = '0.1.0',
}) : _secureStorage = secureStorage,
_registrationClient = registrationClient,
_pushTokenProvider = pushTokenProvider,
_deviceIdGenerator = deviceIdGenerator,
_platform = platform,
_appVersion = appVersion;
final BaronSafeSecureStorageService _secureStorage;
final BaronSafeDeviceRegistrationClient _registrationClient;
final BaronSafePushTokenProvider _pushTokenProvider;
final BaronSafeDeviceIdGenerator _deviceIdGenerator;
final BaronSafePushPlatform _platform;
final String _appVersion;
Future<BaronSafeDeviceRegistrationResult> registerCurrentDevice() async {
final deviceId = await _readOrCreateDeviceId();
final pushToken = await _pushTokenProvider.readCurrentToken();
return _registrationClient.registerDevice(
BaronSafeDeviceRegistrationRequest(
deviceId: deviceId,
platform: pushToken?.platform ?? _platform,
appVersion: pushToken?.appVersion ?? _appVersion,
pushToken: pushToken?.token,
),
);
}
Future<String> _readOrCreateDeviceId() async {
final existingDeviceId = await _secureStorage.readDeviceId();
if (existingDeviceId != null && existingDeviceId.isNotEmpty) {
return existingDeviceId;
}
final newDeviceId = _deviceIdGenerator();
await _secureStorage.saveDeviceId(newDeviceId);
return newDeviceId;
}
}
String _defaultDeviceIdGenerator() {
return 'baron-safe-${DateTime.now().microsecondsSinceEpoch}';
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../session/session_screens.dart';
import '../webview/baron_safe_webview_screen.dart';
class HomeScreen extends StatelessWidget {
@@ -38,6 +39,12 @@ class HomeScreen extends StatelessWidget {
subtitle: 'Route container placeholder',
onTap: () => context.goNamed(BaronSafeWebViewScreen.routeName),
),
_HomeActionTile(
icon: Icons.history,
title: 'Recent sessions',
subtitle: 'Mock session list and detail',
onTap: () => context.goNamed(SessionListScreen.routeName),
),
const _HomeActionTile(
icon: Icons.fingerprint,
title: 'Biometric bridge',
@@ -0,0 +1,50 @@
import '../bridge/baron_safe_bridge_service.dart';
import 'baron_safe_session_models.dart';
import 'baron_safe_session_repository.dart';
abstract interface class BaronSafeSessionBlockService {
Future<BaronSafeSessionBlockResult> blockSession({
required String sessionId,
required String reason,
});
}
class BiometricBaronSafeSessionBlockService
implements BaronSafeSessionBlockService {
const BiometricBaronSafeSessionBlockService({
required BaronSafeSessionRepository repository,
required BaronSafeBiometricPrompt biometricPrompt,
}) : _repository = repository,
_biometricPrompt = biometricPrompt;
final BaronSafeSessionRepository _repository;
final BaronSafeBiometricPrompt _biometricPrompt;
@override
Future<BaronSafeSessionBlockResult> blockSession({
required String sessionId,
required String reason,
}) async {
final biometricResult = await _biometricPrompt.verify(reason: reason);
if (!biometricResult.verified) {
return BaronSafeSessionBlockResult(
sessionId: sessionId,
blocked: false,
biometricVerified: false,
message: biometricResult.reason,
);
}
final response = await _repository.blockSession(
BaronSafeSessionBlockRequest(sessionId: sessionId, reason: reason),
);
return BaronSafeSessionBlockResult(
sessionId: response.sessionId,
blocked: response.blocked,
biometricVerified: true,
message: response.blocked ? 'Session blocked' : 'Session block failed',
blockedAt: response.blockedAt,
);
}
}
@@ -0,0 +1,81 @@
import 'baron_safe_session_models.dart';
abstract interface class BaronSafeSessionClient {
Future<List<BaronSafeSessionSummary>> listSessions();
Future<BaronSafeSessionDetail> getSession(String sessionId);
Future<BaronSafeSessionBlockResponse> blockSession(
BaronSafeSessionBlockRequest request,
);
}
class MockBaronSafeSessionClient implements BaronSafeSessionClient {
MockBaronSafeSessionClient({List<BaronSafeSessionDetail>? sessions})
: _sessions = sessions ?? _defaultSessions;
final List<BaronSafeSessionDetail> _sessions;
@override
Future<List<BaronSafeSessionSummary>> listSessions() async {
return List<BaronSafeSessionSummary>.unmodifiable(_sessions);
}
@override
Future<BaronSafeSessionDetail> getSession(String sessionId) async {
return _sessions.firstWhere(
(session) => session.id == sessionId,
orElse: () => throw BaronSafeSessionNotFoundException(sessionId),
);
}
@override
Future<BaronSafeSessionBlockResponse> blockSession(
BaronSafeSessionBlockRequest request,
) async {
await getSession(request.sessionId);
return BaronSafeSessionBlockResponse(
sessionId: request.sessionId,
blocked: true,
blockedAt: DateTime.utc(2026, 6, 30, 10),
);
}
}
class BaronSafeSessionNotFoundException implements Exception {
const BaronSafeSessionNotFoundException(this.sessionId);
final String sessionId;
@override
String toString() {
return 'BaronSafeSessionNotFoundException: $sessionId';
}
}
final _defaultSessions = [
BaronSafeSessionDetail(
id: 'session-1',
serviceName: 'Baron Portal',
status: BaronSafeSessionStatus.active,
riskLevel: BaronSafeSessionRiskLevel.low,
createdAt: DateTime.utc(2026, 6, 30, 9),
ipAddress: '203.0.113.10',
deviceLabel: 'Chrome on Windows',
locationLabel: 'Seoul',
userAgent: 'Chrome',
authMethod: 'phone',
),
BaronSafeSessionDetail(
id: 'session-2',
serviceName: 'Admin Console',
status: BaronSafeSessionStatus.active,
riskLevel: BaronSafeSessionRiskLevel.high,
createdAt: DateTime.utc(2026, 6, 30, 9, 15),
ipAddress: '198.51.100.22',
deviceLabel: 'Unknown browser',
locationLabel: 'Unknown',
userAgent: 'Unknown',
authMethod: 'phone',
),
];
@@ -0,0 +1,188 @@
enum BaronSafeSessionStatus {
active('active'),
blocked('blocked'),
expired('expired'),
unknown('unknown');
const BaronSafeSessionStatus(this.value);
final String value;
static BaronSafeSessionStatus fromValue(String value) {
return values.firstWhere(
(status) => status.value == value,
orElse: () => unknown,
);
}
}
enum BaronSafeSessionRiskLevel {
low('low'),
medium('medium'),
high('high'),
unknown('unknown');
const BaronSafeSessionRiskLevel(this.value);
final String value;
static BaronSafeSessionRiskLevel fromValue(String value) {
return values.firstWhere(
(riskLevel) => riskLevel.value == value,
orElse: () => unknown,
);
}
}
class BaronSafeSessionSummary {
const BaronSafeSessionSummary({
required this.id,
required this.serviceName,
required this.status,
required this.riskLevel,
required this.createdAt,
this.ipAddress,
this.deviceLabel,
});
final String id;
final String serviceName;
final BaronSafeSessionStatus status;
final BaronSafeSessionRiskLevel riskLevel;
final DateTime createdAt;
final String? ipAddress;
final String? deviceLabel;
factory BaronSafeSessionSummary.fromJson(Map<String, Object?> json) {
return BaronSafeSessionSummary(
id: json['id'] as String,
serviceName: json['serviceName'] as String? ?? 'Unknown service',
status: BaronSafeSessionStatus.fromValue(
json['status'] as String? ?? 'unknown',
),
riskLevel: BaronSafeSessionRiskLevel.fromValue(
json['riskLevel'] as String? ?? 'unknown',
),
createdAt: DateTime.parse(json['createdAt'] as String),
ipAddress: json['ipAddress'] as String?,
deviceLabel: json['deviceLabel'] as String?,
);
}
Map<String, Object?> toJson() {
return {
'id': id,
'serviceName': serviceName,
'status': status.value,
'riskLevel': riskLevel.value,
'createdAt': createdAt.toIso8601String(),
'ipAddress': ipAddress,
'deviceLabel': deviceLabel,
};
}
}
class BaronSafeSessionDetail extends BaronSafeSessionSummary {
const BaronSafeSessionDetail({
required super.id,
required super.serviceName,
required super.status,
required super.riskLevel,
required super.createdAt,
super.ipAddress,
super.deviceLabel,
this.locationLabel,
this.userAgent,
this.authMethod,
});
final String? locationLabel;
final String? userAgent;
final String? authMethod;
factory BaronSafeSessionDetail.fromJson(Map<String, Object?> json) {
final summary = BaronSafeSessionSummary.fromJson(json);
return BaronSafeSessionDetail(
id: summary.id,
serviceName: summary.serviceName,
status: summary.status,
riskLevel: summary.riskLevel,
createdAt: summary.createdAt,
ipAddress: summary.ipAddress,
deviceLabel: summary.deviceLabel,
locationLabel: json['locationLabel'] as String?,
userAgent: json['userAgent'] as String?,
authMethod: json['authMethod'] as String?,
);
}
@override
Map<String, Object?> toJson() {
return {
...super.toJson(),
'locationLabel': locationLabel,
'userAgent': userAgent,
'authMethod': authMethod,
};
}
}
class BaronSafeSessionBlockRequest {
const BaronSafeSessionBlockRequest({
required this.sessionId,
required this.reason,
});
final String sessionId;
final String reason;
Map<String, Object?> toJson() {
return {'sessionId': sessionId, 'reason': reason};
}
}
class BaronSafeSessionBlockResponse {
const BaronSafeSessionBlockResponse({
required this.sessionId,
required this.blocked,
this.blockedAt,
});
final String sessionId;
final bool blocked;
final DateTime? blockedAt;
Map<String, Object?> toJson() {
return {
'sessionId': sessionId,
'blocked': blocked,
'blockedAt': blockedAt?.toIso8601String(),
};
}
}
class BaronSafeSessionBlockResult {
const BaronSafeSessionBlockResult({
required this.sessionId,
required this.blocked,
required this.biometricVerified,
required this.message,
this.blockedAt,
});
final String sessionId;
final bool blocked;
final bool biometricVerified;
final String message;
final DateTime? blockedAt;
Map<String, Object?> toJson() {
return {
'sessionId': sessionId,
'blocked': blocked,
'biometricVerified': biometricVerified,
'message': message,
'blockedAt': blockedAt?.toIso8601String(),
};
}
}
@@ -0,0 +1,35 @@
import 'baron_safe_session_client.dart';
import 'baron_safe_session_models.dart';
abstract interface class BaronSafeSessionRepository {
Future<List<BaronSafeSessionSummary>> listRecentSessions();
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId);
Future<BaronSafeSessionBlockResponse> blockSession(
BaronSafeSessionBlockRequest request,
);
}
class ClientBaronSafeSessionRepository implements BaronSafeSessionRepository {
const ClientBaronSafeSessionRepository(this._client);
final BaronSafeSessionClient _client;
@override
Future<List<BaronSafeSessionSummary>> listRecentSessions() {
return _client.listSessions();
}
@override
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId) {
return _client.getSession(sessionId);
}
@override
Future<BaronSafeSessionBlockResponse> blockSession(
BaronSafeSessionBlockRequest request,
) {
return _client.blockSession(request);
}
}
@@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../bridge/baron_safe_bridge_service.dart';
import 'baron_safe_session_block_service.dart';
import 'baron_safe_session_client.dart';
import 'baron_safe_session_models.dart';
import 'baron_safe_session_repository.dart';
final _sessionRepository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
final _sessionBlockService = BiometricBaronSafeSessionBlockService(
repository: _sessionRepository,
biometricPrompt: const PlaceholderBaronSafeBiometricPrompt(),
);
class SessionListScreen extends StatelessWidget {
const SessionListScreen({super.key});
static const routeName = 'sessions';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Recent sessions')),
body: SafeArea(
child: FutureBuilder<List<BaronSafeSessionSummary>>(
future: _sessionRepository.listRecentSessions(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final sessions = snapshot.data ?? const <BaronSafeSessionSummary>[];
if (sessions.isEmpty) {
return const Center(child: Text('No recent sessions'));
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final session = sessions[index];
return _SessionSummaryTile(session: session);
},
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemCount: sessions.length,
);
},
),
),
);
}
}
class SessionDetailScreen extends StatefulWidget {
const SessionDetailScreen({required this.sessionId, super.key});
static const routeName = 'session-detail';
final String sessionId;
@override
State<SessionDetailScreen> createState() => _SessionDetailScreenState();
}
class _SessionDetailScreenState extends State<SessionDetailScreen> {
bool _isBlocking = false;
Future<void> _blockSession(BaronSafeSessionDetail session) async {
setState(() {
_isBlocking = true;
});
try {
final result = await _sessionBlockService.blockSession(
sessionId: session.id,
reason: 'Confirm blocking ${session.serviceName}',
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(result.message)));
} finally {
if (mounted) {
setState(() {
_isBlocking = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Session detail')),
body: SafeArea(
child: FutureBuilder<BaronSafeSessionDetail>(
future: _sessionRepository.getSessionDetail(widget.sessionId),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError || !snapshot.hasData) {
return const Center(child: Text('Session not found'));
}
final session = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(20),
children: [
Text(
session.serviceName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_StatusRow(
status: session.status,
riskLevel: session.riskLevel,
),
const SizedBox(height: 24),
_DetailItem(label: 'Session ID', value: session.id),
_DetailItem(
label: 'Created',
value: session.createdAt.toUtc().toIso8601String(),
),
_DetailItem(label: 'IP address', value: session.ipAddress),
_DetailItem(label: 'Device', value: session.deviceLabel),
_DetailItem(label: 'Location', value: session.locationLabel),
_DetailItem(label: 'User agent', value: session.userAgent),
_DetailItem(label: 'Auth method', value: session.authMethod),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _isBlocking ? null : () => _blockSession(session),
icon: const Icon(Icons.block),
label: Text(_isBlocking ? 'Blocking...' : 'Block session'),
),
],
);
},
),
),
);
}
}
class _SessionSummaryTile extends StatelessWidget {
const _SessionSummaryTile({required this.session});
final BaronSafeSessionSummary session;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
title: Text(session.serviceName),
subtitle: Text(
[
session.deviceLabel,
session.ipAddress,
].whereType<String>().join(' · '),
),
leading: Icon(
_riskIcon(session.riskLevel),
color: _riskColor(colorScheme, session.riskLevel),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.goNamed(
SessionDetailScreen.routeName,
pathParameters: {'sessionId': session.id},
),
),
);
}
}
class _StatusRow extends StatelessWidget {
const _StatusRow({required this.status, required this.riskLevel});
final BaronSafeSessionStatus status;
final BaronSafeSessionRiskLevel riskLevel;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('Status: ${status.value}')),
Chip(label: Text('Risk: ${riskLevel.value}')),
],
);
}
}
class _DetailItem extends StatelessWidget {
const _DetailItem({required this.label, required this.value});
final String label;
final String? value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(value == null || value!.isEmpty ? '-' : value!),
],
),
);
}
}
IconData _riskIcon(BaronSafeSessionRiskLevel riskLevel) {
return switch (riskLevel) {
BaronSafeSessionRiskLevel.high => Icons.warning_amber,
BaronSafeSessionRiskLevel.medium => Icons.info_outline,
BaronSafeSessionRiskLevel.low => Icons.check_circle_outline,
BaronSafeSessionRiskLevel.unknown => Icons.help_outline,
};
}
Color _riskColor(ColorScheme colorScheme, BaronSafeSessionRiskLevel riskLevel) {
return switch (riskLevel) {
BaronSafeSessionRiskLevel.high => colorScheme.error,
BaronSafeSessionRiskLevel.medium => colorScheme.tertiary,
BaronSafeSessionRiskLevel.low => colorScheme.primary,
BaronSafeSessionRiskLevel.unknown => colorScheme.onSurfaceVariant,
};
}
@@ -0,0 +1,36 @@
class BaronSafeWebViewPolicy {
const BaronSafeWebViewPolicy({this.allowedHosts = defaultAllowedHosts});
static const defaultAllowedHosts = {
'safe.baron.hmac.kr',
'safe-staging.baron.hmac.kr',
'localhost',
'127.0.0.1',
'10.0.2.2',
};
final Set<String> allowedHosts;
bool allows(Uri uri) {
if (!uri.hasScheme || !uri.hasAuthority) {
return false;
}
if (uri.scheme != 'https' && uri.scheme != 'http') {
return false;
}
if (uri.scheme == 'http' && !_isLocalHost(uri.host)) {
return false;
}
return allowedHosts.contains(uri.host.toLowerCase());
}
bool _isLocalHost(String host) {
final normalizedHost = host.toLowerCase();
return normalizedHost == 'localhost' ||
normalizedHost == '127.0.0.1' ||
normalizedHost == '10.0.2.2';
}
}
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../bridge/baron_safe_bridge_message_handler.dart';
import '../bridge/baron_safe_bridge_service.dart';
import 'baron_safe_web_config.dart';
import 'baron_safe_webview_policy.dart';
class BaronSafeWebViewScreen extends StatefulWidget {
const BaronSafeWebViewScreen({super.key});
@@ -14,8 +17,13 @@ class BaronSafeWebViewScreen extends StatefulWidget {
class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
late final WebViewController _controller;
final _policy = const BaronSafeWebViewPolicy();
final _bridgeMessageHandler = const BaronSafeBridgeMessageHandler(
PlaceholderBaronSafeBridgeService(),
);
var _loadingProgress = 0;
WebResourceError? _lastError;
Uri? _blockedUri;
@override
void initState() {
@@ -23,14 +31,30 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'BaronSafeBridge',
onMessageReceived: (message) {
_handleBridgeMessage(message.message);
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (_) {
setState(() {
_loadingProgress = 0;
_lastError = null;
_blockedUri = null;
});
},
onNavigationRequest: (request) {
final uri = Uri.tryParse(request.url);
if (uri == null || !_policy.allows(uri)) {
setState(() => _blockedUri = uri);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onProgress: (progress) {
setState(() => _loadingProgress = progress);
},
@@ -45,6 +69,14 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
..loadRequest(Uri.parse(BaronSafeWebConfig.initialUrl));
}
Future<void> _handleBridgeMessage(String message) async {
final response = await _bridgeMessageHandler.handle(message);
await _controller.runJavaScript(
'window.dispatchEvent(new CustomEvent("BaronSafeBridgeResponse", '
'{ detail: $response }));',
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -69,6 +101,11 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
description: _lastError!.description,
onRetry: _controller.reload,
),
if (_blockedUri != null)
_BlockedNavigationState(
uri: _blockedUri!,
onDismiss: () => setState(() => _blockedUri = null),
),
],
),
),
@@ -76,6 +113,57 @@ class _BaronSafeWebViewScreenState extends State<BaronSafeWebViewScreen> {
}
}
class _BlockedNavigationState extends StatelessWidget {
const _BlockedNavigationState({required this.uri, required this.onDismiss});
final Uri uri;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ColoredBox(
color: colorScheme.surface,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.gpp_maybe_outlined,
size: 40,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Blocked navigation',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
uri.toString(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.check),
label: const Text('OK'),
),
],
),
),
),
);
}
}
class _WebViewErrorState extends StatelessWidget {
const _WebViewErrorState({required this.description, required this.onRetry});
@@ -0,0 +1,63 @@
import 'dart:convert';
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_message_handler.dart';
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BaronSafeBridgeMessageHandler', () {
const handler = BaronSafeBridgeMessageHandler(
PlaceholderBaronSafeBridgeService(),
);
test('handles an allowed command and returns response envelope', () async {
final rawResponse = await handler.handle(
jsonEncode({'requestId': 'req-1', 'command': 'getDeviceInfo'}),
);
final response = jsonDecode(rawResponse) as Map<String, Object?>;
expect(response['requestId'], 'req-1');
expect(response['ok'], isTrue);
expect(response['data'], isA<Map<String, Object?>>());
});
test('rejects unknown commands before service handling', () async {
final rawResponse = await handler.handle(
jsonEncode({'requestId': 'req-2', 'command': 'stealPrivateKey'}),
);
final response = jsonDecode(rawResponse) as Map<String, Object?>;
final error = response['error'] as Map<String, Object?>;
expect(response['ok'], isFalse);
expect(error['code'], 'unsupported_command');
});
test('rejects invalid JSON messages', () async {
final rawResponse = await handler.handle('not-json');
final response = jsonDecode(rawResponse) as Map<String, Object?>;
final error = response['error'] as Map<String, Object?>;
expect(response['ok'], isFalse);
expect(error['code'], 'invalid_json');
});
test('rejects non-object payloads', () async {
final rawResponse = await handler.handle(
jsonEncode({
'requestId': 'req-3',
'command': 'openBiometricPrompt',
'payload': 'bad-payload',
}),
);
final response = jsonDecode(rawResponse) as Map<String, Object?>;
final error = response['error'] as Map<String, Object?>;
expect(response['ok'], isFalse);
expect(error['code'], 'invalid_payload');
});
});
}
@@ -1,6 +1,8 @@
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_command.dart';
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_models.dart';
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_block_service.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@@ -77,5 +79,55 @@ void main() {
expect(response.isSuccess, isFalse);
expect(response.error?.code, 'not_implemented');
});
test('rejects blockSession when sessionId is missing', () async {
const service = PlaceholderBaronSafeBridgeService(
sessionBlockService: _FakeSessionBlockService(),
);
final response = await service.handle(
const BaronSafeBridgeRequest(
command: BaronSafeBridgeCommand.blockSession,
),
);
expect(response.isSuccess, isFalse);
expect(response.error?.code, 'invalid_payload');
});
test('runs injected session block service', () async {
const service = PlaceholderBaronSafeBridgeService(
sessionBlockService: _FakeSessionBlockService(),
);
final response = await service.handle(
const BaronSafeBridgeRequest(
command: BaronSafeBridgeCommand.blockSession,
payload: {'sessionId': 'session-1', 'reason': 'Suspicious login'},
),
);
expect(response.isSuccess, isTrue);
expect(response.data['sessionId'], 'session-1');
expect(response.data['blocked'], isTrue);
expect(response.data['biometricVerified'], isTrue);
});
});
}
class _FakeSessionBlockService implements BaronSafeSessionBlockService {
const _FakeSessionBlockService();
@override
Future<BaronSafeSessionBlockResult> blockSession({
required String sessionId,
required String reason,
}) async {
return BaronSafeSessionBlockResult(
sessionId: sessionId,
blocked: true,
biometricVerified: true,
message: reason,
);
}
}
@@ -0,0 +1,87 @@
import 'package:baron_safe_app/src/features/device/baron_safe_device_models.dart';
import 'package:baron_safe_app/src/features/device/baron_safe_device_registration_client.dart';
import 'package:baron_safe_app/src/features/device/baron_safe_device_registration_service.dart';
import 'package:baron_safe_app/src/features/push/baron_safe_push_models.dart';
import 'package:baron_safe_app/src/features/push/baron_safe_push_service.dart';
import 'package:baron_safe_app/src/features/secure_storage/baron_safe_secure_storage.dart';
import 'package:baron_safe_app/src/features/secure_storage/baron_safe_secure_storage_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BaronSafeDeviceRegistrationService', () {
test('creates, stores, and registers a new device id', () async {
final secureStorage = DefaultBaronSafeSecureStorageService(
MemoryBaronSafeKeyValueStore(),
);
final client = _RecordingDeviceRegistrationClient();
final service = BaronSafeDeviceRegistrationService(
secureStorage: secureStorage,
registrationClient: client,
pushTokenProvider: const _FixedPushTokenProvider(
BaronSafePushToken(
token: 'push-token-1',
platform: BaronSafePushPlatform.android,
deviceId: 'ignored-device-id',
appVersion: '0.1.0',
),
),
deviceIdGenerator: () => 'device-1',
);
final result = await service.registerCurrentDevice();
expect(result.registered, isTrue);
expect(result.deviceId, 'device-1');
expect(await secureStorage.readDeviceId(), 'device-1');
expect(client.lastRequest?.pushToken, 'push-token-1');
expect(client.lastRequest?.platform, BaronSafePushPlatform.android);
});
test('reuses an existing device id', () async {
final secureStorage = DefaultBaronSafeSecureStorageService(
MemoryBaronSafeKeyValueStore(),
);
await secureStorage.saveDeviceId('existing-device');
final client = _RecordingDeviceRegistrationClient();
final service = BaronSafeDeviceRegistrationService(
secureStorage: secureStorage,
registrationClient: client,
pushTokenProvider: const _FixedPushTokenProvider(null),
deviceIdGenerator: () => 'new-device',
);
final result = await service.registerCurrentDevice();
expect(result.deviceId, 'existing-device');
expect(client.lastRequest?.deviceId, 'existing-device');
expect(client.lastRequest?.pushToken, isNull);
});
});
}
class _RecordingDeviceRegistrationClient
implements BaronSafeDeviceRegistrationClient {
BaronSafeDeviceRegistrationRequest? lastRequest;
@override
Future<BaronSafeDeviceRegistrationResult> registerDevice(
BaronSafeDeviceRegistrationRequest request,
) async {
lastRequest = request;
return BaronSafeDeviceRegistrationResult(
deviceId: request.deviceId,
registered: true,
);
}
}
class _FixedPushTokenProvider implements BaronSafePushTokenProvider {
const _FixedPushTokenProvider(this._token);
final BaronSafePushToken? _token;
@override
Future<BaronSafePushToken?> readCurrentToken() async {
return _token;
}
}
@@ -0,0 +1,89 @@
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_models.dart';
import 'package:baron_safe_app/src/features/bridge/baron_safe_bridge_service.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_block_service.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_client.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_repository.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BiometricBaronSafeSessionBlockService', () {
test('blocks a session after biometric verification succeeds', () async {
final repository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
final service = BiometricBaronSafeSessionBlockService(
repository: repository,
biometricPrompt: const _FakeBiometricPrompt(verified: true),
);
final result = await service.blockSession(
sessionId: 'session-1',
reason: 'Suspicious login',
);
expect(result.sessionId, 'session-1');
expect(result.biometricVerified, isTrue);
expect(result.blocked, isTrue);
expect(result.message, 'Session blocked');
});
test('does not call block API when biometric verification fails', () async {
final repository = _RecordingSessionRepository();
final service = BiometricBaronSafeSessionBlockService(
repository: repository,
biometricPrompt: const _FakeBiometricPrompt(
verified: false,
reason: 'User canceled biometric prompt',
),
);
final result = await service.blockSession(
sessionId: 'session-1',
reason: 'Suspicious login',
);
expect(result.biometricVerified, isFalse);
expect(result.blocked, isFalse);
expect(result.message, 'User canceled biometric prompt');
expect(repository.blockCallCount, 0);
});
});
}
class _FakeBiometricPrompt implements BaronSafeBiometricPrompt {
const _FakeBiometricPrompt({required this.verified, this.reason = 'OK'});
final bool verified;
final String reason;
@override
Future<BaronSafeBiometricResult> verify({required String reason}) async {
return BaronSafeBiometricResult(verified: verified, reason: this.reason);
}
}
class _RecordingSessionRepository implements BaronSafeSessionRepository {
int blockCallCount = 0;
@override
Future<BaronSafeSessionBlockResponse> blockSession(
BaronSafeSessionBlockRequest request,
) async {
blockCallCount += 1;
return BaronSafeSessionBlockResponse(
sessionId: request.sessionId,
blocked: true,
);
}
@override
Future<BaronSafeSessionDetail> getSessionDetail(String sessionId) {
throw UnimplementedError();
}
@override
Future<List<BaronSafeSessionSummary>> listRecentSessions() {
throw UnimplementedError();
}
}
@@ -0,0 +1,80 @@
import 'package:baron_safe_app/src/features/session/baron_safe_session_client.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_models.dart';
import 'package:baron_safe_app/src/features/session/baron_safe_session_repository.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ClientBaronSafeSessionRepository', () {
test('lists recent sessions from the client', () async {
final repository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
final sessions = await repository.listRecentSessions();
expect(sessions, hasLength(2));
expect(sessions.first.id, 'session-1');
expect(sessions.last.riskLevel, BaronSafeSessionRiskLevel.high);
});
test('loads a session detail by id', () async {
final repository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
final session = await repository.getSessionDetail('session-2');
expect(session.serviceName, 'Admin Console');
expect(session.locationLabel, 'Unknown');
expect(session.status, BaronSafeSessionStatus.active);
});
test('throws when a session id is unknown', () async {
final repository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
expect(
() => repository.getSessionDetail('missing-session'),
throwsA(isA<BaronSafeSessionNotFoundException>()),
);
});
test('blocks a known session through the client', () async {
final repository = ClientBaronSafeSessionRepository(
MockBaronSafeSessionClient(),
);
final result = await repository.blockSession(
const BaronSafeSessionBlockRequest(
sessionId: 'session-1',
reason: 'Suspicious login',
),
);
expect(result.sessionId, 'session-1');
expect(result.blocked, isTrue);
});
});
group('BaronSafeSessionDetail', () {
test('round trips from JSON to model', () {
final session = BaronSafeSessionDetail.fromJson({
'id': 'session-json',
'serviceName': 'JSON Service',
'status': 'blocked',
'riskLevel': 'medium',
'createdAt': '2026-06-30T10:00:00.000Z',
'ipAddress': '203.0.113.11',
'deviceLabel': 'Safari on iOS',
'locationLabel': 'Busan',
'userAgent': 'Safari',
'authMethod': 'phone',
});
expect(session.status, BaronSafeSessionStatus.blocked);
expect(session.riskLevel, BaronSafeSessionRiskLevel.medium);
expect(session.toJson()['locationLabel'], 'Busan');
});
});
}
@@ -0,0 +1,32 @@
import 'package:baron_safe_app/src/features/webview/baron_safe_webview_policy.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BaronSafeWebViewPolicy', () {
const policy = BaronSafeWebViewPolicy();
test('allows production and staging HTTPS hosts', () {
expect(policy.allows(Uri.parse('https://safe.baron.hmac.kr')), isTrue);
expect(
policy.allows(Uri.parse('https://safe-staging.baron.hmac.kr/path')),
isTrue,
);
});
test('allows local HTTP development hosts', () {
expect(policy.allows(Uri.parse('http://localhost:8080')), isTrue);
expect(policy.allows(Uri.parse('http://127.0.0.1:8080')), isTrue);
expect(policy.allows(Uri.parse('http://10.0.2.2:8080')), isTrue);
});
test('blocks unknown hosts and insecure remote HTTP', () {
expect(policy.allows(Uri.parse('https://example.com')), isFalse);
expect(policy.allows(Uri.parse('http://safe.baron.hmac.kr')), isFalse);
});
test('blocks non-web schemes and relative URLs', () {
expect(policy.allows(Uri.parse('javascript:alert(1)')), isFalse);
expect(policy.allows(Uri.parse('/relative/path')), isFalse);
});
});
}
+33
View File
@@ -0,0 +1,33 @@
# 금일 작업내역 보고
일자: 2026-06-30
프로젝트: Baron Safe 앱 개발
## 금일 내용
| 작업 꼭지 | 주요 내용 |
| --- | --- |
| 앱 개발 방향 및 Flutter 프로젝트 확장 계획 정리 | Baron Safe 앱 개발 단계, 진행 원칙, Docker Flutter 사용 방식, 단계별 타임테이블 정리 |
| Flutter Android/iOS 정식 프로젝트 구조 병합 | 임시 Flutter 프로젝트 생성 후 Android/iOS 기본 scaffold를 기존 repository 구조에 병합 |
| 앱 기본 실행 구조 및 의존성 정리 | `pubspec.yaml` SDK/의존성 정리, `go_router` 기반 앱 entrypoint와 기본 홈 화면 구성 |
| WebView 및 Native bridge skeleton 구성 | Baron Safe WebView route, URL 주입 방식, WebView 보안 정책, JavaScript bridge 메시지 구조 구성 |
| Push, Secure Storage, Device registration skeleton 구성 | push token service, secure storage abstraction, device id 생성/저장 및 기기 등록 service skeleton 구성 |
| 세션 API 및 사용자 확인 화면 skeleton 구성 | 최근 세션 목록/상세 DTO, mock repository, 세션 목록/상세 화면 route와 홈 진입 타일 구성 |
| 검증 및 원격 반영 | Docker Flutter 컨테이너에서 `flutter pub get`, `flutter analyze`, `flutter test` 검증 및 0-12단계 변경분 Gitea push |
## 산출물
- Flutter Android/iOS 앱 기본 구조 생성
- Baron Safe 앱 기본 홈, WebView, bridge, push, secure storage, device registration, session skeleton 추가
- 세션 목록/상세 mock 화면 확인 가능
- 진행 문서 및 후속 개발 단계표 작성
- Gitea 원격 반영: `d2f6af7 Expand Flutter app scaffold`
## 다음 예정 작업
| 우선순위 | 작업 |
| --- | --- |
| 1 | 세션 차단 workflow skeleton 구성 |
| 2 | 생체 인증 placeholder와 세션 차단 요청 흐름 연결 |
| 3 | 연결 앱 목록/차단 skeleton 구성 |
| 4 | Android FCM 및 iOS APNs 설정 방식 확정 |
@@ -180,13 +180,39 @@ flutter create --project-name baron_safe_app --org kr.hmac.baron.safe /tmp/baron
| 2026-06-30 | 10단계 | Secure storage skeleton | 완료 | device id, app lock, biometric 설정 저장 abstraction과 `flutter_secure_storage` adapter, memory test store 구성 완료. Docker Flutter 컨테이너에서 `dart format lib test`, `flutter analyze`, secure storage 단위 테스트 통과 |
| 2026-06-30 | 11단계 | 검증 | 완료 | Docker Flutter 컨테이너에서 `flutter pub get`, `flutter analyze`, 전체 `flutter test` 통과. 전체 테스트 10건 성공 |
| 2026-06-30 | 12단계 | 커밋 및 push | 완료 | Flutter Android/iOS 프로젝트 병합 및 앱 skeleton 변경분을 커밋하고 Gitea `origin/main` push 진행 |
| 2026-06-30 | 13단계 | WebView URL 및 실행 환경 분리 | 완료 | `BARON_SAFE_WEB_URL` dart-define 기반 WebView URL 주입 방식과 local/staging/production 예시, 검증 방법을 `docs/runtime-config.md`에 정리 |
| 2026-06-30 | 14단계 | WebView 보안 정책 보강 | 완료 | 허용 host 기반 WebView navigation policy와 차단 UI를 구성하고 URL 허용/차단 단위 테스트 추가 |
| 2026-06-30 | 15단계 | Bridge 메시지 연결 | 완료 | WebView `BaronSafeBridge` JavaScript channel, bridge JSON request/response envelope, command parsing 및 허용 command 검증 테스트 추가 |
| 2026-06-30 | 16단계 | Device registration skeleton | 완료 | device id 생성/저장 service, device register API client interface, push token 포함 등록 request skeleton 및 단위 테스트 추가 |
| 2026-06-30 | 17단계 | Session API skeleton | 완료 | 세션 목록/상세 DTO, session client/repository interface, mock session client 및 목록/상세 단위 테스트 추가 |
| 2026-06-30 | 18단계 | Session 화면 skeleton | 완료 | 최근 세션 목록 route, 세션 상세 route, mock repository 기반 세션 목록/상세 화면과 홈 진입 타일 구성 |
| 2026-06-30 | 19단계 | Session block skeleton | 완료 | 세션 차단 request/response/result 모델, 생체 인증 placeholder 기반 block workflow, bridge `blockSession` service 주입 경로, 상세 화면 차단 버튼 연결 및 관련 단위 테스트 추가. Docker Flutter 컨테이너에서 `dart format lib test`, `flutter analyze`, 관련 테스트 15건 통과 |
## 7. 다음 승인 요청 예정
다음 단계는 후속 개발 범위 확정 후 별도 승인으로 진행한다.
다음 단계는 `20단계: Linked apps skeleton`다.
승인 후 결정할 작업:
1. Baron Safe WebView 실제 URL과 환경 분리 방식을 확정한다.
2. Firebase/FCM Android 설정과 iOS APNs 설정 방식을 확정한다.
3. native bridge와 backend API 연동 순서를 확정한다.
1. 연결 앱 목록/차단 DTO와 service interface를 정의한다.
2. mock 기반 연결 앱 목록/상세 또는 차단 진입 화면 placeholder를 구성한다.
3. 연결 앱 목록/차단 workflow 단위 테스트를 추가한다.
## 8. 후속 개발 단계
정식 Flutter Android/iOS scaffold와 앱 skeleton이 원격 `main`에 반영된 이후의 후속 개발 단계이다. 각 단계는 기존 원칙과 동일하게 사용자 승인 후 진행하며, 검증 가능한 단위마다 Docker Flutter 컨테이너에서 `flutter analyze`와 필요한 테스트를 수행한다.
| 단계 | 작업명 | 주요 작업 | 산출물 | 완료 기준 |
| --- | --- | --- | --- | --- |
| 13단계 | WebView URL 및 실행 환경 분리 | `BARON_SAFE_WEB_URL` 사용법 문서화, local/staging/production URL 관리 방식 정리 | 실행 환경 설정 문서 | 환경별 WebView URL 주입 방식 확인 |
| 14단계 | WebView 보안 정책 보강 | 허용 origin 목록, navigation delegate 차단 정책, 외부 링크 처리 기준 정리 | WebView policy 코드/문서 | 허용되지 않은 URL 이동 차단 테스트 가능 |
| 15단계 | Bridge 메시지 연결 | WebView JavaScript channel, bridge request parsing, response envelope 구성 | WebView-native bridge 연결 skeleton | 허용 command만 처리되는 테스트 통과 |
| 16단계 | Device registration skeleton | device id 생성/저장, device register API client interface 구성 | 기기 등록 service skeleton | device id 저장 및 등록 요청 테스트 통과 |
| 17단계 | Session API skeleton | 세션 목록/상세 DTO, API client interface, mock repository 구성 | session service skeleton | mock 기반 목록/상세 테스트 통과 |
| 18단계 | Session 화면 skeleton | 최근 로그인/세션 목록, 상세, 차단 진입 UI 구성 | Flutter session 화면 | analyze 통과 및 화면 route 동작 |
| 19단계 | Session block skeleton | 생체 인증 placeholder와 세션 차단 service 연결 | block workflow skeleton | 생체 인증 후 차단 요청 흐름 테스트 통과 |
| 20단계 | Linked apps skeleton | 연결 앱 목록/차단 DTO, service, 화면 placeholder 구성 | linked apps feature skeleton | mock 기반 목록/차단 테스트 통과 |
| 21단계 | Firebase/FCM Android 준비 | Android Firebase 설정 파일 반영 정책, Gradle 설정, 알림 권한 확인 | Android push 준비 변경 | secret 제외 여부 확인 및 analyze 통과 |
| 22단계 | iOS APNs 준비 | bundle id, entitlement, APNs/FCM 설정 방식 문서화 및 iOS 설정 skeleton | iOS push 준비 변경 | secret 제외 여부 확인 및 analyze 통과 |
| 23단계 | 앱 실행/빌드 검증 | Docker Flutter 기반 Android debug build 가능성 확인, 필요 시 iOS 설정 정합성 점검 | 빌드 검증 결과 | Android debug build 또는 명확한 차단 사유 기록 |
| 24단계 | 후속 커밋 및 push | 13-23단계 변경 목록 확인, 검증, 커밋, Gitea push | Git commit | 원격 `main` 반영 |
+70
View File
@@ -0,0 +1,70 @@
# Runtime Configuration
Baron Safe runtime values are injected at Flutter build/run time with `--dart-define`.
## WebView URL
The app reads the initial WebView URL from `BARON_SAFE_WEB_URL`.
| Environment | Example value | Purpose |
| --- | --- | --- |
| local | `http://10.0.2.2:8080` | Android emulator access to a host preview server |
| staging | `https://safe-staging.baron.hmac.kr` | Internal QA and SSO integration testing |
| production | `https://safe.baron.hmac.kr` | Production Baron Safe web route |
Default value in code:
```text
https://safe.baron.hmac.kr
```
## Run Examples
Android emulator local preview:
```bash
flutter run --dart-define=BARON_SAFE_WEB_URL=http://10.0.2.2:8080
```
Staging:
```bash
flutter run --dart-define=BARON_SAFE_WEB_URL=https://safe-staging.baron.hmac.kr
```
Release build example:
```bash
flutter build apk --dart-define=BARON_SAFE_WEB_URL=https://safe.baron.hmac.kr
```
## Docker Flutter Examples
From repository root:
```bash
docker run --rm \
-v /home/ubuntu/workspace/baron-safe-app/app:/app \
-w /app \
ghcr.io/cirruslabs/flutter:3.38.0 \
flutter analyze
```
For build or run commands, pass the same `--dart-define` argument to Flutter inside the container.
## Rules
- Do not hardcode staging-only or developer-only URLs in feature code.
- Do not put API keys, APNs keys, FCM server keys, signing keys, or production secrets in `--dart-define`.
- Keep WebView URL configuration separate from future native API base URL configuration.
- Keep the WebView allowed host list in app code small and explicit.
- Allow remote WebView traffic only over HTTPS.
- Allow HTTP only for local development hosts such as `localhost`, `127.0.0.1`, and Android emulator host `10.0.2.2`.
## Verification
1. Confirm [baron_safe_web_config.dart](../app/lib/src/features/webview/baron_safe_web_config.dart) still reads `BARON_SAFE_WEB_URL`.
2. Confirm [baron_safe_webview_policy.dart](../app/lib/src/features/webview/baron_safe_webview_policy.dart) allows only approved WebView hosts.
3. Run `flutter analyze`.
4. Run WebView policy tests.
5. For device/emulator checks, launch the app with the environment-specific `--dart-define` and verify the `/safe` route loads the expected WebView target.