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]