diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b4199ed8..cdb1ed76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ android { android { sourceSets["main"].jniLibs.srcDirs("src/main/jniLibs") + } applicationVariants.all { @@ -95,6 +96,7 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + packagingOptions.resources.excludes.add("META-INF/*") packagingOptions.resources.excludes.add("mozilla/*") packagingOptions.resources.excludes.add("META-INF/*/*") @@ -114,9 +116,16 @@ android { doNotStrip("**/libffmpeg.zip.so") doNotStrip("**/libpython.zip.so") } + } + dependencies { + + implementation(fileTree(mapOf( + "dir" to "libs", + "include" to listOf("*.aar", "*.jar"), + ))) val kotlinVersion: String? by extra val realmVersion = "2.0.0" implementation ("androidx.appcompat:appcompat:1.7.1") @@ -172,12 +181,15 @@ dependencies { // Lifecycle KTX: viewLifecycleOwner.lifecycleScope 등을 사용하기 위해 필요 implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3") + + // implementation ("com.arthenica:ffmpeg-kit-full:4.5.LTS") // implementation ("com.arthenica:ffmpeg-kit-full:6.0-2") // implementation(files("libs/ffmpeg-kit-full-6.0-2.LTS.aar")) // implementation("com.arthenica:ffmpeg-kit-full-gpl:5.1") + implementation ("androidx.media:media:1.7.0") // implementation(project(":sdk")) // implementation ("me.everything:providers-android:1.0.1") diff --git a/app/libs/jlibtorrent-2.0.12.7.jar b/app/libs/jlibtorrent-2.0.12.7.jar new file mode 100644 index 00000000..cffb75fd Binary files /dev/null and b/app/libs/jlibtorrent-2.0.12.7.jar differ diff --git a/app/libs/jlibtorrent-android-arm-2.0.12.7.jar b/app/libs/jlibtorrent-android-arm-2.0.12.7.jar new file mode 100644 index 00000000..4ddda334 Binary files /dev/null and b/app/libs/jlibtorrent-android-arm-2.0.12.7.jar differ diff --git a/app/libs/jlibtorrent-android-arm64-2.0.12.7.jar b/app/libs/jlibtorrent-android-arm64-2.0.12.7.jar new file mode 100644 index 00000000..b5e0a038 Binary files /dev/null and b/app/libs/jlibtorrent-android-arm64-2.0.12.7.jar differ diff --git a/app/libs/jlibtorrent-android-x86-2.0.12.7.jar b/app/libs/jlibtorrent-android-x86-2.0.12.7.jar new file mode 100644 index 00000000..6f320f4c Binary files /dev/null and b/app/libs/jlibtorrent-android-x86-2.0.12.7.jar differ diff --git a/app/libs/jlibtorrent-android-x86_64-2.0.12.7.jar b/app/libs/jlibtorrent-android-x86_64-2.0.12.7.jar new file mode 100644 index 00000000..c55782d0 Binary files /dev/null and b/app/libs/jlibtorrent-android-x86_64-2.0.12.7.jar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb9ed1ab..37f5d6b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -148,6 +148,12 @@ android:enabled="true" android:exported="false" /> + + + currentUrl.includes(str)); + + if (port && !shouldExclude) { + toast("connect port on " + location.href); + time1 = setTimeout(autoScrollAndSave(false), 3500); } }) @@ -726,12 +742,12 @@ async function handleCommon() { .filter(src => src && src.startsWith('http')); const uniqueUrls = [...new Set(validImageUrls)]; - console.log(`Found ${'$'}{uniqueUrls.length} unique images to cache.`); +// console.log(`Found ${'$'}{uniqueUrls.length} unique images to cache.`); - for (const image of uniqueUrls) { - - } +// for (const image of uniqueUrls) { +// +// } // 3. 각 URL을 순회하며 Base64로 변환하고 즉시 네이티브로 전송 // (모든 작업을 병렬로 처리하지 않고 순차적(또는 하나씩)으로 보내 메모리 부담을 줄임) var idx = 0 @@ -770,7 +786,7 @@ async function handleCommon() { // 현재 스크롤 위치 + 화면에 보이는 높이가 전체 페이지 높이보다 크거나 같으면 // 페이지의 끝에 도달한 것입니다. if (Math.ceil(window.scrollY) + Math.ceil(window.innerHeight) >= (totalHeight * 0.9)) { - console.log("✅ 페이지 끝에 도달했습니다. 스크롤을 중지합니다."); +// console.log("✅ 페이지 끝에 도달했습니다. 스크롤을 중지합니다."); toast("✅ 페이지 끝에 도달했습니다. 스크롤을 중지합니다.") clearInterval(scrollInterval); // 반복 실행을 멈춥니다. getImg() @@ -781,7 +797,7 @@ async function handleCommon() { left: 0, behavior: 'smooth' // 부드럽게 스크롤하는 옵션 }); - toast(`smooth scrollBy ${scrollIncrement}, ${window.scrollY}, ${window.innerHeight}, ${totalHeight}`) +// toast(`smooth scrollBy ${scrollIncrement}, ${window.scrollY}, ${window.innerHeight}, ${totalHeight}`) } }, 2000); // 1000밀리초 = 1초 } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt b/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt index 9bd9bdac..1ea1f2ab 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt @@ -425,7 +425,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() { for (item in scoredItems) { if (item.type == "APP") { val app = realm.query("pkgName == $0", item.key).first().find() - if (app != null && !app.blockRecommend) { + + if (app != null && (app.blockRecommend == false || (binding.hidden.isSelected == app.blockRecommend))) { try { if(pm.getLaunchIntentForPackage(app.pkgName ?: "") != null) { unifiedList.add(RecommendationItem.AppItem(realm.copyFromRealm(app))) }} catch (e: Exception) { } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt index 5e22480a..2965d4e2 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -12,6 +12,8 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.text.SpannableStringBuilder +import android.text.style.RelativeSizeSpan import android.util.AttributeSet import android.util.Base64 import android.util.Log @@ -28,6 +30,7 @@ import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContentProviderCompat.requireContext import androidx.core.net.toUri import androidx.core.view.isVisible import bums.lunatic.launcher.BookmarkUploader @@ -43,6 +46,7 @@ import bums.lunatic.launcher.model.getRssData import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.utils.CommonUtils import bums.lunatic.launcher.utils.SimpleFingerGestures +import bums.lunatic.launcher.workers.TorrentService import bums.lunatic.launcher.workers.WorkersDb import com.google.android.material.textfield.TextInputEditText import com.google.gson.Gson @@ -75,6 +79,7 @@ import java.io.FileOutputStream import java.io.InputStream import java.text.SimpleDateFormat import java.util.Date +import kotlin.jvm.java // BWebview와 GeckoWeb을 통합한 클래스 open class GeckoWeb @JvmOverloads constructor( @@ -350,9 +355,15 @@ open class GeckoWeb @JvmOverloads constructor( markdownContents = null markdownUri = null if (url.startsWith("magnet:?")) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { - flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP - }) + val intent = Intent(context, TorrentService::class.java).apply { + putExtra("EXTRA_MAGNET_URI", url) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } } onPageStartCallback?.invoke(url) } @@ -604,6 +615,20 @@ open class GeckoWeb @JvmOverloads constructor( } private fun getFilterF() = String(Base64.decode("aHR0cHM6Ly9pamF2dG9ycmVudC5jb20=", Base64.DEFAULT)) - private fun Context.toast(msg: String) = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + + var toast : Toast? = null + private fun Context.toast(msg: String) { + if (toast==null) { + toast = Toast(this) + toast?.duration = Toast.LENGTH_SHORT + val biggerText = SpannableStringBuilder(msg) + biggerText.setSpan(RelativeSizeSpan(1.6f), 0, msg.length, 0) + val view: View = inflate(this, R.layout.simple_toast, null) + view.findViewById(R.id.text).text = biggerText + toast?.view = view + } + toast?.show() + } + private fun View.post(action: () -> Unit) = this.post(Runnable(action)) } \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt index 3998428d..42de62b4 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt @@ -343,10 +343,7 @@ open class NeoRssActivity : CommonActivity() { } handleBackPress() - updateLocationService() - -// showContents(binding.feeds.id) binding.floatingActionMenu.setOnTouchListener { v: View, e: MotionEvent -> if (binding.floatingActionMenu.isOpened) { binding.floatingActionMenu.close(true) @@ -431,12 +428,24 @@ open class NeoRssActivity : CommonActivity() { .replace(R.id.fragment_container, TokiFragment.newInstancePerplexity()) .commit() } - R.id.zota ->{ + R.id.zzalbang ->{ supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, BookmarkPagerFragment()) .commit() } + R.id.btn_x ->{ + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TokiFragment.newInstanceX()) + .commit() + } + + R.id.btn_torrent ->{ + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, TorrentListFragment()) + .commit() + } + R.id.close ->{ supportFragmentManager.findFragmentById(R.id.fragment_container)?.let { supportFragmentManager.beginTransaction() diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/TorrentListFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/TorrentListFragment.kt new file mode 100644 index 00000000..ebbeec3d --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/TorrentListFragment.kt @@ -0,0 +1,95 @@ +package bums.lunatic.launcher.home + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import bums.lunatic.launcher.R +import bums.lunatic.launcher.home.adapters.TorrentListAdapter +import bums.lunatic.launcher.workers.TorrentService +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class TorrentListFragment : BottomSheetDialogFragment() { + + private lateinit var adapter: TorrentListAdapter + private var torrentService: TorrentService? = null + private var isBound = false + + // 서비스 연결 콜백 + private val connection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as TorrentService.TorrentBinder + torrentService = binder.getService() + isBound = true + observeTorrentTasks() + } + + override fun onServiceDisconnected(arg0: ComponentName) { + isBound = false + torrentService = null + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_torrent_list, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val recyclerView = view.findViewById(R.id.recyclerViewTorrents) + + adapter = TorrentListAdapter( + onPauseResumeClick = { task -> + if (task.isPaused) { + torrentService?.resumeTorrent(task.infoHash) + } else { + torrentService?.pauseTorrent(task.infoHash) + } + }, + onCancelClick = { task -> + // UI에서 취소 시 파일까지 삭제할지 여부는 두 번째 파라미터로 결정합니다. (예: true) + torrentService?.removeTorrent(task.infoHash, deleteFile = true) + } + ) + + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.adapter = adapter + } + + override fun onStart() { + super.onStart() + // 프래그먼트가 보일 때 서비스 바인딩 + Intent(requireContext(), TorrentService::class.java).also { intent -> + requireContext().bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + // 프래그먼트가 숨겨질 때 바인딩 해제 + if (isBound) { + requireContext().unbindService(connection) + isBound = false + } + } + + private fun observeTorrentTasks() { + lifecycleScope.launch { + // 바인딩된 서비스의 Flow를 실시간 수집 + torrentService?.torrentTasks?.collectLatest { tasks -> + adapter.submitList(tasks) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt new file mode 100644 index 00000000..47db00fd --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt @@ -0,0 +1,55 @@ +// kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt +package bums.lunatic.launcher.home.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ProgressBar +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import bums.lunatic.launcher.R +import bums.lunatic.launcher.workers.TorrentTask + +class TorrentListAdapter( + private val onPauseResumeClick: (TorrentTask) -> Unit, + private val onCancelClick: (TorrentTask) -> Unit +) : RecyclerView.Adapter() { + + private var tasks = listOf() + + fun submitList(newTasks: List) { + tasks = newTasks + notifyDataSetChanged() + } + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val tvName: TextView = view.findViewById(R.id.tvName) // 레이아웃에 맞게 수정 필요 + val tvProgress: TextView = view.findViewById(R.id.tvProgress) + val progressBar: ProgressBar = view.findViewById(R.id.progressBar) + val btnPauseResume: ImageButton = view.findViewById(R.id.btnPauseResume) + val btnCancel: ImageButton = view.findViewById(R.id.btnCancel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + // res/layout/item_torrent_task.xml 을 만들어 사용하세요 + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_torrent_task, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val task = tasks[position] + holder.tvName.text = task.name + holder.progressBar.progress = task.progress.toInt() + holder.tvProgress.text = String.format("%.1f%%", task.progress) + + holder.btnPauseResume.setImageResource( + if (task.isPaused) android.R.drawable.ic_media_play else android.R.drawable.ic_media_pause + ) + + holder.btnPauseResume.setOnClickListener { onPauseResumeClick(task) } + holder.btnCancel.setOnClickListener { onCancelClick(task) } + } + + override fun getItemCount() = tasks.size +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt index 5736eb48..1e02ab82 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt @@ -149,14 +149,14 @@ class TokiFragment : Fragment(), PagedTextViewInterface { } } - fun newInstanceWebtoons(): TokiFragment = TokiFragment().apply { + fun newInstanceX(): TokiFragment = TokiFragment().apply { arguments = Bundle().apply { - putString(ARG_TYPE, "webtoon") - putInt(ARG_LAST_NUM, 468) - putString(ARG_NAME, "newtoki") + putString(ARG_TYPE, "X") + putInt(ARG_LAST_NUM, 143) + putString(ARG_NAME, "x") putString(ARG_DOT, "com") - putBoolean(ARG_USE_NUM_URL, true) - putBoolean(ARG_ENABLE_GESTURE, true) + putBoolean(ARG_USE_NUM_URL, false) + putBoolean(ARG_ENABLE_GESTURE, false) } } @@ -204,6 +204,17 @@ class TokiFragment : Fragment(), PagedTextViewInterface { putBoolean(ARG_ENABLE_GESTURE, true) } } + + fun newInstanceWebtoons(): TokiFragment = TokiFragment().apply { + arguments = Bundle().apply { + putString(ARG_TYPE, "webtoon") + putInt(ARG_LAST_NUM, 468) + putString(ARG_NAME, "newtoki") + putString(ARG_DOT, "com") + putBoolean(ARG_USE_NUM_URL, true) + putBoolean(ARG_ENABLE_GESTURE, true) + } + } } // --- Unified Gesture Implementation --- diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt new file mode 100644 index 00000000..7aab5c2f --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt @@ -0,0 +1,407 @@ +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.os.* +import androidx.annotation.RequiresApi +import bums.lunatic.launcher.utils.Blog +import com.frostwire.jlibtorrent.swig.error_code +import com.frostwire.jlibtorrent.swig.libtorrent +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 + +data class TorrentTask( + val infoHash: String, + val name: String, + val progress: Float, // 0.0 ~ 100.0 + val isPaused: Boolean +) + +class TorrentService : Service() { + + private lateinit var session: SessionManager + private val binder = TorrentBinder() + + // 경로 설정 + private val tempDir by lazy { File(getExternalFilesDir(null), "temp_torrents").apply { mkdirs() } } + private val resumeDir by lazy { File(getExternalFilesDir(null), "resume_data").apply { mkdirs() } } + + 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() + startForegroundService() + initLibTorrent() + startPolling() // 폴링 시작 + } + + private fun startPolling() { + serviceScope.launch { + while (isActive) { + // 1. SessionManager에서 SWIG 원시 객체(vector)로 핸들 목록을 가져옵니다. + val vector = session.swig().get_torrents() + 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 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() + ) + ) + } + + _torrentTasks.value = tasks + delay(1000) + } + } + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() // 서비스 종료 시 코루틴 정리 + } + + 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") + } + 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") + } + + // 서비스가 강제 종료되어도 시스템이 다시 살려내도록 START_STICKY 반환 + return START_STICKY + } + + // --- 기존 제어 기능들 아래에 추가 --- + fun resumeTorrent(infoHash: String) { + session.find(Sha1Hash(infoHash))?.resume() + } + + private fun initLibTorrent() { + session = SessionManager() + + session.addListener(object : AlertListener { + override fun types(): IntArray? = intArrayOf( + AlertType.TORRENT_FINISHED.swig(), + AlertType.METADATA_RECEIVED.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.TORRENT_FINISHED -> { + val ta = alert as TorrentFinishedAlert + moveToPublicDownload(ta.handle()) + } + AlertType.METADATA_RECEIVED -> { + val ma = alert as MetadataReceivedAlert + ma.handle().saveResumeData() + } + AlertType.SAVE_RESUME_DATA -> { + val ra = alert as SaveResumeDataAlert + saveResumeFile(ra.handle().infoHash().toString(), ra.params()) + } + else -> {} + } + } + }) + + session.start() + restoreExistingDownloads() + } + + /** 마그넷 추가 */ + fun addMagnet(magnetUri: String) { + try { + val error = error_code() + + // 1. SessionManager 대신 libtorrent 원시 함수로 마그넷 파싱 시도 + val swigParams = libtorrent.parse_magnet_uri(magnetUri, error) + + if (error.value() != 0) { + // 파싱 실패 시 어떤 이유로 실패했는지 정확한 에러 메시지 출력 + println("TorrentService: 마그넷 파싱 에러 - ${error.message()} / URI: $magnetUri") + return + } + + // 2. 파싱 성공 시, 다운로드 경로 설정 + swigParams.setSave_path(tempDir.absolutePath) + + // 3. 원시 세션에 비동기로 토렌트 추가 + session.swig().async_add_torrent(swigParams) + + println("TorrentService: 마그넷 추가 성공!") + + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** 상태 리스트 반환 */ + fun getStatusList(): List { + // SessionManager 대신 swig 원시 객체(C++ 레벨)에서 직접 벡터를 가져옵니다. + val vector = session.swig().get_torrents() + val list = 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 + } + + /** 제어 기능 */ + fun pauseTorrent(infoHash: String) { + session.find(Sha1Hash(infoHash))?.pause() + + } + + 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() + } + File(resumeDir, "$infoHash.resume").delete() + } + } + + @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 (torrentName.isNullOrEmpty() || torrentName == status.infoHash().toString()) { + println("TorrentService: 이동할 유효한 파일/폴더 이름을 찾지 못했습니다.") + return + } + + // 진짜 이름으로 임시 폴더 내의 실제 경로를 찾음 + val sourcePath = File(tempDir, torrentName) + if (!sourcePath.exists()) { + println("TorrentService: 원본 파일/폴더가 존재하지 않습니다 -> ${sourcePath.absolutePath}") + return + } + + // 1. 단일 파일인 경우 + if (sourcePath.isFile) { + copySingleFileToDownloads(sourcePath, Environment.DIRECTORY_DOWNLOADS) + } + // 2. 다중 파일(폴더)인 경우 + else if (sourcePath.isDirectory) { + sourcePath.walkTopDown().filter { it.isFile }.forEach { file -> + val relativeSubPath = file.parentFile?.absolutePath?.substringAfter(sourcePath.absolutePath) ?: "" + val destRelativePath = "${Environment.DIRECTORY_DOWNLOADS}/$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) { + 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() + } + } + + 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로 자동 변환합니다) + swigParams.setSave_path(tempDir.absolutePath) + + // 4. SessionManager의 C++ 원시 핸들에 직접 접근하여 토렌트 추가 (비동기 권장) + session.swig().async_add_torrent(swigParams) + + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + override fun onBind(intent: Intent): IBinder = binder + + private fun startForegroundService() { + val channelId = "torrent_channel" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, "Torrent Service", NotificationManager.IMPORTANCE_LOW) + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + val notification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Torrent Manager Active") + .build() + startForeground(101, notification) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt index 0aa2a30f..8614de83 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt @@ -72,7 +72,8 @@ object WorkersDb { WeatherForcast::class, Location::class, Current::class, Forecast::class, Condition::class, Forecastday::class, Day::class, Astro::class, Hour::class, LocationLog::class, LastInfo::class, HistoryItem::class, ReaderConfig::class, ContentsCollection::class, ContentsPageInfo::class, - WidgetData::class,AppUsageLog::class + WidgetData::class,AppUsageLog::class, + ) //,UserActionModel::class diff --git a/app/src/main/res/color/bottom_option.xml b/app/src/main/res/color/bottom_option.xml new file mode 100644 index 00000000..4aaaefde --- /dev/null +++ b/app/src/main/res/color/bottom_option.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/simple_bg.xml b/app/src/main/res/drawable/simple_bg.xml index 19f69e22..de82f80c 100644 --- a/app/src/main/res/drawable/simple_bg.xml +++ b/app/src/main/res/drawable/simple_bg.xml @@ -1,6 +1,7 @@ - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_torrent_list.xml b/app/src/main/res/layout/fragment_torrent_list.xml new file mode 100644 index 00000000..4cf562b7 --- /dev/null +++ b/app/src/main/res/layout/fragment_torrent_list.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_torrent_task.xml b/app/src/main/res/layout/item_torrent_task.xml new file mode 100644 index 00000000..1a004a49 --- /dev/null +++ b/app/src/main/res/layout/item_torrent_task.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/rss_activity.xml b/app/src/main/res/layout/rss_activity.xml index 54f03f9a..05054124 100644 --- a/app/src/main/res/layout/rss_activity.xml +++ b/app/src/main/res/layout/rss_activity.xml @@ -144,14 +144,30 @@ android:layout_height="20dp"/> + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 58a6b5bd..f268b268 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -44,7 +44,7 @@ wrap_content 35dp center - @color/finestSilver + @color/tabs_black visible