Android 브라우저 점검 수정: 뒤로 키 · 알림 · 백그라운드 복귀 화면

- Android 뒤로 키: 앞 웹 페이지가 있으면 웹 페이지 뒤로 (위쪽 ← 화면 이동은 그대로 브라우저 닫기)
- 실행 취소 · 목록 보기 · 허용 알림이 사라지지 않던 것 (Flutter 3.47 부터 action 이 있으면 기본 persist) → persist: false
- 웹뷰를 텍스처로 그림 (useHybridComposition: false): 백그라운드로 실행 중 앱을 밀었다가 다시 열면
  Impeller "EGL Bad Access" 로 앱 화면이 비던 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 12:51:14 +09:00
parent fa0b7e56b2
commit 967b729e50
5 changed files with 90 additions and 4 deletions

View File

@ -417,7 +417,8 @@ Future<void> runAndroid(String dataDir) async {
downloads.log = controller.note; downloads.log = controller.note;
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
AndroidKeepAlive(controller, downloads); AndroidKeepAlive(controller, downloads);
// 백그라운드로 실행: 화면을 닫았다가 다시 열면 새 화면 (Activity) 이 살아 있던 엔진에 붙는다 → 화면 방향을 다시 적용 // 백그라운드로 실행: 화면 (Activity) 을 닫아도 엔진은 살아 있다가 다시 열면 새 화면에 붙는다 → 화면 방향을 다시 적용
// (웹뷰는 텍스처로 그려 새 화면에 붙어도 그대로 보인다: browser_page _webSettings)
AppLifecycleListener(onResume: () => applyScreenOrientation(controller.settings.screenOrientation)); AppLifecycleListener(onResume: () => applyScreenOrientation(controller.settings.screenOrientation));
// 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일 // 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
@ -468,6 +469,7 @@ Future<void> runAndroid(String dataDir) async {
messengerKey.currentState?.showSnackBar(SnackBar( messengerKey.currentState?.showSnackBar(SnackBar(
duration: const Duration(seconds: 20), duration: const Duration(seconds: 20),
content: Text(tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')), content: Text(tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')),
persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기
action: SnackBarAction(label: tr('허용'), onPressed: AndroidAccess.request), action: SnackBarAction(label: tr('허용'), onPressed: AndroidAccess.request),
)); ));
} }

View File

@ -126,7 +126,9 @@ class AppNavButtons extends StatelessWidget {
scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller), scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller),
here: onExplorerPage, here: onExplorerPage,
), ),
btn(const Icon(Icons.arrow_back), tr('뒤로'), atRoot ? null : () => Navigator.maybePop(context)), btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)),
btn( btn(
Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null), Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null),
onDownloadsPage ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), onDownloadsPage ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'),

View File

