diff --git a/IMPLEMENTATION_AUDIT.md b/IMPLEMENTATION_AUDIT.md index 94ef25d..c077b3f 100644 --- a/IMPLEMENTATION_AUDIT.md +++ b/IMPLEMENTATION_AUDIT.md @@ -2,7 +2,7 @@ > **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 > **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증 -> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **147/147 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료 +> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **154/154 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료 > > **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상. @@ -101,6 +101,7 @@ | **M1** QR 스캔 페어링 + 수동 폴백 | ✅ ZXing(실기기) + 수동 IP:포트+토큰 | 에뮬레이터 E2E에서 수동 페어링으로 실증 (IME 제약) | | **M1** 큐 목록 실시간 + 제어 | ✅ 2초 폴링(계획 허용안: "v1은 2초 폴링 가능"), 일시정지/재개/취소 버튼 | 에뮬 E2E: 라이브 서버 데이터 렌더 실증 | | **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) | +| **M1** 공유 타겟(U1) | ✅ ACTION_SEND 인텐트 필터("PC에서 받기") + MainActivity OnCreate/OnNewIntent → ShareTarget 채널 → 루트 2s 타이머가 SharePayload.ExtractUrl → POST /api/queue | `SharePayload` 단위 7건(RED→GREEN) · 에뮬 E2E: **`am start -a SEND --es EXTRA_TEXT "…https://youtu.be/E2ESHARE123"` → 서버 큐에 해당 URL 반영 확인** + dumpsys 로 인텐트 필터 등록 확인 | | **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 | | **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED | | **M2-U3** 이어보기 위치 동기화 | ✅ `POST/GET /api/position/{id}` + PositionStore(positions.json 영속) + 플레이어 5초 주기·종료 시 저장, 재생 시작 시 시크 | RED→GREEN `이어보기_위치_동기화_E2E` · 에뮬 E2E: positions.json 13.3→24.2→**35.2초**(재오픈 시 저장 위치에서 이어 재생 실증) | @@ -124,6 +125,7 @@ | 재생중 미디어 알림 | ✅ 플레이어 열릴 때 ongoing 노티 "재생 중"(채널 vd_playback), 닫히면 취소 | 에뮬 E2E: 게시·소멸 lifecycle 실증(dumpsys). **FGS 미사용** — 재생은 앱 내(포그라운드) 한정이므로 ongoing 노티로 충족, 문서화 | | 라이브러리 그리드(썸네일·해상도) | ✅ 서버 Thumbnailer(ffmpeg 1초 프레임 320px JPEG + 해상도 ffprobe, 사이드카 .thumb.jpg/.meta.json 캐시) + `GET /api/library/{id}/thumb` + DTO Resolution + 앱 2열 그리드 | `ThumbnailerTests` 4건(Fake 러너) + 서버 E2E(JPEG 매직/404/401) + 에뮬 E2E: **실제 ffmpeg 썸네일 렌더**(스크린샷)·해상도 "480x270" | | 외부망 안내(§7) | ✅ ConnectionTroubleshoot(같은 네트워크/Tailscale 안내) → QueuePage 표시 | 단위 3건 + 에뮬 E2E: 죽은 포트 페어링 → "같은 네트워크… Tailscale 등 VPN" 문구 실증 | +| 오프라인 저장 진행 알림(M2) | ✅ 💾 저장 중 Android ProgressBar 노티(5% 스로틀 갱신) → 완료 시 "폰 저장 완료" 노티로 전환 | 에뮬 E2E: 34MB 저장 중 "폰에 저장 중 — N%" 라이브 관측(t=1~12s) → 완료 후 **"폰 저장 완료"(big.mp4) 레코드 실증**(진행→완료 전환, Report(1) 되덮어쓰기 방지 포함) | | U4 저장소 로직 이관 | ✅ OfflineStore → Mobile.Core(디렉터리 주입, MAUI 비의존) | 재시작 매니페스트 유지 테스트 포함 | ### 명시적 미구현 (계획 허용/후순위) diff --git a/VideoDownloader.Mobile.Core/SharePayload.cs b/VideoDownloader.Mobile.Core/SharePayload.cs new file mode 100644 index 0000000..5e449f4 --- /dev/null +++ b/VideoDownloader.Mobile.Core/SharePayload.cs @@ -0,0 +1,18 @@ +using System.Text.RegularExpressions; + +namespace VideoDownloader.Mobile; + +/// M1 공유 타겟(U1): 다른 앱에서 공유받은 텍스트에서 전송할 URL 추출. +/// 유튜브 공유 시트는 "제목 + https://youtu.be/..." 형식 — 텍스트 중 첫 http(s) URL. +public static partial class SharePayload +{ + [GeneratedRegex(@"https?://[^\s]+")] + private static partial Regex UrlRegex(); + + public static string? ExtractUrl(string? sharedText) + { + if (string.IsNullOrWhiteSpace(sharedText)) return null; + var m = UrlRegex().Match(sharedText); + return m.Success ? m.Value.TrimEnd(')', ']', '}', ',', '.', ';') : null; + } +} diff --git a/VideoDownloader.Mobile/Pages.cs b/VideoDownloader.Mobile/Pages.cs index 0c50727..3793bea 100644 --- a/VideoDownloader.Mobile/Pages.cs +++ b/VideoDownloader.Mobile/Pages.cs @@ -163,6 +163,21 @@ public sealed class QueuePage : ContentPage _summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건"; } + /// U1: 공유받은 링크 처리 결과 표시(루트 타이머가 호출). + public void ShowSharedResult(string message) => _summary.Text = message; + + /// U1: 공유 타겟으로 받은 텍스트에서 URL 을 추출해 PC 대기열에 추가. + private async Task HandleSharedUrlAsync() + { + var shared = ShareTarget.Consume(); + if (shared == null || !ApiClient.Instance.IsConfigured) return; + var url = SharePayload.ExtractUrl(shared); + if (url == null) { _summary.Text = "🔗 공유받은 텍스트에 URL 이 없습니다"; return; } + _summary.Text = "🔗 공유 링크 전송 중…"; + var ok = await ApiClient.Instance.AddAsync(url); + _summary.Text = ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패"; + } + /// U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청). private static async Task NotifyCompletionAsync(string title) { @@ -234,14 +249,19 @@ public sealed class LibraryPage : ContentPage if (item == null) return; if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; } button.Text = "0%"; + var notifId = 5000 + Math.Abs(item.Id.GetHashCode() % 1000); // M2 진행 알림: 항목별 고정 id 로 갱신 try { var dest = AppOfflineStore.Default.PathFor(item.Id); - var progress = new Progress(p => MainThread.BeginInvokeOnMainThread( - () => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%")); + var progress = new Progress(p => MainThread.BeginInvokeOnMainThread(() => + { + button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%"; + if (p < 1) ShowSaveProgress(notifId, item.FileName, p); // 100% 는 완료 노티가 대체(되덮어쓰기 방지) + })); await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None); AppOfflineStore.Default.Record(item.Id, item.FileName); button.Text = "✓ 저장됨"; + ShowSaveDone(notifId, item.FileName); } catch (Exception e) { @@ -249,6 +269,53 @@ public sealed class LibraryPage : ContentPage } } + /// M2: 오프라인 저장 진행 알림(같은 id 갱신) — 5% 단위 스로틀. + private static int _lastNotifiedPercent = -1; + + private static void ShowSaveProgress(int notifId, string fileName, double p) + { + var percent = (int)(p * 100); + if (p < 1 && percent / 5 == _lastNotifiedPercent) return; // 알림 스팸 방지 + _lastNotifiedPercent = percent / 5; + try + { + Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest + { + NotificationId = notifId, + Title = "폰에 저장 중", + Description = $"{fileName} — {percent}%", + Android = + { + ChannelId = "vd_save", + Ongoing = true, + AutoCancel = false, + ProgressBar = new Plugin.LocalNotification.AndroidOption.AndroidProgressBar + { + Max = 100, + Progress = percent, + IsIndeterminate = false, + }, + }, + }); + } + catch { } + } + + private static void ShowSaveDone(int notifId, string fileName) + { + try + { + Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest + { + NotificationId = notifId, + Title = "폰 저장 완료", + Description = fileName, + Android = { ChannelId = "vd_save", Ongoing = false, AutoCancel = true }, + }); + } + catch { } + } + private async void OpenPlayer(LibraryItem item) { // 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로) diff --git a/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs b/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs index 471765c..4981227 100644 --- a/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs +++ b/VideoDownloader.Mobile/Platforms/Android/MainActivity.cs @@ -1,10 +1,31 @@ -using Android.App; +using Android.App; +using Android.Content; using Android.Content.PM; using Android.OS; namespace VideoDownloader.Mobile; [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +// M1 공유 타겟(U1): 다른 앱의 공유 시트에 "PC에서 받기" 로 등록 +[IntentFilter(new[] { Intent.ActionSend }, Categories = new[] { Intent.CategoryDefault }, DataMimeType = "text/plain", Label = "PC에서 받기")] public class MainActivity : MauiAppCompatActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + ReceiveShare(Intent); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + ReceiveShare(intent); + } + + private static void ReceiveShare(Intent? intent) + { + if (intent?.Action != Intent.ActionSend) return; + var text = intent.GetStringExtra(Intent.ExtraText); + ShareTarget.Deliver(text); + } } diff --git a/VideoDownloader.Mobile/ShareTarget.cs b/VideoDownloader.Mobile/ShareTarget.cs new file mode 100644 index 0000000..e54e1ac --- /dev/null +++ b/VideoDownloader.Mobile/ShareTarget.cs @@ -0,0 +1,16 @@ +namespace VideoDownloader.Mobile; + +/// M1 공유 타겟(U1): 플랫폼(안드로이드 인텐트)이 전달한 공유 텍스트 큐. +public static class ShareTarget +{ + private static string? _pending; + + public static void Deliver(string? sharedText) + { + if (string.IsNullOrWhiteSpace(sharedText)) return; + Volatile.Write(ref _pending, sharedText); + } + + /// 대기 중 공유 텍스트를 꺼낸다(없으면 null). + public static string? Consume() => Interlocked.Exchange(ref _pending, null); +} diff --git a/VideoDownloader.Mobile/TabbedRootPage.cs b/VideoDownloader.Mobile/TabbedRootPage.cs index f53022c..83e32e1 100644 --- a/VideoDownloader.Mobile/TabbedRootPage.cs +++ b/VideoDownloader.Mobile/TabbedRootPage.cs @@ -1,15 +1,33 @@ namespace VideoDownloader.Mobile; -/// 컴패니언 루트: 연결 / 큐 / 라이브러리 탭. +/// 컴팠니언 루트: 큐 / 라이브러리 / 저장 / 연결 탭 + 공유 타겟 처리. public sealed class TabbedRootPage : TabbedPage { public TabbedRootPage() { ApiClient.Instance.Restore(); - Children.Add(new QueuePage()); + var queuePage = new QueuePage(); + Children.Add(queuePage); Children.Add(new LibraryPage()); Children.Add(new DownloadsPage()); Children.Add(new ConnectPage()); Title = "VD 원격"; + + // M1 공유 타겟(U1): 어떤 탭에 있든/다른 앱에서 공유받으면 URL 을 PC 큐로 전송 후 큐 탭 표시 + Device.StartTimer(TimeSpan.FromSeconds(2), () => + { + _ = HandleShareAsync(queuePage); + return true; + }); + } + + private static async Task HandleShareAsync(QueuePage queuePage) + { + var shared = ShareTarget.Consume(); + if (shared == null || !ApiClient.Instance.IsConfigured) return; + var url = SharePayload.ExtractUrl(shared); + if (url == null) { queuePage.ShowSharedResult("🔗 공유받은 텍스트에 URL 이 없습니다"); return; } + var ok = await ApiClient.Instance.AddAsync(url); + queuePage.ShowSharedResult(ok ? $"🔗 공유 링크 전송됨: {url}" : "❌ 공유 링크 전송 실패"); } } diff --git a/VideoDownloader.Tests/Mobile/MobileCoreTests.cs b/VideoDownloader.Tests/Mobile/MobileCoreTests.cs index a1dc9e4..ef78392 100644 --- a/VideoDownloader.Tests/Mobile/MobileCoreTests.cs +++ b/VideoDownloader.Tests/Mobile/MobileCoreTests.cs @@ -61,6 +61,24 @@ public class MobileCoreTests public void 정상_시_빈_문자열() => Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null)); + + // ── M1 공유 타겟(U1): 공유 텍스트 → URL 추출 ── + + [Theory] + [InlineData("https://youtu.be/abc123", "https://youtu.be/abc123")] // URL 만 + [InlineData("재밌는 영상\nhttps://youtu.be/abc123", "https://youtu.be/abc123")] // 제목+URL (유튜브 공유 형식) + [InlineData("http://x.com/a?b=1 입니다", "http://x.com/a?b=1")] // http + 쿼리 + [InlineData("a https://1.com b https://2.com", "https://1.com")] // 첫 번째 우선 + public void 공유_텍스트에서_URL_추출(string text, string expected) + => Assert.Equal(expected, SharePayload.ExtractUrl(text)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("URL 없는 그냥 텍스트")] + public void URL_없으면_null(string? text) + => Assert.Null(SharePayload.ExtractUrl(text)); + // ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ── [Fact]