feat: install session history and device page improvements

- Add install_attempt_result model with session tracking
- Update devices page with install history UI
- Extend app_shell with session management
- Update workflow integration phase roadmap
- Add widget tests for device features
This commit is contained in:
toki 2026-06-16 21:13:16 +09:00
parent c8ef7ec81f
commit c1eb3a912c
6 changed files with 317 additions and 42 deletions

View file

@ -17,7 +17,7 @@ MVP 설치 흐름을 Teams/Jenkins 알림과 연결하고, 설치 실패 대응
- [완료] macOS 빌드 CI와 인증 빌드
- 경로: `agent-roadmap/archive/phase/workflow-integration/milestones/macos-build-ci.md`
- 요약: Teams deep link와 오류 리포트 작업 전에 원격 Mac build/CI baseline과 Developer ID notarized 인증 빌드 절차를 확정한다.
- [진행중] 설치 오류 표시와 복사 리포트
- [검토중] 설치 오류 표시와 복사 리포트
- 경로: `agent-roadmap/phase/workflow-integration/milestones/install-session-history.md`
- 요약: 1차 MVP에서는 설치 이력을 저장하지 않고, 현재 설치 실패 원인과 masking된 복사 리포트만 제공한다.
- [보류] Teams deep link 설치 진입

View file

@ -11,7 +11,7 @@
## 상태
[진행중]
[검토중]
## 승격 조건
@ -39,29 +39,32 @@
설치 이력을 누적 저장하지 않고 현재 실행의 설치 결과만 앱 안에서 확인할 수 있게 한다.
- [ ] [session-model] 현재 설치 시도 결과를 화면 상태로 표현할 최소 모델을 정의한다.
- [x] [session-model] 현재 설치 시도 결과를 화면 상태로 표현할 최소 모델을 정의한다.
- [x] [local-store] 1차 MVP에서 설치 결과를 로컬 저장소에 영구 저장하지 않도록 한다.
- [ ] [recent-list] 최근 설치 목록 대신 현재 설치 결과와 실패 요약을 표시한다.
- [x] [recent-list] 최근 설치 목록 대신 현재 설치 결과와 실패 요약을 표시한다.
- [x] [rerun] 저장된 최근 세션 재실행은 제공하지 않고 사용자가 현재 선택한 build/device로 다시 실행한다.
### Epic: [report] 오류 리포트
실패 원인을 사용자와 개발자가 빠르게 공유할 수 있게 한다.
- [ ] [error-summary] install stderr/stdout의 주요 실패 원인을 요약 표시한다.
- [ ] [copy-report] 현재 build/device/install result를 포함한 masking된 텍스트 리포트를 복사할 수 있다.
- [ ] [masking] token, private URL, device serial masking 규칙을 적용한다.
- [ ] [retention] 보관 기간과 삭제 버튼 없이 현재 화면 상태만 초기화할 수 있게 한다.
- [x] [error-summary] install stderr/stdout의 주요 실패 원인을 요약 표시한다.
- [x] [copy-report] 현재 build/device/install result를 포함한 masking된 텍스트 리포트를 복사할 수 있다.
- [x] [masking] token, private URL, device serial masking 규칙을 적용한다.
- [x] [retention] 보관 기간과 삭제 버튼 없이 현재 화면 상태만 초기화할 수 있게 한다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증 충족 후 기록한다.
- 상태: 요청됨
- 요청일: 2026-06-16
- 완료 근거:
- 현재 설치 결과 모델과 단일 결과 배너로 history Epic을 충족했다.
- masking된 설치 리포트 복사, 실패 요약 표시, 현재 결과 초기화로 report Epic을 충족했다.
- local 및 remote Mac runner에서 `flutter analyze`, `flutter test`가 통과했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- 리뷰 코멘트: 모든 기능 Task가 완료되어 사용자 최종 확인과 archive 승인 대기 상태다.
## 범위 제외

View file

@ -158,6 +158,11 @@ class _AppSokShellState extends State<AppSokShell> {
_cleanupPendingInstall(pending);
}
void _handleInstallAttemptCleared() {
if (_lastInstallAttempt == null) return;
setState(() => _lastInstallAttempt = null);
}
void _cleanupPendingInstall(PendingInstall pending) {
_cleanupPath(pending.apkPath);
}
@ -208,6 +213,7 @@ class _AppSokShellState extends State<AppSokShell> {
deviceLoader: widget.deviceLoader,
isInstalling: _isInstalling,
lastInstallAttempt: _lastInstallAttempt,
onInstallAttemptCleared: _handleInstallAttemptCleared,
onLogcatRequested: _handleLogcatRequested,
),
),

View file

@ -1,11 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/adb_device.dart';
import '../../models/install_attempt_result.dart';
import '../../models/pending_install.dart';
import '../../services/adb_service.dart';
typedef PendingInstallRunner =
Future<void> Function(AdbDevice device, PendingInstall pending);
@ -20,6 +20,7 @@ class DevicesPage extends StatefulWidget {
this.deviceLoader,
this.isInstalling = false,
this.lastInstallAttempt,
this.onInstallAttemptCleared,
this.onLogcatRequested,
});
@ -29,6 +30,7 @@ class DevicesPage extends StatefulWidget {
final AdbDeviceLoader? deviceLoader;
final bool isInstalling;
final InstallAttemptResult? lastInstallAttempt;
final VoidCallback? onInstallAttemptCleared;
final ValueChanged<AdbDevice>? onLogcatRequested;
@override
@ -128,7 +130,10 @@ class _DevicesPageState extends State<DevicesPage> {
const SizedBox(height: 12),
],
if (widget.lastInstallAttempt case final attempt?) ...[
_InstallResultBanner(attempt: attempt),
_InstallResultBanner(
attempt: attempt,
onClear: widget.onInstallAttemptCleared,
),
const SizedBox(height: 12),
],
Wrap(
@ -593,16 +598,17 @@ class _PendingInstallBanner extends StatelessWidget {
}
class _InstallResultBanner extends StatelessWidget {
const _InstallResultBanner({required this.attempt});
const _InstallResultBanner({required this.attempt, this.onClear});
final InstallAttemptResult attempt;
final VoidCallback? onClear;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isSuccess = attempt.result.succeeded;
final isSuccess = attempt.succeeded;
final color = isSuccess ? colorScheme.primary : colorScheme.error;
final output = _resultOutputSummary(attempt.result);
final output = attempt.maskedOutputSummary;
final metadataKey = ValueKey(
'install-result-attempt:${attempt.pendingInstall.jobName}:'
'${attempt.pendingInstall.buildNumber}:${attempt.pendingInstall.fileName}',
@ -630,6 +636,17 @@ class _InstallResultBanner extends StatelessWidget {
key: metadataKey,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
key: const ValueKey('current-install-result-title'),
'현재 설치 결과',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
key: const ValueKey('install-result-label'),
isSuccess
@ -643,7 +660,7 @@ class _InstallResultBanner extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
Text(
'#${attempt.pendingInstall.buildNumber} · ${attempt.pendingInstall.jobName} · ${attempt.pendingInstall.fileName}',
attempt.artifactLabel,
key: const ValueKey('install-result-metadata'),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
@ -668,36 +685,46 @@ class _InstallResultBanner extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const ValueKey('install-report-copy-button'),
tooltip: '리포트 복사',
constraints: const BoxConstraints.tightFor(
width: 36,
height: 36,
),
padding: EdgeInsets.zero,
onPressed: () => _copyReport(context),
icon: const Icon(Icons.content_copy, size: 20),
),
IconButton(
key: const ValueKey('install-result-clear-button'),
tooltip: '현재 결과 초기화',
constraints: const BoxConstraints.tightFor(
width: 36,
height: 36,
),
padding: EdgeInsets.zero,
onPressed: onClear,
icon: const Icon(Icons.close, size: 20),
),
],
),
],
),
),
);
}
String _resultOutputSummary(AdbInstallResult result) {
final stdout = result.stdout.trim();
final stderr = result.stderr.trim();
if (result.succeeded) {
return [stdout, stderr].where((it) => it.isNotEmpty).join('\n');
}
if (stderr.isNotEmpty) {
return _firstNonEmptyLine(stderr);
}
if (stdout.isNotEmpty) {
return _firstNonEmptyLine(stdout);
}
return '설치 실패';
}
String _firstNonEmptyLine(String text) {
final lines = text
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty);
return lines.isEmpty ? '' : lines.first;
Future<void> _copyReport(BuildContext context) async {
await Clipboard.setData(ClipboardData(text: attempt.maskedReport));
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('리포트 복사됨')));
}
}

