WIP Android: 작업 알림 서비스 specialUse 설명을 실제 하는 일대로 · 망 종류를 ConnectivityManager 로 판단 - 권한 1개 추가 (관리자 판단) (검토 대기)
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 <noreply@anthropic.com>next2
parent
25a0cd3c96
commit
b10158b46b
|
|
@ -1,5 +1,7 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<!-- 28 APK 점검 · 관리자 판단 (10/19): Wi-Fi 공유 · SMB 찾기가 망 종류 (Wi-Fi · 유선 / 모바일 데이터 · VPN) 를 알기 위해. 일반 권한 (묻지 않음) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
|
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
|
|
@ -135,13 +137,15 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/jj_file_paths"/>
|
android:resource="@xml/jj_file_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<!-- 작업 알림 서비스 (28 APK 점검 · 관리자 지시 10/19): specialUse 설명은 실제로 하는 일 모두를 적는다
|
||||||
|
(실시간 동기화 · 동영상 변환 · AI 자막 · 다운로드 · 복사/이동 · 원격 서버 전송 · Wi-Fi 공유) -->
|
||||||
<service
|
<service
|
||||||
android:name=".KeepAliveService"
|
android:name=".KeepAliveService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync|specialUse">
|
android:foregroundServiceType="dataSync|specialUse">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="Keeps folder live sync, video conversion and downloads running while the app is closed (user setting: run in background)"/>
|
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."/>
|
||||||
</service>
|
</service>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@ class MainActivity : FlutterActivity() {
|
||||||
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
|
||||||
|
"netKinds" -> result.success(netKinds())
|
||||||
"diskSpace" -> result.success(
|
"diskSpace" -> result.success(
|
||||||
try {
|
try {
|
||||||
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
||||||
|
|
@ -619,4 +621,29 @@ class MainActivity : FlutterActivity() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun netKinds(): Map<String, String>? = try {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
val out = HashMap<String, String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다.
|
/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다.
|
||||||
/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께.
|
/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께.
|
||||||
/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다.
|
/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다.
|
||||||
|
|
@ -24,6 +26,27 @@ final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet
|
||||||
final _virtualIf =
|
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');
|
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<Map<String, String>?> Function() netKindsOf = _androidNetKinds;
|
||||||
|
|
||||||
|
Future<Map<String, String>?> _androidNetKinds() async {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
|
try {
|
||||||
|
final m = await const MethodChannel('jj_mkvmaker/android').invokeMethod<Map<Object?, Object?>>('netKinds');
|
||||||
|
return m?.map((k, v) => MapEntry('$k', '$v'));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로
|
||||||
|
bool isLanAddress(String ip, String interfaceName, Map<String, String>? 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) 인지
|
/// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지
|
||||||
bool isPrivateLanAddress(String ip) {
|
bool isPrivateLanAddress(String ip) {
|
||||||
final v = ip.split('.').map(int.tryParse).toList();
|
final v = ip.split('.').map(int.tryParse).toList();
|
||||||
|
|
@ -34,9 +57,10 @@ bool isPrivateLanAddress(String ip) {
|
||||||
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다)
|
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다)
|
||||||
Future<List<InternetAddress>> localPrivateIPv4() async {
|
Future<List<InternetAddress>> localPrivateIPv4() async {
|
||||||
final out = <InternetAddress>[];
|
final out = <InternetAddress>[];
|
||||||
|
final kinds = await netKindsOf();
|
||||||
for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||||
if (!isLanInterfaceName(ni.name)) continue;
|
|
||||||
for (final a in ni.addresses) {
|
for (final a in ni.addresses) {
|
||||||
|
if (!isLanAddress(a.address, ni.name, kinds)) continue;
|
||||||
final b = a.rawAddress;
|
final b = a.rawAddress;
|
||||||
final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168);
|
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);
|
if (private && !a.isLoopback) out.add(a);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile;
|
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 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
|
/// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
|
||||||
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17):
|
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17):
|
||||||
|
|
@ -160,10 +160,11 @@ class WifiShare extends ChangeNotifier {
|
||||||
static Future<List<String>> localAddresses({bool tailscale = true}) async {
|
static Future<List<String>> localAddresses({bool tailscale = true}) async {
|
||||||
try {
|
try {
|
||||||
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
|
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
|
||||||
|
final kinds = await netKindsOf();
|
||||||
final all = [
|
final all = [
|
||||||
for (final i in list)
|
for (final i in list)
|
||||||
for (final a in i.addresses)
|
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;
|
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))));
|
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)
|
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
|
||||||
/// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기")
|
/// [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<String, String>? kinds}) {
|
||||||
final v = ip.split('.');
|
final v = ip.split('.');
|
||||||
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0;
|
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; // 주소를 못 받은 연결
|
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
|
||||||
return isLanInterfaceName(name);
|
return isLanAddress(ip, name, kinds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지
|
/// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue