From b10158b46bdb41d3ed85329641b2c6d7bf462e6f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:53 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20Android:=20=EC=9E=91=EC=97=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20specialUse=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9D=BC=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=A7=9D=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EB=A5=BC=20ConnectivityManager=20=EB=A1=9C?= =?UTF-8?q?=20=ED=8C=90=EB=8B=A8=20-=20=EA=B6=8C=ED=95=9C=201=EA=B0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8)=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 의 APK 점검 · 관리자 지시 (10/19): 1. KeepAliveService 의 PROPERTY_SPECIAL_USE_FGS_SUBTYPE 설명: "동기화 · 변환 · 다운로드" 만 적혀 있던 것을 실제로 하는 일 모두로 (실시간 폴더 동기화 · MKV 변환 · AI 자막 · 다운로드 · 복사/이동 · 사용자의 SFTP/SMB/FTP/WebDAV 서버 전송 · 읽기 전용 Wi-Fi 공유, 알림에 무엇이 도는지 보임). 2. 망 종류 판단 (관리자 판단): 인터페이스 이름 (rmnet · ccmni …) 대신 Android 의 ConnectivityManager · NetworkCapabilities 로 주소마다 wifi · ethernet · cellular · vpn 을 알아 (MainActivity "netKinds"), Wi-Fi 공유 주소 · SMB 찾기는 Wi-Fi · 유선만 쓴다. 모바일 데이터 · VPN 은 뺀다. Tailscale (100.64/10) 은 VPN 이어도 설정 "Tailscale 에서도 열기" 대로 (통신사 CGNAT = cellular 는 뺀다). 판단하지 못하면 (Android 가 아님 · 오류 · 종류를 모르는 주소 - 예: 휴대폰 핫스팟) 예전의 이름 방식. **권한 1개 추가 (관리자 판단): ACCESS_NETWORK_STATE** - 일반 권한 (사용자에게 묻지 않음), 망의 종류만 본다. 릴리스 노트 초안 (위키 릴리스\다음판_노트_초안 · release\RELEASE_NOTES_draft_next.md) 에도 적음. usesCleartextTraffic 은 그대로 (관리자: Android 는 IP 범위로 줄일 수 없고 http NAS 사용자가 있음). 시험: net_security_test (새 1: 종류가 이름보다 먼저 · 모르는 주소는 이름 · Tailscale 설정 · CGNAT) · lan_scan · smb_find · mdns_smb 13 통과, analyze 문제 없음, APK 빌드 (Kotlin 컴파일) 성공. 실제 기기 (Wi-Fi + 모바일 데이터 동시 · VPN · Tailscale) 확인은 테스터 몫. Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/AndroidManifest.xml | 6 ++++- .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 27 +++++++++++++++++++ lib/core/lan_scan.dart | 26 +++++++++++++++++- lib/core/wifi_share.dart | 13 +++++---- test/net_security_test.dart | 23 ++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92b6167..418e8d8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + @@ -135,13 +137,15 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/jj_file_paths"/> + + android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/> diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 1f7b66c..64dd537 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -170,6 +170,8 @@ class MainActivity : FlutterActivity() { startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) result.success(null) } + // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) + "netKinds" -> result.success(netKinds()) "diskSpace" -> result.success( try { val st = android.os.StatFs(call.argument("path") ?: "") @@ -619,4 +621,29 @@ class MainActivity : FlutterActivity() { ) } } + + /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) + @Suppress("DEPRECATION") + private fun netKinds(): Map? = try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + val out = HashMap() + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + val lp = cm.getLinkProperties(n) ?: continue + val kind = when { + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + for (la in lp.linkAddresses) { + val a = la.address + if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } + } + } + out + } catch (e: Exception) { + null + } } diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart index 5454797..3fd1fe5 100644 --- a/lib/core/lan_scan.dart +++ b/lib/core/lan_scan.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/services.dart'; + /// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다. /// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께. /// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다. @@ -24,6 +26,27 @@ final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet final _virtualIf = RegExp(r'vethernet|hyper-v|virtualbox|vbox|vmware|vmnet|wsl|docker|^br-|^veth|virbr|^tun|^tap|^ppp|^ipsec|wireguard|^wg|zerotier|openvpn|tailscale'); +/// 28 APK 점검 · 관리자 판단 (10/19): Android 의 망 종류 (ConnectivityManager · NetworkCapabilities) - 주소 → "wifi" · "ethernet" · +/// "cellular" · "vpn" · "other". 판단하지 못하면 (Android 가 아님 · 오류) null → 연결 이름으로 (예전 방식). 시험에서 바꾼다 +Future?> Function() netKindsOf = _androidNetKinds; + +Future?> _androidNetKinds() async { + if (!Platform.isAndroid) return null; + try { + final m = await const MethodChannel('jj_mkvmaker/android').invokeMethod>('netKinds'); + return m?.map((k, v) => MapEntry('$k', '$v')); + } catch (_) { + return null; + } +} + +/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로 +bool isLanAddress(String ip, String interfaceName, Map? kinds) { + final k = kinds?[ip]; + if (k != null) return k == 'wifi' || k == 'ethernet'; + return isLanInterfaceName(interfaceName); +} + /// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지 bool isPrivateLanAddress(String ip) { final v = ip.split('.').map(int.tryParse).toList(); @@ -34,9 +57,10 @@ bool isPrivateLanAddress(String ip) { /// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다) Future> localPrivateIPv4() async { final out = []; + final kinds = await netKindsOf(); for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { - if (!isLanInterfaceName(ni.name)) continue; for (final a in ni.addresses) { + if (!isLanAddress(a.address, ni.name, kinds)) continue; final b = a.rawAddress; final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); if (private && !a.isLoopback) out.add(a); diff --git a/lib/core/wifi_share.dart b/lib/core/wifi_share.dart index 1de015c..73cbc4d 100644 --- a/lib/core/wifi_share.dart +++ b/lib/core/wifi_share.dart @@ -8,7 +8,7 @@ import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile; -import 'lan_scan.dart' show isLanInterfaceName, isMobileInterfaceName; +import 'lan_scan.dart' show isLanAddress, isMobileInterfaceName, netKindsOf; /// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17): @@ -160,10 +160,11 @@ class WifiShare extends ChangeNotifier { static Future> localAddresses({bool tailscale = true}) async { try { final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false); + final kinds = await netKindsOf(); final all = [ for (final i in list) for (final a in i.addresses) - if (shareInterface(i.name, a.address, tailscale: tailscale)) a.address + if (shareInterface(i.name, a.address, tailscale: tailscale, kinds: kinds)) a.address ]; int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3; return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b)))); @@ -174,12 +175,14 @@ class WifiShare extends ChangeNotifier { /// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0) /// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기") - static bool shareInterface(String name, String ip, {bool tailscale = true}) { + /// [kinds]: Android 가 알려 준 주소별 망 종류 (있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 뺀다) + static bool shareInterface(String name, String ip, {bool tailscale = true, Map? kinds}) { final v = ip.split('.'); final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0; - if (a == 100 && b >= 64 && b <= 127) return tailscale && !isMobileInterfaceName(name); + // Tailscale 은 VPN 이지만 설정대로 (통신사 CGNAT 100.x 는 모바일 데이터라 뺀다) + if (a == 100 && b >= 64 && b <= 127) return tailscale && kinds?[ip] != 'cellular' && !isMobileInterfaceName(name); if (a == 169 && b == 254) return false; // 주소를 못 받은 연결 - return isLanInterfaceName(name); + return isLanAddress(ip, name, kinds); } /// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지 diff --git a/test/net_security_test.dart b/test/net_security_test.dart index 241990e..daf62c0 100644 --- a/test/net_security_test.dart +++ b/test/net_security_test.dart @@ -95,4 +95,27 @@ void main() { } } }); + test('관리자 판단 (10/19): Android 가 알려 준 망 종류가 있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 빼고 Tailscale 은 설정대로', () { + const kinds = { + '192.168.0.5': 'wifi', + '10.1.2.3': 'ethernet', + '10.20.30.40': 'cellular', + '10.8.0.2': 'vpn', + '100.80.1.2': 'vpn', + '100.70.1.2': 'cellular', + }; + // 이름이 Wi-Fi 같아도 종류가 모바일 · VPN 이면 뺀다 (이름이 낯선 기기 - 예: 제조사마다 다른 이름) + expect(isLanAddress('192.168.0.5', 'oddname0', kinds), isTrue); + expect(isLanAddress('10.1.2.3', 'eth9', kinds), isTrue); + expect(isLanAddress('10.20.30.40', 'wlan0', kinds), isFalse); + expect(isLanAddress('10.8.0.2', 'wlan1', kinds), isFalse); + // 종류를 모르는 주소 (예: 휴대폰 핫스팟 · Android 가 아님) 는 이름으로 + expect(isLanAddress('192.168.43.1', 'wlan0', kinds), isTrue); + expect(isLanAddress('192.168.43.1', 'rmnet0', null), isFalse); + expect(WifiShare.shareInterface('oddname0', '192.168.0.5', kinds: kinds), isTrue); + expect(WifiShare.shareInterface('wlan0', '10.20.30.40', kinds: kinds), isFalse); + expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds), isTrue, reason: 'Tailscale (VPN) 은 설정대로'); + expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds, tailscale: false), isFalse); + expect(WifiShare.shareInterface('oddname1', '100.70.1.2', kinds: kinds), isFalse, reason: '통신사 CGNAT'); + }); }