Android: 동영상 연결 프로그램 · 공유

- 파일 앱 "다음으로 열기" · 다른 앱의 동영상 열기 (VIEW video/*) 와 공유 (SEND · SEND_MULTIPLE) 에 JJ_MKVMaker
- content:// 주소를 실제 파일 경로로 (저장소 문서 ID · MediaStore _data · 경로 조각 + 크기 확인) → 옆에 MKV 를 만들 수 있게
- 설정 "다른 앱에서 동영상을 열 때": 바로 재생 (기본, MKV 목록에도 추가) / 편집 목록에 추가. 앱이 꺼져 있을 때 · 켜져 있을 때 모두
- 실제 경로를 알 수 없는 앱에서 온 것은 재생만 하고 안내

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
RyuJongJin 2026-10-03 04:28:23 +09:00
parent a459488795
commit 6c15f74e72
4 changed files with 177 additions and 4 deletions

View File

@ -38,6 +38,22 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="video/*"/>
</intent-filter>
<!-- 공유 메뉴: 동영상 하나 · 여러 개 -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="video/*"/>
</intent-filter>
</activity>
<!-- 업데이트 APK 를 설치 화면에 넘기기 위한 파일 공유 (cache/updates 만) -->
<provider

View File

@ -17,10 +17,22 @@ import java.io.File
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비
class MainActivity : FlutterActivity() {
private var channel: MethodChannel? = null
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
private var pendingOpen: List<Map<String, String?>> = emptyList()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android").setMethodCallHandler { call, result ->
pendingOpen = openedFiles(intent)
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
channel = ch
ch.setMethodCallHandler { call, result ->
when (call.method) {
"takeOpenedFiles" -> {
result.success(pendingOpen)
pendingOpen = emptyList()
}
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
"requestAllFilesAccess" -> {
requestAllFilesAccess()
@ -83,6 +95,96 @@ class MainActivity : FlutterActivity() {
)
}
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val files = openedFiles(intent)
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
}
/// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}]
private fun openedFiles(intent: Intent?): List<Map<String, String?>> {
if (intent == null) return emptyList()
val uris = mutableListOf<Uri>()
when (intent.action) {
Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) }
Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) }
Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) }
}
return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) }
}
@Suppress("DEPRECATION")
private fun intentStream(i: Intent): Uri? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableExtra(Intent.EXTRA_STREAM)
@Suppress("DEPRECATION")
private fun intentStreams(i: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
private fun displayName(uri: Uri): String? = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}
} catch (e: Exception) {
null
} ?: uri.lastPathSegment?.substringAfterLast('/')
/// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null.
private fun realPath(uri: Uri): String? {
fun ok(path: String?) = path != null && File(path).isFile
if (uri.scheme == "file") return uri.path?.takeIf { ok(it) }
if (uri.scheme != "content") return null
// 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4)
try {
if (android.provider.DocumentsContract.isDocumentUri(this, uri) &&
uri.authority == "com.android.externalstorage.documents"
) {
val id = android.provider.DocumentsContract.getDocumentId(uri)
val vol = id.substringBefore(':')
val rel = id.substringAfter(':', "")
val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol"
val path = "$base/$rel"
if (ok(path)) return path
}
} catch (e: Exception) {
}
// 2. 미디어 저장소 등이 알려 주는 경로 (_data)
try {
contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use {
if (it.moveToFirst()) {
val path = it.getString(0)
if (ok(path)) return path
}
}
} catch (e: Exception) {
}
// 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서)
// 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만)
val size = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use {
if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L
} ?: -1L
} catch (e: Exception) {
-1L
}
val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() }
val roots = storageVolumes().map { it["path"] as String }
for (i in segs.indices) {
val rest = segs.drop(i).joinToString("/")
for (root in roots) {
val f = File(root, rest)
if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath
}
// 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…)
val abs = "/$rest"
if (abs.startsWith("/storage/") && ok(abs)) return abs
}
return null
}
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
private fun storageVolumes(): List<Map<String, Any>> {
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager

View File

@ -298,6 +298,42 @@ Future<void> main(List<String> args) async {
));
}
/// Android: 다른 앱 (파일 앱 "다음으로 열기" · 공유) 에서 받은 동영상.
/// 설정 "다른 앱에서 동영상을 열 때" 에 따라 바로 재생 (기본) 또는 MKV 목록에 추가. 실제 경로를 모르는 것은 재생만.
Future<void> openFromOtherApp(
GlobalKey<NavigatorState> navigatorKey, AppController c, List<Map<Object?, Object?>> files) async {
final paths = [for (final f in files) f['path'] as String?].whereType<String>().toList();
final uris = [
for (final f in files)
if (f['path'] == null) f['uri'] as String,
];
c.note('다른 앱에서 열기: ${files.map((f) => f['name'] ?? f['uri']).join(', ')}');
// MKV 화면으로 돌아온 뒤 (재생 중이었다면 그 화면은 닫는다)
navigatorKey.currentState?.popUntil((r) => r.isFirst);
final ctx = navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return;
if (paths.isNotEmpty) {
final added = c.addVideos(paths, allowOutputFolder: true);
if (c.settings.openFileAction == 'add') {
await added;
final first = c.videos.where((v) => paths.any((f) => p.equals(f, v.path))).firstOrNull;
if (first != null) c.select(first);
} else {
unawaited(added);
await playFiles(ctx, c, paths);
}
} else if (uris.isNotEmpty) {
// 파일 위치를 알려 주지 않는 앱: 재생만 (MKV 만들기 · 자막은 파일 고르기로 추가해야 함)
ScaffoldMessenger.maybeOf(ctx)?.showSnackBar(const SnackBar(
content: Text('파일 위치를 알 수 없어 재생만 합니다. MKV 를 만들려면 "동영상 추가" 로 고르세요.')));
final create = c.services.createMediaPlayer;
if (create != null) {
await Navigator.of(ctx).push(MaterialPageRoute<void>(
builder: (_) => PlayerPage(c: c, player: create(), files: uris, start: 0)));
}
}
}
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
s.openFileWindow == 'new' &&
@ -359,7 +395,24 @@ Future<void> runAndroid(String dataDir) async {
// 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황)
AndroidKeepAlive(controller, downloads);
// 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent)
const android = MethodChannel('jj_mkvmaker/android');
android.setMethodCallHandler((call) async {
if (call.method == 'openFiles') {
await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast<Map<Object?, Object?>>());
}
return null;
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
// 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터
try {
final opened = await android.invokeMethod<List<Object?>>('takeOpenedFiles') ?? const [];
if (opened.isNotEmpty) {
unawaited(openFromOtherApp(navigatorKey, controller, opened.cast<Map<Object?, Object?>>()));
return;
}
} catch (_) {}
if (controller.settings.startScreen == 'browser') {
navigatorKey.currentState?.push(MaterialPageRoute<void>(
builder: (_) => BrowserPage(c: controller, downloads: downloads, bookmarks: bookmarks)));

View File

@ -427,10 +427,11 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (m) => c.updateSettings((x) => x.playlistMode = m!),
),
),
if (desk) ...[
ListTile(
title: const Text('탐색기에서 동영상을 열 때'),
subtitle: const Text('더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)'),
title: Text(desk ? '탐색기에서 동영상을 열 때' : '다른 앱에서 동영상을 열 때'),
subtitle: Text(desk
? '더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)'
: '파일 앱의 "다음으로 열기" · 공유에서 JJ_MKVMaker 를 골랐을 때'),
trailing: DropdownButton<String>(
value: s.openFileAction,
items: const [
@ -440,6 +441,7 @@ class _SettingsPageState extends State<SettingsPage> {
onChanged: (v) => c.updateSettings((x) => x.openFileAction = v!),
),
),
if (desk) ...[
ListTile(
title: const Text('탐색기에서 연 동영상을 재생할 창'),
subtitle: const Text('프로그램이 이미 켜져 있을 때. 새 창은 재생만 하는 창이며 닫으면 그 창만 끝납니다'),