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