From 10fddc35d3c969ef7f1695457bcceb1a8bc9cd2d Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Mon, 30 Mar 2026 11:13:29 +0900 Subject: [PATCH] ... --- .../launcher/workers/TorrentManager.kt | 792 ++++++------------ 1 file changed, 263 insertions(+), 529 deletions(-) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt index f8c01d24..9c1940e4 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt @@ -1,344 +1,179 @@ package bums.lunatic.launcher.workers -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.ContentValues -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Binder -import android.os.Build -import android.os.Environment -import android.os.IBinder -import android.provider.MediaStore -import androidx.core.app.NotificationCompat -import com.frostwire.jlibtorrent.* -import com.frostwire.jlibtorrent.alerts.Alert -import com.frostwire.jlibtorrent.alerts.AlertType -import com.frostwire.jlibtorrent.alerts.TorrentFinishedAlert -import java.io.File -import com.frostwire.jlibtorrent.alerts.* import android.app.* import android.content.* -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest +import android.net.* import android.os.* import androidx.annotation.RequiresApi -import bums.lunatic.launcher.LunaticLauncher.Companion.toast -import bums.lunatic.launcher.utils.Blog +import androidx.core.app.NotificationCompat +import com.frostwire.jlibtorrent.* +import com.frostwire.jlibtorrent.alerts.* import com.frostwire.jlibtorrent.swig.error_code import com.frostwire.jlibtorrent.swig.libtorrent import com.frostwire.jlibtorrent.swig.settings_pack import com.frostwire.jlibtorrent.swig.torrent_status -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable.isActive -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import java.io.File data class TorrentTask( val infoHash: String, val name: String, val progress: Float, val isPaused: Boolean, - val isQueued: Boolean, // 대기열 여부 + val isQueued: Boolean, val stateText: String ) class TorrentService : Service() { - private lateinit var connectivityManager: ConnectivityManager - private var networkCallback: ConnectivityManager.NetworkCallback? = null - private var isWifiConnected = false - - private fun registerNetworkCallback() { - connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val request = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - - networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { - super.onCapabilitiesChanged(network, networkCapabilities) - val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - - if (isWifi) { - if (!isWifiConnected) { - isWifiConnected = true - println("TorrentService: Wi-Fi 연결됨. 전체 다운로드 세션 재개.") - session.resume() // 💡 세션 전체 트래픽 재개 - } - } else { - if (isWifiConnected) { - isWifiConnected = false - println("TorrentService: Wi-Fi 연결 아님(셀룰러 등). 전체 다운로드 세션 일시정지.") - session.pause() // 💡 세션 전체 트래픽 차단 - } - } - } - - override fun onLost(network: Network) { - super.onLost(network) - if (isWifiConnected) { - isWifiConnected = false - println("TorrentService: 네트워크 완전히 끊김. 전체 다운로드 세션 일시정지.") - session.pause() - } - } - } - - // 초기 상태 체크 (서비스 시작 시점이 Wi-Fi 환경인지 확인) - val activeNetwork = connectivityManager.activeNetwork - val caps = connectivityManager.getNetworkCapabilities(activeNetwork) - isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - - // 처음 켰을 때 Wi-Fi가 아니면 바로 일시정지 - if (!isWifiConnected) { - println("TorrentService: 현재 Wi-Fi가 아닙니다. 세션을 일시정지 상태로 시작합니다.") - session.pause() - } - - connectivityManager.registerNetworkCallback(request, networkCallback!!) - } private lateinit var session: SessionManager private val binder = TorrentBinder() + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // 경로 설정 private val tempDir by lazy { File(getExternalFilesDir(null), "temp_torrents").apply { mkdirs() } } private val resumeDir by lazy { File(getExternalFilesDir(null), "resume_data").apply { mkdirs() } } + + // 알림 및 상태 관리 private lateinit var notificationManager: NotificationManager private lateinit var notificationBuilder: NotificationCompat.Builder private val NOTIFICATION_ID = 101 - private var hasActiveTorrents = false // 자동 종료를 위한 플래그 + private var lastProgress = -1 + + // 제어 플래그 + private var isWifiConnected = false + private var isCharging = false + private lateinit var connectivityManager: ConnectivityManager + private var networkCallback: ConnectivityManager.NetworkCallback? = null inner class TorrentBinder : Binder() { fun getService(): TorrentService = this@TorrentService } - private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - // UI에서 관찰할 상태 Flow - private val _torrentTasks = MutableStateFlow>(emptyList()) - val torrentTasks: StateFlow> = _torrentTasks - override fun onCreate() { super.onCreate() + notificationManager = getSystemService(Context.CONNECTIVITY_SERVICE) as NotificationManager startForegroundService() initLibTorrent() + + // 1. 초기 상태 체크 (배터리 및 네트워크) + checkInitialStatus() + + // 2. 리시버 및 콜백 등록 + registerBatteryReceiver() registerNetworkCallback() - startPolling() // 폴링 시작 + + // 3. 초기 세션 상태 적용 + updateSessionState() } - private fun startPolling() { - serviceScope.launch { - while (isActive) { - val vector = session.swig().get_torrents() - val taskCount = vector.size.toInt() + private fun checkInitialStatus() { + // 배터리 상태 + val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + val batteryStatus = registerReceiver(null, ifilter) + val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL - // 💡 1. 자동 종료 로직 - if (taskCount > 0) { - hasActiveTorrents = true // 토렌트가 1개 이상 활성화된 적이 있음을 마킹 - } else if (hasActiveTorrents && taskCount == 0) { - // 활성화된 적이 있었는데 0개가 되었다면 (모두 완료되어 이동되었거나 삭제됨) - println("TorrentService: 모든 작업이 완료되어 서비스를 자동 종료합니다.") + // Wi-Fi 상태 + connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork + val caps = connectivityManager.getNetworkCapabilities(activeNetwork) + isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } - try { - if (tempDir.exists()) { - tempDir.listFiles()?.forEach { it.deleteRecursively() } - println("TorrentService: 임시 다운로드 폴더 정리를 완료했습니다.") - } - } catch (e: Exception) { - e.printStackTrace() - } + private fun registerBatteryReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) + } + registerReceiver(batteryReceiver, filter) + } - stopForeground(true) // 💡 상단바 알림 즉시 제거 - stopSelf() // 💡 서비스 스스로 종료 (메모리 해제) - break // 무한 루프 탈출 + private val batteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + isCharging = when (intent?.action) { + Intent.ACTION_POWER_CONNECTED -> true + Intent.ACTION_POWER_DISCONNECTED -> false + else -> isCharging + } + updateSessionState() + } + } + + private fun registerNetworkCallback() { + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + if (isWifiConnected != isWifi) { + isWifiConnected = isWifi + updateSessionState() } - // 1. SessionManager에서 SWIG 원시 객체(vector)로 핸들 목록을 가져옵니다. - val tasks = mutableListOf() - - // 2. 각 핸들(TorrentHandle)을 순회하며 데이터를 추출합니다. - for (i in 0 until vector.size.toInt()) { - val swigHandle = vector.get(i) - val handle = com.frostwire.jlibtorrent.TorrentHandle(swigHandle) - - // 핸들이 유효한지 확인 - if (!handle.isValid) continue - - val status = handle.status() - val state = status.state() - -// 1. 현재 다운로드 대기 중(Queued)인지 확인 -// 'auto_managed'이면서, 실제 'downloading' 상태가 아닌 경우를 체크합니다. - val isQueued = status.flags().and_(libtorrent.getAuto_managed()).nonZero() && - (state.swig() == torrent_status.state_t.downloading_metadata.swigValue() || - state.swig() == torrent_status.state_t.checking_files.swigValue() || - state.swig() == torrent_status.state_t.checking_resume_data.swigValue()) - -// 2. 더 확실한 방법 (상태값이 'queued_for_checking' 등인 경우 포함) -// 실제 데이터 전송이 일어나지 않고 대기 순번을 기다리는 상태인지 확인 - val isDownloading = (state.swig() == torrent_status.state_t.downloading.swigValue()) - val isFinished = status.isFinished - val isPaused = status.flags().and_(libtorrent.getPaused()).nonZero() - val stateText = when { - status.flags().and_(libtorrent.getPaused()).nonZero() -> "일시정지" - state.swig() == torrent_status.state_t.checking_files.swigValue() -> "파일 검사 중" - state.swig() == torrent_status.state_t.downloading_metadata.swigValue() -> "메타데이터 수신 중" - state.swig() == torrent_status.state_t.downloading.swigValue() -> "다운로드 중" - state.swig() == torrent_status.state_t.finished.swigValue() || state.swig() == torrent_status.state_t.seeding.swigValue() -> "완료" - else -> "대기 중" // activeDownloads 제한에 걸려 대기하는 경우 여기로 들어옵니다. - } -// "일시정지도 아니고, 완료도 아닌데, 현재 다운로드 중도 아니면" -> 대기열 상태로 간주 - val realIsQueued = !isPaused && !isFinished && !isDownloading - val hashStr = status.infoHash().toString() - var rawName = status.name() - - // 💡 핵심: TorrentHandle 객체에서 torrentFile()을 호출하여 TorrentInfo를 가져옵니다. - if (status.hasMetadata()) { - val torrentInfo = handle.torrentFile() - // torrentInfo가 null이 아니고 유효하다면 진짜 이름을 추출합니다. - if (torrentInfo != null && torrentInfo.isValid) { - val realName = torrentInfo.name() - if (!realName.isNullOrEmpty()) { - rawName = realName - } - } - } - - val displayName = if (rawName.isNullOrEmpty() || rawName == hashStr) { - if (status.hasMetadata()) { - "파일 정보 분석 중..." - } else { - "메타데이터 수신 중... (${hashStr.take(6)})" - } - } else { - rawName - } - - tasks.add( - TorrentTask( - infoHash = hashStr, - name = displayName, - progress = status.progress() * 100f, - isPaused = status.flags().and_(com.frostwire.jlibtorrent.swig.libtorrent.getPaused()).nonZero(), - isQueued = isQueued, - stateText = stateText - ) - ) - } - _torrentTasks.value = tasks - - // 💡 2. UI 알림 갱신 로직 - if (tasks.isNotEmpty()) { - updateNotification(tasks) - } - delay(15000) } } + connectivityManager.registerNetworkCallback(request, networkCallback!!) } - override fun onDestroy() { - super.onDestroy() - networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) } - serviceScope.cancel() // 서비스 종료 시 코루틴 정리 - } + /** + * 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절 + */ + private fun updateSessionState() { + if (isCharging) { + if (session.isPaused) session.resume() - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // 인텐트에서 마그넷 주소를 꺼냄 - val uriString = intent?.getStringExtra("EXTRA_MAGNET_URI") ?: "" - Blog.LOGE("uriString >>> $uriString") - if (uriString.startsWith("magnet:?")) { - var fixedUri = uriString - if (fixedUri.contains("xt=urn:") && !fixedUri.contains("xt=urn:btih:")) { - fixedUri = fixedUri.replace("xt=urn:", "xt=urn:btih:") - println("TorrentService: 누락된 btih: 를 추가하여 마그넷 주소를 수정했습니다 -> $fixedUri") + // Wi-Fi면 5개, 셀룰러면 1개 다운로드 허용 + val sp = SettingsPack() + if (isWifiConnected) { + sp.activeDownloads(5) + sp.activeLimit(8) + } else { + sp.activeDownloads(1) + sp.activeLimit(2) } - addMagnet(fixedUri) - } - // 2. .torrent 파일을 가리키는 일반 HTTP 웹 링크인 경우 - else if (uriString.startsWith("http://") || uriString.startsWith("https://")) { - println("TorrentService: 마그넷이 아닌 웹 URL이 전달되었습니다 -> $uriString") - // 여기서 바로 addMagnet을 호출하면 안 됩니다! - // downloadTorrentFileFromWeb(uriString) // 별도의 파일 다운로드 로직 필요 - } - // 3. 알 수 없는 형식인 경우 - else { - println("TorrentService: 잘못된 형식의 URI입니다 -> $uriString") - } + session.applySettings(sp) + println("TorrentService: 충전 중 - 세션 활성화 (Wi-Fi: $isWifiConnected)") + } else { + if (!session.isPaused) session.pause() - // 서비스가 강제 종료되어도 시스템이 다시 살려내도록 START_STICKY 반환 - return START_STICKY - } - - // --- 기존 제어 기능들 아래에 추가 --- - fun resumeTorrent(infoHash: String) { - session.find(Sha1Hash(infoHash))?.resume() + // 💡 충전 중단 시 타이머 즉시 종료 (배터리 소모 0) + updateJob?.cancel() + println("TorrentService: 충전 중 아님 - 세션 및 업데이트 타이머 일시정지") + } + refreshTorrentStats() // 상태 변경 후 즉시 UI 갱신 } private fun initLibTorrent() { session = SessionManager() -// 💡 속도 최적화를 위한 세팅 적용 val sp = SettingsPack() - // [1] 통신사(ISP) 토렌트 트래픽 제한 우회 (강력 권장) - // 패킷을 암호화하여 통신사가 토렌트 다운로드 중인지 모르게 만듭니다. + // 최적화 설정 sp.setInteger(settings_pack.int_types.in_enc_policy.swigValue(), settings_pack.enc_policy.pe_enabled.swigValue()) sp.setInteger(settings_pack.int_types.out_enc_policy.swigValue(), settings_pack.enc_policy.pe_enabled.swigValue()) + sp.setBoolean(settings_pack.bool_types.enable_dht.swigValue(), true) + sp.setBoolean(settings_pack.bool_types.enable_lsd.swigValue(), true) - // [2] 마그넷 피어 탐색 극대화 (초기 메타데이터 수신 속도업) - sp.setBoolean(settings_pack.bool_types.enable_dht.swigValue(), true) // DHT 활성화 -// sp.setBoolean(settings_pack.bool_types.enable_pex.swigValue(), true) // Peer Exchange (피어 교환) - sp.setBoolean(settings_pack.bool_types.enable_lsd.swigValue(), true) // 로컬 네트워크 탐색 - sp.setBoolean(settings_pack.bool_types.dont_count_slow_torrents.swigValue(), true) - // 2. '느리다'고 판단할 다운로드 속도 기준 (예: 10KB/s 미만) - sp.setInteger(settings_pack.int_types.inactive_down_rate.swigValue(), 10 * 1024) - -// 3. '느리다'고 판단할 업로드 속도 기준 (예: 5KB/s 미만) - sp.setInteger(settings_pack.int_types.inactive_up_rate.swigValue(), 5 * 1024) - -// 4. 이 느린 속도가 몇 초간 지속될 때 대기열로 미룰 것인지 판단 (예: 20초) - sp.setInteger(settings_pack.int_types.inactivity_timeout.swigValue(), 20) - -// [3] 얼마나 오래 느려야 대기열로 보낼지 결정 (초 단위) - sp.setInteger(settings_pack.int_types.auto_manage_interval.swigValue(), 20) - // [3] 모바일 맞춤형 연결 최적화 - // 안드로이드는 데스크탑처럼 수천 개를 뚫으면 공유기가 뻗거나 앱 OOM이 발생할 수 있습니다. - sp.connectionsLimit(400) // 글로벌 최대 연결 수 (기본값 보통 200) - sp.activeDownloads(4) // 동시에 속도를 끌어올릴 다운로드 개수 (너무 많으면 속도가 분산됨) - sp.activeLimit(6) // 활성 상태(업/다운 포함) 유지 최대 개수 - sp.activeSeeds(2) - - // 세팅을 세션에 반영 session.applySettings(sp) session.addListener(object : AlertListener { override fun types(): IntArray? = intArrayOf( AlertType.TORRENT_FINISHED.swig(), AlertType.METADATA_RECEIVED.swig(), + AlertType.STATE_UPDATE.swig(), AlertType.SAVE_RESUME_DATA.swig() ) @RequiresApi(Build.VERSION_CODES.Q) override fun alert(alert: Alert<*>) { - Blog.LOGE("alert ${alert.type()}") when (alert.type()) { + AlertType.STATE_UPDATE -> refreshTorrentStats() AlertType.TORRENT_FINISHED -> { - val ta = alert as TorrentFinishedAlert -// moveToPublicDownload(ta.handle()) - moveToPrivateStorage(ta.handle()) - } - AlertType.METADATA_RECEIVED -> { - val ma = alert as MetadataReceivedAlert - ma.handle().saveResumeData() + moveToPrivateStorage((alert as TorrentFinishedAlert).handle()) + refreshTorrentStats() } + AlertType.METADATA_RECEIVED -> (alert as MetadataReceivedAlert).handle().saveResumeData() AlertType.SAVE_RESUME_DATA -> { val ra = alert as SaveResumeDataAlert saveResumeFile(ra.handle().infoHash().toString(), ra.params()) @@ -349,318 +184,217 @@ class TorrentService : Service() { }) session.start() + restoreExistingDownloads() } - /** 마그넷 추가 */ - fun addMagnet(magnetUri: String) { - try { - val error = error_code() + private var updateJob: Job? = null - // 1. SessionManager 대신 libtorrent 원시 함수로 마그넷 파싱 시도 - val swigParams = libtorrent.parse_magnet_uri(magnetUri, error) - - if (error.value() != 0) { - // 파싱 실패 시 어떤 이유로 실패했는지 정확한 에러 메시지 출력 - println("TorrentService: 마그넷 파싱 에러 - ${error.message()} / URI: $magnetUri") - return + // ✅ 추가할 함수: 다운로드 중일 때만 2초마다 가볍게 찔러줍니다. + private fun startLightweightUpdater() { + updateJob?.cancel() + updateJob = serviceScope.launch { + while (isActive) { + // 충전 중이고 세션이 돌아갈 때만 C++ 엔진에 상태 업데이트 요청 + if (isCharging && !session.isPaused) { + session.postTorrentUpdates() + } + delay(2000) // 2초 주기 (원하는 대로 조절 가능) } - swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed()) - swigParams.max_connections = Int.MAX_VALUE - // 다운로드/업로드 제한 해제 (기본값이 -1이긴 하나 명시적으로 박아줌) - swigParams.download_limit = -1 - swigParams.upload_limit = -1 - // 참고: 만약 "순차 다운로드(동영상 스트리밍용)"가 필요하다면 아래 주석을 푸세요. - // 단, 전체 다운로드 완료 속도는 일반 다운로드보다 느려집니다. - // swigParams.flags = swigParams.flags.or_(libtorrent.getSequential_download()) - swigParams.setSave_path(tempDir.absolutePath) - session.swig().async_add_torrent(swigParams) - - println("TorrentService: 마그넷 추가 성공!") - toast("마그넷 추가 성공 ${session.torrentHandles.size}개 진행중") - } catch (e: Exception) { - e.printStackTrace() } } - /** 상태 리스트 반환 */ - fun getStatusList(): List { - // SessionManager 대신 swig 원시 객체(C++ 레벨)에서 직접 벡터를 가져옵니다. - val vector = session.swig().get_torrents() - val list = mutableListOf() + /** + * 리스너에 의해 호출되는 UI 및 데이터 갱신 함수 + */ + private fun refreshTorrentStats() { + serviceScope.launch { + val vector = session.swig().get_torrents() + val tasks = mutableListOf() - // SWIG vector size는 Long 타입이므로 toInt()로 캐스팅이 필요합니다. - for (i in 0 until vector.size.toInt()) { - val handle = com.frostwire.jlibtorrent.TorrentHandle(vector.get(i)) - list.add(handle.status()) - } - return list - } + for (i in 0 until vector.size.toInt()) { + val handle = TorrentHandle(vector.get(i)) + if (!handle.isValid) continue - /** 제어 기능 */ - fun pauseTorrent(infoHash: String) { - session.find(Sha1Hash(infoHash))?.let { - it.pause() - it.queuePositionBottom() - } + val status = handle.status() + val state = status.state() - } + val isPaused = status.flags().and_(libtorrent.getPaused()).nonZero() + val isFinished = status.isFinished + val isDownloading = (state.swig() == torrent_status.state_t.downloading.swigValue()) - fun removeTorrent(infoHash: String, deleteFile: Boolean) { - val handle = session.find(Sha1Hash(infoHash)) - if (handle != null) { - // 1.2.x 버전의 제거 로직 - session.remove(handle) - if (deleteFile) { - // 파일 삭제는 수동으로 처리하거나 handle의 옵션을 확인해야 합니다. - File(tempDir, handle.status().name()).deleteRecursively() + val stateText = when { + !isCharging -> "충전 대기 중" + isPaused -> "일시정지" + isDownloading -> "다운로드 중" + isFinished -> "완료" + else -> "대기 중" + } + + tasks.add(TorrentTask( + infoHash = status.infoHash().toString(), + name = status.name() ?: "알 수 없음", + progress = status.progress() * 100f, + isPaused = isPaused, + isQueued = !isPaused && !isFinished && !isDownloading, + stateText = stateText + )) } - File(resumeDir, "$infoHash.resume").delete() + + _torrentTasks.value = tasks + updateNotification(tasks) } } + private fun updateNotification(tasks: List) { + if (tasks.isEmpty()) return + val activeTask = tasks.firstOrNull { !it.isPaused && it.progress < 100f } ?: tasks.first() + val currentProgress = activeTask.progress.toInt() + + // 최적화: 진행률이 변했을 때만 notify 호출 + if (currentProgress == lastProgress) return + lastProgress = currentProgress + + notificationBuilder.setContentTitle(if (tasks.size > 1) "${activeTask.name} 외 ${tasks.size - 1}건" else activeTask.name) + .setContentText("${activeTask.stateText}: $currentProgress%") + .setProgress(100, currentProgress, false) + + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + } + + // --- 데이터 관리 및 이동 로직 (기존 유지) --- + @RequiresApi(Build.VERSION_CODES.Q) private fun moveToPrivateStorage(handle: TorrentHandle) { try { val status = handle.status() - val infoHash = status.infoHash().toString() - var torrentName = status.name() - - if (status.hasMetadata()) { - val torrentInfo = handle.torrentFile() - if (torrentInfo != null && torrentInfo.isValid) { - val realName = torrentInfo.name() - if (!realName.isNullOrEmpty()) { - torrentName = realName - } - } - } - - if (torrentName.isNullOrEmpty() || torrentName == infoHash) { - println("TorrentService: 이동할 유효한 파일/폴더 이름을 찾지 못했습니다.") - return - } - + val torrentName = if (status.hasMetadata()) handle.torrentFile()?.name() ?: status.name() else status.name() val sourcePath = File(tempDir, torrentName) - if (!sourcePath.exists()) { - println("TorrentService: 원본 파일/폴더가 존재하지 않습니다 -> ${sourcePath.absolutePath}") - return - } + val destPath = File(File(getExternalFilesDir(null), "completed_torrents").apply { mkdirs() }, torrentName) - // 💡 앱 전용 프라이빗 폴더 설정 (다른 앱 접근 불가) - // 안드로이드/data/bums.lunatic.launcher/files/completed_torrents 에 저장됩니다. - val privateCompletedDir = File(getExternalFilesDir(null), "completed_torrents") - if (!privateCompletedDir.exists()) { - privateCompletedDir.mkdirs() - } - - val destPath = File(privateCompletedDir, torrentName) - - // 1. renameTo로 빠른 파일/폴더 이동 시도 (같은 파티션 내 이동 시 즉시 완료됨) - val success = sourcePath.renameTo(destPath) - - // 2. 만약 renameTo가 실패할 경우 (파티션이 다르거나 권한 문제 등), 직접 복사 후 삭제 - if (!success) { - sourcePath.copyRecursively(destPath, overwrite = true) - sourcePath.deleteRecursively() - } - - // 세션에서 제거 및 리쥼 파일 삭제 - session.remove(handle) - - val resumeFile = File(resumeDir, "$infoHash.resume") - if (resumeFile.exists()) { - resumeFile.delete() - println("TorrentService: 리쥼 파일 삭제 완료 ($infoHash.resume)") - } - - println("TorrentService: 다운로드 완료 및 프라이빗 폴더 이동 성공 ($torrentName)") - - } catch (e: Exception) { - e.printStackTrace() - println("TorrentService: 파일 이동 중 에러 발생 - ${e.message}") - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun moveToPublicDownload(handle: TorrentHandle) { - try { - val status = handle.status() - val infoHash = status.infoHash().toString() // 💡 리쥼 파일 삭제용 해시값 미리 저장 - var torrentName = status.name() - - // 💡 날카로운 지적 반영: 메타데이터(TorrentInfo)에서 진짜 파일/폴더명을 확실하게 가져옵니다! - if (status.hasMetadata()) { - val torrentInfo = handle.torrentFile() - if (torrentInfo != null && torrentInfo.isValid) { - val realName = torrentInfo.name() - if (!realName.isNullOrEmpty()) { - torrentName = realName - } + if (sourcePath.exists()) { + if (!sourcePath.renameTo(destPath)) { + sourcePath.copyRecursively(destPath, overwrite = true) + sourcePath.deleteRecursively() } + session.remove(handle) + File(resumeDir, "${status.infoHash()}.resume").delete() } - - // 진짜 이름도 없고 해시값과 동일하다면 비정상 상태로 간주 - if (torrentName.isNullOrEmpty() || torrentName == status.infoHash().toString()) { - println("TorrentService: 이동할 유효한 파일/폴더 이름을 찾지 못했습니다.") - return - } - - // 진짜 이름으로 임시 폴더 내의 실제 경로를 찾음 - val sourcePath = File(tempDir, torrentName) - if (!sourcePath.exists()) { - println("TorrentService: 원본 파일/폴더가 존재하지 않습니다 -> ${sourcePath.absolutePath}") - return - } - - val appFolderName = "Lunatic" - val baseDownloadPath = "${Environment.DIRECTORY_DOWNLOADS}/$appFolderName" - var root = File(baseDownloadPath) - if (root.exists() == false) { - root.mkdirs() - } - // 1. 단일 파일인 경우 - if (sourcePath.isFile) { - copySingleFileToDownloads(sourcePath, baseDownloadPath) - } - // 2. 다중 파일(폴더)인 경우 - else if (sourcePath.isDirectory) { - sourcePath.walkTopDown().filter { it.isFile }.forEach { file -> - val relativeSubPath = file.parentFile?.absolutePath?.substringAfter(sourcePath.absolutePath) ?: "" - val destRelativePath = "$baseDownloadPath/$torrentName$relativeSubPath" - - copySingleFileToDownloads(file, destRelativePath) - } - } - - // 복사가 모두 끝난 후 임시 파일(폴더) 삭제 및 세션에서 제거 - sourcePath.deleteRecursively() - session.remove(handle) - - val resumeFile = File(resumeDir, "$infoHash.resume") - if (resumeFile.exists()) { - resumeFile.delete() - println("TorrentService: 리쥼 파일 삭제 완료 ($infoHash.resume)") - } - - println("TorrentService: 다운로드 완료 및 Public 폴더 이동 성공 ($torrentName)") - - } catch (e: Exception) { - e.printStackTrace() - println("TorrentService: 파일 이동 중 에러 발생 - ${e.message}") - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun copySingleFileToDownloads(sourceFile: File, destRelativePath: String) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, sourceFile.name) - put(MediaStore.MediaColumns.RELATIVE_PATH, destRelativePath) - // 파일을 복사하는 동안 다른 앱이 접근하지 못하도록 IS_PENDING 설정 - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - - val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) - uri?.let { destUri -> - contentResolver.openOutputStream(destUri)?.use { output -> - sourceFile.inputStream().use { input -> - input.copyTo(output) - } - } - - // 복사 완료 후 IS_PENDING 해제 - contentValues.clear() - contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) - contentResolver.update(destUri, contentValues, null, null) - } + } catch (e: Exception) { e.printStackTrace() } } private fun saveResumeFile(hash: String, params: AddTorrentParams) { try { - val file = File(resumeDir, "$hash.resume") - - // 1.2.x의 bencode() 대신 2.0.x 규격의 버퍼(buf_ex) 쓰기 함수 사용 - val byteVector = libtorrent.write_resume_data_buf_ex(params.swig()) - val data = Vectors.byte_vector2bytes(byteVector) - - file.writeBytes(data) - } catch (e: Exception) { - e.printStackTrace() - } + val data = Vectors.byte_vector2bytes(libtorrent.write_resume_data_buf_ex(params.swig())) + File(resumeDir, "$hash.resume").writeBytes(data) + } catch (e: Exception) { e.printStackTrace() } } private fun restoreExistingDownloads() { resumeDir.listFiles()?.filter { it.extension == "resume" }?.forEach { file -> try { val data = file.readBytes() - - // 1. 바이트 배열을 SWIG byte_vector로 변환 - val byteVector = Vectors.bytes2byte_vector(data) - val error = error_code() - - // 2. 올려주신 클래스에 있는 read_resume_data_ex 사용! - val swigParams = libtorrent.read_resume_data_ex(byteVector, error) - - if (error.value() != 0) { - println("Resume data error: ${error.message()}") - return@forEach - } - - // 3. 다운로드 경로 재지정 - // (SWIG는 스네이크 케이스 변수를 카멜 케이스 setter로 자동 변환합니다) + val swigParams = libtorrent.read_resume_data_ex(Vectors.bytes2byte_vector(data), error_code()) swigParams.setSave_path(tempDir.absolutePath) - - // 4. SessionManager의 C++ 원시 핸들에 직접 접근하여 토렌트 추가 (비동기 권장) session.swig().async_add_torrent(swigParams) - - } catch (e: Exception) { - e.printStackTrace() - } + } catch (e: Exception) { e.printStackTrace() } } } - override fun onBind(intent: Intent): IBinder = binder + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.getStringExtra("EXTRA_MAGNET_URI")?.let { addMagnet(it) } + return START_STICKY + } - private fun updateNotification(tasks: List) { - if (tasks.isEmpty()) return - - // 다운로드 중인 첫 번째 항목 찾기 - val activeTask = tasks.firstOrNull { !it.isPaused && it.progress < 100f } ?: tasks.first() - - // 여러 개를 다운 중일 경우 텍스트 처리 - val title = if (tasks.size > 1) { - "${activeTask.name} 외 ${tasks.size - 1}건" - } else { - activeTask.name - } - - notificationBuilder.setContentTitle(title) - notificationBuilder.setContentText(String.format("다운로드 중... %.1f%%", activeTask.progress)) - notificationBuilder.setProgress(100, activeTask.progress.toInt(), false) // 프로그레스바 세팅 - - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + fun addMagnet(magnetUri: String) { + try { + val error = error_code() + val swigParams = libtorrent.parse_magnet_uri(magnetUri, error) + if (error.value() == 0) { + swigParams.setSave_path(tempDir.absolutePath) + swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed()) + session.swig().async_add_torrent(swigParams) + } + } catch (e: Exception) { e.printStackTrace() } } private fun startForegroundService() { val channelId = "torrent_channel" - notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - channelId, - "Torrent Service", - // 💡 중요: 진행률이 1초마다 갱신되므로 소리가 나지 않게 LOW로 설정합니다. - NotificationManager.IMPORTANCE_LOW - ) - notificationManager.createNotificationChannel(channel) + notificationManager.createNotificationChannel(NotificationChannel(channelId, "Torrent Service", NotificationManager.IMPORTANCE_LOW)) } - notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(android.R.drawable.stat_sys_download) // 다운로드 아이콘 - .setContentTitle("토렌트 엔진 준비 중...") - .setOnlyAlertOnce(true) // 💡 갱신될 때마다 알림음/진동이 울리지 않게 설정 + .setSmallIcon(android.R.drawable.stat_sys_download) + .setOnlyAlertOnce(true) .setOngoing(true) - startForeground(NOTIFICATION_ID, notificationBuilder.build()) } + + private val _torrentTasks = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) + val torrentTasks: kotlinx.coroutines.flow.StateFlow> = _torrentTasks + + override fun onBind(intent: Intent): IBinder = binder + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(batteryReceiver) + networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) } + serviceScope.cancel() + } + + + fun resumeTorrent(infoHash: String) { + try { + session.find(Sha1Hash(infoHash))?.resume() + // 💡 UI 즉시 갱신: 수동 조작 시 답답하지 않게 바로 상태를 업데이트합니다. + refreshTorrentStats() + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun pauseTorrent(infoHash: String) { + try { + session.find(Sha1Hash(infoHash))?.let { + it.pause() + // 대기열 맨 뒤로 보내기 (선택 사항) + // it.queuePositionBottom() + } + refreshTorrentStats() + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun removeTorrent(infoHash: String, deleteFile: Boolean) { + try { + val handle = session.find(Sha1Hash(infoHash)) + if (handle != null) { + // 1. 실제 파일 삭제 (요청 시) + if (deleteFile) { + val status = handle.status() + val torrentName = if (status.hasMetadata()) handle.torrentFile()?.name() ?: status.name() else status.name() + val targetFile = File(tempDir, torrentName) + if (targetFile.exists()) { + targetFile.deleteRecursively() + } + } + + // 2. 엔진에서 세션 제거 + session.remove(handle) + + // 3. 리쥼(이어받기) 데이터 파일 삭제 + val resumeFile = File(resumeDir, "$infoHash.resume") + if (resumeFile.exists()) { + resumeFile.delete() + } + + // 💡 삭제 후 목록에서 즉시 사라지도록 UI 갱신 + refreshTorrentStats() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } \ No newline at end of file