View file

@ -2,6 +2,8 @@ import '../services/adb_service.dart';
import 'adb_device.dart';
import 'pending_install.dart';
enum InstallAttemptOutcome { success, failure }
class InstallAttemptResult {
const InstallAttemptResult({
required this.pendingInstall,
@ -12,4 +14,84 @@ class InstallAttemptResult {
final PendingInstall pendingInstall;
final AdbDevice device;
final AdbInstallResult result;
InstallAttemptOutcome get outcome => result.succeeded
? InstallAttemptOutcome.success
: InstallAttemptOutcome.failure;
bool get succeeded => outcome == InstallAttemptOutcome.success;
bool get failed => outcome == InstallAttemptOutcome.failure;
String get artifactLabel =>
'#${pendingInstall.buildNumber} · ${pendingInstall.jobName} · '
'${pendingInstall.fileName}';
String get outputSummary {
final stdout = result.stdout.trim();
final stderr = result.stderr.trim();
if (succeeded) {
return [stdout, stderr].where((it) => it.isNotEmpty).join('\n');
}
if (stderr.isNotEmpty) return _firstNonEmptyLine(stderr);
if (stdout.isNotEmpty) return _firstNonEmptyLine(stdout);
return '설치 실패';
}
String get maskedOutputSummary => _maskSensitive(outputSummary);
String get maskedDeviceSerial => _maskSerial(device.serial);
String get maskedReport {
final lines = [
'AppSok 설치 리포트',
'결과: ${succeeded ? '성공' : '실패'}',
'exitCode: ${result.exitCode}',
'Build: $artifactLabel',
'Device: ${device.displayName} ($maskedDeviceSerial)',
if (maskedOutputSummary.isNotEmpty) '요약: $maskedOutputSummary',
if (result.stdout.trim().isNotEmpty)
'stdout:\n${_maskSensitive(result.stdout.trim())}',
if (result.stderr.trim().isNotEmpty)
'stderr:\n${_maskSensitive(result.stderr.trim())}',
];
return lines.join('\n');
}
String _firstNonEmptyLine(String text) {
final lines = text
.split('\n')
.map((line) => line.trim())
.where((line) => line.isNotEmpty);
return lines.isEmpty ? '' : lines.first;
}
String _maskSensitive(String value) {
return value
.replaceAll(RegExp(r'https?:\/\/[^\s]+'), '[masked-url]')
.replaceAllMapped(
RegExp(
r'\b(authorization)\s*[:=]\s*bearer\s+[A-Za-z0-9._~+/=-]+',
caseSensitive: false,
),
(match) => '${match.group(1)}: [masked]',
)
.replaceAllMapped(
RegExp(
r'\b(api[_-]?token|token|password|passwd|secret)\s*[:=]\s*[^\s&]+',
caseSensitive: false,
),
(match) => '${match.group(1)}=[masked]',
)
.replaceAll(device.serial, maskedDeviceSerial);
}
String _maskSerial(String serial) {
if (serial.isEmpty) return '[masked-serial]';
if (serial.length <= 4) return '*' * serial.length;
final prefix = serial.substring(0, 2);
final suffix = serial.substring(serial.length - 2);
return '$prefix${'*' * (serial.length - 4)}$suffix';
}
}

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
@ -11,6 +12,7 @@ import 'package:http/testing.dart';
import 'package:appsok/src/app.dart';
import 'package:appsok/src/features/app_shell.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/models/install_attempt_result.dart';
import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/models/pending_install.dart';
import 'package:appsok/src/features/builds/builds_page.dart'
@ -111,6 +113,72 @@ void main() {
expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFBF7));
});
test('InstallAttemptResult exposes the current attempt state', () {
const attempt = InstallAttemptResult(
pendingInstall: PendingInstall(
apkPath: '/tmp/staged/app-release.apk',
fileName: 'app-release.apk',
buildNumber: 7,
jobName: 'android-app',
sizeBytes: 1024,
),
device: AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
),
result: AdbInstallResult(
exitCode: 1,
stdout: 'Performing Streamed Install',
stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE\nDetailed adb output',
),
);
expect(attempt.outcome, InstallAttemptOutcome.failure);
expect(attempt.succeeded, isFalse);
expect(attempt.failed, isTrue);
expect(attempt.artifactLabel, '#7 · android-app · app-release.apk');
expect(attempt.outputSummary, 'INSTALL_FAILED_VERSION_DOWNGRADE');
});
test('InstallAttemptResult builds a masked copy report', () {
const attempt = InstallAttemptResult(
pendingInstall: PendingInstall(
apkPath: '/tmp/staged/app-release.apk',
fileName: 'app-release.apk',
buildNumber: 7,
jobName: 'android-app',
sizeBytes: 1024,
),
device: AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
),
result: AdbInstallResult(
exitCode: 1,
stdout: 'Install from https://jenkins.private/job/app/7/?token=raw',
stderr:
'INSTALL_FAILED_VERSION_DOWNGRADE\n'
'apiToken=secret-token\n'
'device R5CT90A1B2C',
),
);
final report = attempt.maskedReport;
expect(report, contains('AppSok 설치 리포트'));
expect(report, contains('Build: #7 · android-app · app-release.apk'));
expect(report, contains('Device: SM S918N (R5*******2C)'));
expect(report, contains('[masked-url]'));
expect(report, contains('apiToken=[masked]'));
expect(report, isNot(contains('jenkins.private')));
expect(report, isNot(contains('secret-token')));
expect(report, isNot(contains('R5CT90A1B2C')));
});
testWidgets('renders the app shell', (WidgetTester tester) async {
await tester.pumpWidget(const AppSokApp());
@ -730,8 +798,14 @@ void main() {
expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
// Result banner must survive after cleanup
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
expect(
find.byKey(const ValueKey('current-install-result-title')),
findsOneWidget,
);
expect(find.text('현재 설치 결과'), findsOneWidget);
// Pending banner must be gone
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
expect(find.textContaining('최근 설치'), findsNothing);
});
testWidgets(
@ -810,6 +884,89 @@ void main() {
expect(find.textContaining('Detailed adb output'), findsNothing);
});
testWidgets('copies masked install report from current result banner', (
tester,
) async {
String? copiedText;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
final args = Map<Object?, Object?>.from(call.arguments as Map);
copiedText = args['text'] as String?;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async => const AdbInstallResult(
exitCode: 1,
stdout: 'downloaded from https://jenkins.private/job/app/7/',
stderr:
'INSTALL_FAILED_VERSION_DOWNGRADE\n'
'token=secret-token\n'
'serial R5CT90A1B2C',
),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('install-report-copy-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(copiedText, isNotNull);
expect(copiedText, contains('AppSok 설치 리포트'));
expect(copiedText, contains('INSTALL_FAILED_VERSION_DOWNGRADE'));
expect(copiedText, contains('[masked-url]'));
expect(copiedText, contains('token=[masked]'));
expect(copiedText, isNot(contains('jenkins.private')));
expect(copiedText, isNot(contains('secret-token')));
expect(copiedText, isNot(contains('R5CT90A1B2C')));
expect(find.text('리포트 복사됨'), findsOneWidget);
});
testWidgets('clears only the current install result from devices page', (
tester,
) async {
await tester.pumpWidget(
shellWithInstaller(
installerFn: (device, pending) async =>
const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
),
);
await navigateToDevicesWithPending(tester);
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치',
);
await tester.tap(installButtons.first);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('install-result-clear-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('install-result-banner')), findsNothing);
expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
expect(find.textContaining('최근 설치'), findsNothing);
});
testWidgets('does not clean staged apk before install future completes', (
tester,
) async {