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
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<List<TorrentTask>>(emptyList())
val torrentTasks: StateFlow<List<TorrentTask>> = _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<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()
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<TorrentStatus> {
// SessionManager 대신 swig 원시 객체(C++ 레벨)에서 직접 벡터를 가져옵니다.
val vector = session.swig().get_torrents()
val list = mutableListOf<TorrentStatus>()
/**
* 리스너에 의해 호출되는 UI 데이터 갱신 함수
*/
private fun refreshTorrentStats() {
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()) {
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<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)
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<TorrentTask>) {
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<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()
}
}
}