@ -239,6 +239,7 @@ void removeBookmarkWithUndo(BuildContext context, BookmarksController bm, Bookma
..clearSnackBars() ..clearSnackBars()
..showSnackBar(SnackBar( ..showSnackBar(SnackBar(
content: Text(n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title])), content: Text(n.isFolder ? trf('폴더 삭제: {0} (안의 {1}개 포함)', [n.title, bm.tree.countLinks(n)]) : trf('삭제: {0}', [n.title])),
persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기
action: SnackBarAction(label: tr('실행 취소'), onPressed: bm.undoRemove), action: SnackBarAction(label: tr('실행 취소'), onPressed: bm.undoRemove),
)); ));
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@ -355,6 +356,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
content: Text(trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title])), content: Text(trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title])),
duration: const Duration(minutes: 10), duration: const Duration(minutes: 10),
showCloseIcon: true, // [✕] 로 바로 닫기 showCloseIcon: true, // [✕] 로 바로 닫기
persist: false, // Flutter 3.47+: [action] 이 있으면 기본은 안 사라짐 → duration 대로 닫기
action: SnackBarAction(label: tr('목록 보기'), onPressed: () => DownloadsPage.open(nav, d)), action: SnackBarAction(label: tr('목록 보기'), onPressed: () => DownloadsPage.open(nav, d)),
)); ));
// 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다
@ -420,7 +422,14 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(), const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(),
}, },
child: Focus( // Android 뒤로 키: 앞 웹 페이지가 있으면 웹 페이지 뒤로 (없으면 브라우저 화면을 닫는다).
// 앱 위쪽의 ← (화면 이동) 은 이것과 상관없이 브라우저 화면을 닫는다 (AppNavButtons.onBrowserPage).
child: PopScope(
canPop: !(defaultTargetPlatform == TargetPlatform.android && _canBack),
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _nav?.back();
},
child: Focus(
autofocus: true, autofocus: true,
child: Scaffold( child: Scaffold(
body: ListenableBuilder( body: ListenableBuilder(
@ -495,6 +504,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
), ),
), ),
), ),
),
); );
} }
@ -609,6 +619,9 @@ InAppWebViewSettings _webSettings({required bool javaScript}) => InAppWebViewSet
javaScriptEnabled: javaScript, javaScriptEnabled: javaScript,
mediaPlaybackRequiresUserGesture: false, mediaPlaybackRequiresUserGesture: false,
supportMultipleWindows: false, supportMultipleWindows: false,
// Android: 텍스처로 그린다. 기본 hybrid composition 은 백그라운드로 실행 중 화면 (Activity) 이 새로 붙을 때
// Impeller 가 "EGL Bad Access" 로 그리지 못해 앱 화면이 비어 버린다.
useHybridComposition: false,
); );
class _EdgeView extends StatefulWidget { class _EdgeView extends StatefulWidget {

View File

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
@ -50,8 +51,9 @@ class _Nav implements WebNav {
final loads = <String>[]; final loads = <String>[];
@override @override
Future<void> load(String url) async => loads.add(url); Future<void> load(String url) async => loads.add(url);
int backs = 0;
@override @override
Future<void> back() async {} Future<void> back() async => backs++;
@override @override
Future<void> forward() async {} Future<void> forward() async {}
@override @override
@ -290,6 +292,60 @@ void main() {
d.dispose(); d.dispose();
}); });
testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final dir = Directory.systemTemp.createTempSync('jj_back_');
addTearDown(() => dir.deleteSync(recursive: true));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
final navKey = GlobalKey<NavigatorState>();
late BrowserHost host;
final nav = _Nav();
Future<void> open() async {
navKey.currentState!.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(
c: c,
bookmarks: bm,
initialUrl: 'https://example.com/',
viewBuilder: (h, url) {
host = h;
h.attach(nav);
return const ColoredBox(color: Colors.white);
},
),
));
await tester.pumpAndSettle();
}
await tester.pumpWidget(MaterialApp(navigatorKey: navKey, home: const Scaffold(body: Text('MKV 화면'))));
await open();
host.onHistory(true, false);
await tester.pump();
await tester.binding.handlePopRoute(); // 기기의 뒤로 키
await tester.pumpAndSettle();
expect(nav.backs, 1);
expect(find.byType(BrowserPage), findsOneWidget);
host.onHistory(false, true);
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(nav.backs, 1);
expect(find.byType(BrowserPage), findsNothing);
// 위쪽 ← (화면 이동) 은 웹 기록이 있어도 브라우저 화면을 닫는다
await open();
host.onHistory(true, false);
await tester.pump();
await tester.tap(find.byTooltip('뒤로'));
await tester.pumpAndSettle();
expect(nav.backs, 1);
expect(find.byType(BrowserPage), findsNothing);
debugDefaultTargetPlatformOverride = null; // 끝나기 전에 되돌려야 한다 (테스트 검사)
});
testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async { testWidgets('브라우저: ☆ 추가 · 표시줄 · 다운로드 버튼 · 관리 패널 (삭제 · 실행 취소)', (tester) async {
tester.view.physicalSize = const Size(1500, 900); tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -364,6 +420,18 @@ void main() {
await tester.tap(find.text('실행 취소')); await tester.tap(find.text('실행 취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull); expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull);
// 실행 취소 알림은 누르지 않으면 몇 초 뒤 저절로 사라진다 (Flutter 3.47+ 는 action 이 있으면 기본이 안 사라짐)
await tester.tap(find.byTooltip('더 보기').last);
await tester.pumpAndSettle();
await tester.tap(find.text('삭제').last);
await tester.pumpAndSettle();
expect(find.text('실행 취소'), findsOneWidget);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('실행 취소'), findsNothing);
bm.undoRemove();
await tester.pumpAndSettle();
expect(bm.tree.findByUrl('https://www.youtube.com/watch?v=abc'), isNotNull);
// 패널에서 누르면 그 주소로 이동 // 패널에서 누르면 그 주소로 이동
await tester.tap(find.text('OpenSubtitles').last); await tester.tap(find.text('OpenSubtitles').last);