This commit is contained in:
lunaticbum 2026-03-30 11:13:29 +09:00
parent a7df635f66
commit 10fddc35d3

View File

@ -1,344 +1,179 @@
package bums.lunatic.launcher.workers 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.app.*
import android.content.* import android.content.*
import android.net.ConnectivityManager import android.net.*
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.* import android.os.*
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import bums.lunatic.launcher.LunaticLauncher.Companion.toast import androidx.core.app.NotificationCompat
import bums.lunatic.launcher.utils.Blog import com.frostwire.jlibtorrent.*
import com.frostwire.jlibtorrent.alerts.*
import com.frostwire.jlibtorrent.swig.error_code import com.frostwire.jlibtorrent.swig.error_code
import com.frostwire.jlibtorrent.swig.libtorrent import com.frostwire.jlibtorrent.swig.libtorrent
import com.frostwire.jlibtorrent.swig.settings_pack import com.frostwire.jlibtorrent.swig.settings_pack
import com.frostwire.jlibtorrent.swig.torrent_status import com.frostwire.jlibtorrent.swig.torrent_status
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import java.io.File
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
data class TorrentTask( data class TorrentTask(
val infoHash: String, val infoHash: String,
val name: String, val name: String,
val progress: Float, val progress: Float,
val isPaused: Boolean, val isPaused: Boolean,
val isQueued: Boolean, // 대기열 여부 val isQueued: Boolean,
val stateText: String val stateText: String
) )
class TorrentService : Service() { 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 lateinit var session: SessionManager
private val binder = TorrentBinder() 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 tempDir by lazy { File(getExternalFilesDir(null), "temp_torrents").apply { mkdirs() } }
private val resumeDir by lazy { File(getExternalFilesDir(null), "resume_data").apply { mkdirs() } } private val resumeDir by lazy { File(getExternalFilesDir(null), "resume_data").apply { mkdirs() } }
// 알림 및 상태 관리
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
private lateinit var notificationBuilder: NotificationCompat.Builder private lateinit var notificationBuilder: NotificationCompat.Builder
private val NOTIFICATION_ID = 101 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() { inner class TorrentBinder : Binder() {
fun getService(): TorrentService = this@TorrentService fun getService(): TorrentService = this@TorrentService
} }
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// UI에서 관찰할 상태 Flow
private val _torrentTasks = MutableStateFlow<List<TorrentTask>>(emptyList())
val torrentTasks: StateFlow<List<TorrentTask>> = _torrentTasks
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = getSystemService(Context.CONNECTIVITY_SERVICE) as NotificationManager
startForegroundService() startForegroundService()
initLibTorrent() initLibTorrent()
// 1. 초기 상태 체크 (배터리 및 네트워크)
checkInitialStatus()
// 2. 리시버 및 콜백 등록
registerBatteryReceiver()
registerNetworkCallback() registerNetworkCallback()
startPolling() // 폴링 시작
// 3. 초기 세션 상태 적용
updateSessionState()
} }
private fun startPolling() { private fun checkInitialStatus() {
serviceScope.launch { // 배터리 상태
while (isActive) { val ifilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val vector = session.swig().get_torrents() val batteryStatus = registerReceiver(null, ifilter)
val taskCount = vector.size.toInt() val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
// 💡 1. 자동 종료 로직 // Wi-Fi 상태
if (taskCount > 0) { connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
hasActiveTorrents = true // 토렌트가 1개 이상 활성화된 적이 있음을 마킹 val activeNetwork = connectivityManager.activeNetwork
} else if (hasActiveTorrents && taskCount == 0) { val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
// 활성화된 적이 있었는데 0개가 되었다면 (모두 완료되어 이동되었거나 삭제됨) isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
println("TorrentService: 모든 작업이 완료되어 서비스를 자동 종료합니다.") }
try { private fun registerBatteryReceiver() {
if (tempDir.exists()) { val filter = IntentFilter().apply {
tempDir.listFiles()?.forEach { it.deleteRecursively() } addAction(Intent.ACTION_POWER_CONNECTED)
println("TorrentService: 임시 다운로드 폴더 정리를 완료했습니다.") addAction(Intent.ACTION_POWER_DISCONNECTED)
} }
} catch (e: Exception) { registerReceiver(batteryReceiver, filter)
e.printStackTrace() }
}
stopForeground(true) // 💡 상단바 알림 즉시 제거 private val batteryReceiver = object : BroadcastReceiver() {
stopSelf() // 💡 서비스 스스로 종료 (메모리 해제) override fun onReceive(context: Context?, intent: Intent?) {
break // 무한 루프 탈출 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<TorrentTask>()
// 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() * 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) } */
serviceScope.cancel() // 서비스 종료 시 코루틴 정리 private fun updateSessionState() {
} if (isCharging) {
if (session.isPaused) session.resume()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Wi-Fi면 5개, 셀룰러면 1개 다운로드 허용
// 인텐트에서 마그넷 주소를 꺼냄 val sp = SettingsPack()
val uriString = intent?.getStringExtra("EXTRA_MAGNET_URI") ?: "" if (isWifiConnected) {
Blog.LOGE("uriString >>> $uriString") sp.activeDownloads(5)
if (uriString.startsWith("magnet:?")) { sp.activeLimit(8)
var fixedUri = uriString } else {
if (fixedUri.contains("xt=urn:") && !fixedUri.contains("xt=urn:btih:")) { sp.activeDownloads(1)
fixedUri = fixedUri.replace("xt=urn:", "xt=urn:btih:") sp.activeLimit(2)
println("TorrentService: 누락된 btih: 를 추가하여 마그넷 주소를 수정했습니다 -> $fixedUri")
} }
addMagnet(fixedUri) session.applySettings(sp)
} println("TorrentService: 충전 중 - 세션 활성화 (Wi-Fi: $isWifiConnected)")
// 2. .torrent 파일을 가리키는 일반 HTTP 웹 링크인 경우 } else {
else if (uriString.startsWith("http://") || uriString.startsWith("https://")) { if (!session.isPaused) session.pause()
println("TorrentService: 마그넷이 아닌 웹 URL이 전달되었습니다 -> $uriString")
// 여기서 바로 addMagnet을 호출하면 안 됩니다!
// downloadTorrentFileFromWeb(uriString) // 별도의 파일 다운로드 로직 필요
}
// 3. 알 수 없는 형식인 경우
else {
println("TorrentService: 잘못된 형식의 URI입니다 -> $uriString")
}
// 서비스가 강제 종료되어도 시스템이 다시 살려내도록 START_STICKY 반환 // 💡 충전 중단 시 타이머 즉시 종료 (배터리 소모 0)
return START_STICKY updateJob?.cancel()
} println("TorrentService: 충전 중 아님 - 세션 및 업데이트 타이머 일시정지")
}
// --- 기존 제어 기능들 아래에 추가 --- refreshTorrentStats() // 상태 변경 후 즉시 UI 갱신
fun resumeTorrent(infoHash: String) {
session.find(Sha1Hash(infoHash))?.resume()
} }
private fun initLibTorrent() { private fun initLibTorrent() {
session = SessionManager() session = SessionManager()
// 💡 속도 최적화를 위한 세팅 적용
val sp = SettingsPack() 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.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.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.applySettings(sp)
session.addListener(object : AlertListener { session.addListener(object : AlertListener {
override fun types(): IntArray? = intArrayOf( override fun types(): IntArray? = intArrayOf(
AlertType.TORRENT_FINISHED.swig(), AlertType.TORRENT_FINISHED.swig(),
AlertType.METADATA_RECEIVED.swig(), AlertType.METADATA_RECEIVED.swig(),
AlertType.STATE_UPDATE.swig(),
AlertType.SAVE_RESUME_DATA.swig() AlertType.SAVE_RESUME_DATA.swig()
) )
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
override fun alert(alert: Alert<*>) { override fun alert(alert: Alert<*>) {
Blog.LOGE("alert ${alert.type()}")
when (alert.type()) { when (alert.type()) {
AlertType.STATE_UPDATE -> refreshTorrentStats()
AlertType.TORRENT_FINISHED -> { AlertType.TORRENT_FINISHED -> {
val ta = alert as TorrentFinishedAlert moveToPrivateStorage((alert as TorrentFinishedAlert).handle())
// moveToPublicDownload(ta.handle()) refreshTorrentStats()
moveToPrivateStorage(ta.handle())
}
AlertType.METADATA_RECEIVED -> {
val ma = alert as MetadataReceivedAlert
ma.handle().saveResumeData()
} }
AlertType.METADATA_RECEIVED -> (alert as MetadataReceivedAlert).handle().saveResumeData()
AlertType.SAVE_RESUME_DATA -> { AlertType.SAVE_RESUME_DATA -> {
val ra = alert as SaveResumeDataAlert val ra = alert as SaveResumeDataAlert
saveResumeFile(ra.handle().infoHash().toString(), ra.params()) saveResumeFile(ra.handle().infoHash().toString(), ra.params())
@ -349,318 +184,217 @@ class TorrentService : Service() {
}) })
session.start() session.start()
restoreExistingDownloads() restoreExistingDownloads()
} }
/** 마그넷 추가 */ private var updateJob: Job? = null
fun addMagnet(magnetUri: String) {
try {
val error = error_code()
// 1. SessionManager 대신 libtorrent 원시 함수로 마그넷 파싱 시도 // ✅ 추가할 함수: 다운로드 중일 때만 2초마다 가볍게 찔러줍니다.
val swigParams = libtorrent.parse_magnet_uri(magnetUri, error) private fun startLightweightUpdater() {
updateJob?.cancel()
if (error.value() != 0) { updateJob = serviceScope.launch {
// 파싱 실패 시 어떤 이유로 실패했는지 정확한 에러 메시지 출력 while (isActive) {
println("TorrentService: 마그넷 파싱 에러 - ${error.message()} / URI: $magnetUri") // 충전 중이고 세션이 돌아갈 때만 C++ 엔진에 상태 업데이트 요청
return 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<TorrentStatus> { * 리스너에 의해 호출되는 UI 데이터 갱신 함수
// SessionManager 대신 swig 원시 객체(C++ 레벨)에서 직접 벡터를 가져옵니다. */
val vector = session.swig().get_torrents() private fun refreshTorrentStats() {
val list = mutableListOf<TorrentStatus>() serviceScope.launch {
val vector = session.swig().get_torrents()
val tasks = mutableListOf<TorrentTask>()
// SWIG vector size는 Long 타입이므로 toInt()로 캐스팅이 필요합니다. for (i in 0 until vector.size.toInt()) {
for (i in 0 until vector.size.toInt()) { val handle = TorrentHandle(vector.get(i))
val handle = com.frostwire.jlibtorrent.TorrentHandle(vector.get(i)) if (!handle.isValid) continue
list.add(handle.status())
}
return list
}
/** 제어 기능 */ val status = handle.status()
fun pauseTorrent(infoHash: String) { val state = status.state()
session.find(Sha1Hash(infoHash))?.let {
it.pause()
it.queuePositionBottom()
}
} 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 stateText = when {
val handle = session.find(Sha1Hash(infoHash)) !isCharging -> "충전 대기 중"
if (handle != null) { isPaused -> "일시정지"
// 1.2.x 버전의 제거 로직 isDownloading -> "다운로드 중"
session.remove(handle) isFinished -> "완료"
if (deleteFile) { else -> "대기 중"
// 파일 삭제는 수동으로 처리하거나 handle의 옵션을 확인해야 합니다. }
File(tempDir, handle.status().name()).deleteRecursively()
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<TorrentTask>) {
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) @RequiresApi(Build.VERSION_CODES.Q)
private fun moveToPrivateStorage(handle: TorrentHandle) { private fun moveToPrivateStorage(handle: TorrentHandle) {
try { try {
val status = handle.status() val status = handle.status()
val infoHash = status.infoHash().toString() val torrentName = if (status.hasMetadata()) handle.torrentFile()?.name() ?: status.name() else status.name()
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 sourcePath = File(tempDir, torrentName) val sourcePath = File(tempDir, torrentName)
if (!sourcePath.exists()) { val destPath = File(File(getExternalFilesDir(null), "completed_torrents").apply { mkdirs() }, torrentName)
println("TorrentService: 원본 파일/폴더가 존재하지 않습니다 -> ${sourcePath.absolutePath}")
return
}
// 💡 앱 전용 프라이빗 폴더 설정 (다른 앱 접근 불가) if (sourcePath.exists()) {
// 안드로이드/data/bums.lunatic.launcher/files/completed_torrents 에 저장됩니다. if (!sourcePath.renameTo(destPath)) {
val privateCompletedDir = File(getExternalFilesDir(null), "completed_torrents") sourcePath.copyRecursively(destPath, overwrite = true)
if (!privateCompletedDir.exists()) { sourcePath.deleteRecursively()
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
}
} }
session.remove(handle)
File(resumeDir, "${status.infoHash()}.resume").delete()
} }
} catch (e: Exception) { e.printStackTrace() }
// 진짜 이름도 없고 해시값과 동일하다면 비정상 상태로 간주
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)
}
} }
private fun saveResumeFile(hash: String, params: AddTorrentParams) { private fun saveResumeFile(hash: String, params: AddTorrentParams) {
try { try {
val file = File(resumeDir, "$hash.resume") val data = Vectors.byte_vector2bytes(libtorrent.write_resume_data_buf_ex(params.swig()))
File(resumeDir, "$hash.resume").writeBytes(data)
// 1.2.x의 bencode() 대신 2.0.x 규격의 버퍼(buf_ex) 쓰기 함수 사용 } catch (e: Exception) { e.printStackTrace() }
val byteVector = libtorrent.write_resume_data_buf_ex(params.swig())
val data = Vectors.byte_vector2bytes(byteVector)
file.writeBytes(data)
} catch (e: Exception) {
e.printStackTrace()
}
} }
private fun restoreExistingDownloads() { private fun restoreExistingDownloads() {
resumeDir.listFiles()?.filter { it.extension == "resume" }?.forEach { file -> resumeDir.listFiles()?.filter { it.extension == "resume" }?.forEach { file ->
try { try {
val data = file.readBytes() val data = file.readBytes()
val swigParams = libtorrent.read_resume_data_ex(Vectors.bytes2byte_vector(data), error_code())
// 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로 자동 변환합니다)
swigParams.setSave_path(tempDir.absolutePath) swigParams.setSave_path(tempDir.absolutePath)
// 4. SessionManager의 C++ 원시 핸들에 직접 접근하여 토렌트 추가 (비동기 권장)
session.swig().async_add_torrent(swigParams) 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<TorrentTask>) { fun addMagnet(magnetUri: String) {
if (tasks.isEmpty()) return try {
val error = error_code()
// 다운로드 중인 첫 번째 항목 찾기 val swigParams = libtorrent.parse_magnet_uri(magnetUri, error)
val activeTask = tasks.firstOrNull { !it.isPaused && it.progress < 100f } ?: tasks.first() if (error.value() == 0) {
swigParams.setSave_path(tempDir.absolutePath)
// 여러 개를 다운 중일 경우 텍스트 처리 swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed())
val title = if (tasks.size > 1) { session.swig().async_add_torrent(swigParams)
"${activeTask.name}${tasks.size - 1}" }
} else { } catch (e: Exception) { e.printStackTrace() }
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())
} }
private fun startForegroundService() { private fun startForegroundService() {
val channelId = "torrent_channel" val channelId = "torrent_channel"
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( notificationManager.createNotificationChannel(NotificationChannel(channelId, "Torrent Service", NotificationManager.IMPORTANCE_LOW))
channelId,
"Torrent Service",
// 💡 중요: 진행률이 1초마다 갱신되므로 소리가 나지 않게 LOW로 설정합니다.
NotificationManager.IMPORTANCE_LOW
)
notificationManager.createNotificationChannel(channel)
} }
notificationBuilder = NotificationCompat.Builder(this, channelId) notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download) // 다운로드 아이콘 .setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("토렌트 엔진 준비 중...") .setOnlyAlertOnce(true)
.setOnlyAlertOnce(true) // 💡 갱신될 때마다 알림음/진동이 울리지 않게 설정
.setOngoing(true) .setOngoing(true)
startForeground(NOTIFICATION_ID, notificationBuilder.build()) startForeground(NOTIFICATION_ID, notificationBuilder.build())
} }
private val _torrentTasks = kotlinx.coroutines.flow.MutableStateFlow<List<TorrentTask>>(emptyList())
val torrentTasks: kotlinx.coroutines.flow.StateFlow<List<TorrentTask>> = _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()
}
}
} }