This commit is contained in:
lunaticbum 2026-05-20 17:57:56 +09:00
parent 257ddb6776
commit 378f6495be
14 changed files with 553 additions and 349 deletions

View File

@ -177,6 +177,8 @@ dependencies {
// implementation 'com.vladsch.flexmark:flexmark-all:0.64.8' // implementation 'com.vladsch.flexmark:flexmark-all:0.64.8'
// implementation("org.opencv:opencv-android:4.11.0") // implementation("org.opencv:opencv-android:4.11.0")
// build.gradle에 추가 // build.gradle에 추가
implementation ("androidx.emoji2:emoji2:1.4.0")
implementation ("com.googlecode.juniversalchardet:juniversalchardet:1.0.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// implementation ("com.github.aeonSolutions:FloatingActionButtonMenuDrag:1.1") // implementation ("com.github.aeonSolutions:FloatingActionButtonMenuDrag:1.1")
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4") implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")

View File

@ -132,7 +132,7 @@
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:excludeFromRecents="true" android:excludeFromRecents="false"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:exported="false"> android:exported="false">
</activity> </activity>

View File

@ -1,9 +1,9 @@
(function() { //(function() {
var meta = document.querySelector('meta[name=viewport]'); // var meta = document.querySelector('meta[name=viewport]');
if (!meta) { // if (!meta) {
meta = document.createElement('meta'); // meta = document.createElement('meta');
meta.name = 'viewport'; // meta.name = 'viewport';
document.head.appendChild(meta); // document.head.appendChild(meta);
} // }
meta.setAttribute('content', 'width=device-width, initial-scale=1.0'); // meta.setAttribute('content', 'width=device-width, initial-scale=1.0');
})(); //})();

View File

@ -13,7 +13,7 @@
}, },
"content_scripts": [ "content_scripts": [
{ {
"run_at": "document_end", "run_at": "document_start",
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["messaging.js","inject-viewport.js"] "js": ["messaging.js","inject-viewport.js"]
} }

View File

@ -1,5 +1,6 @@
function pubDateNumber(tdateTime) { function pubDateNumber(tdateTime) {
let date = new Date(); let date = new Date();
let dateTime = date.getTime(); let dateTime = date.getTime();
@ -55,11 +56,11 @@ port.onMessage.addListener(response => {
var type= response["type"]; var type= response["type"];
switch (type) { switch (type) {
case "GO_TO_SUBTITLE_DETAIL": { case "GO_TO_SUBTITLE_DETAIL": {
const detailUrl = response["url"]; const detailUrl = response["url"];
location.href = detailUrl; // 상세 페이지로 이동 location.href = detailUrl; // 상세 페이지로 이동
break; break;
} }
case "SEARCH_SUBTITLE_CAT": { case "SEARCH_SUBTITLE_CAT": {
const query = response["query"]; const query = response["query"];
const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`; const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`;
@ -72,7 +73,7 @@ port.onMessage.addListener(response => {
behavior: 'smooth' behavior: 'smooth'
}); });
} }
break; break;
case "SEEK_NEXT": // 5초 앞으로 case "SEEK_NEXT": // 5초 앞으로
{ {
let btn = let btn =
@ -469,13 +470,9 @@ if(document.querySelector(".list-body") !== null) {
var listBody = null var listBody = null
try {listBody = document.querySelector(".list-body");}catch (e) {} try {listBody = document.querySelector(".list-body");}catch (e) {}
getList(listBody.children) getList(listBody.children)
} else if(document.querySelector(".novel-eps") !== null) { }
//document.querySelector(".novel-eps").children
removeSpecificGifs() if(document.querySelector("#novel_content") !== null){
var listBody = null
try {listBody = document.querySelector(".novel-eps");}catch (e) {}
getList2(listBody.children)
} else if(document.querySelector("#novel_content") !== null){
removeSpecificGifs() removeSpecificGifs()
var title = null var title = null
var contents = null var contents = null
@ -492,24 +489,9 @@ if(document.querySelector(".list-body") !== null) {
} }
); );
} }
} else if(document.querySelector('[style^="--novel"]') !== null){
removeSpecificGifs()
var title = null
var contents = null
try {title = toonTitle(document.querySelector(".page-desc")); }catch (e) {}
try {contents = toonContents(document.querySelector('[style^="--novel"]'))}catch (e) {}
if (toonTitle !== undefined && toonContents !== undefined && toonTitle !== "" && toonContents !=="") {
sendMessage(
{
type: "BookContents",
book : {
chapterTitle : title,
bookContents : contents
}
}
);
}
} }
if(document.querySelector("#html_encoder_div")) { if(document.querySelector("#html_encoder_div")) {
sendMessage( sendMessage(
{ {
@ -814,7 +796,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (document.readyState === 'complete') { if (document.readyState === 'complete') {
sendCookiesToNative(); sendCookiesToNative();
try{removeSpecificGifs();}catch(e){}
} else { } else {
// 2. 아직 로딩 중이라면 window.onload(모든 리소스 로드 완료) 시점에 실행 // 2. 아직 로딩 중이라면 window.onload(모든 리소스 로드 완료) 시점에 실행
window.addEventListener('load', sendCookiesToNative); window.addEventListener('load', sendCookiesToNative);
} }
@ -860,27 +844,36 @@ function extractSubtitleList() {
} }
function scrollToEndAndExtract() { function scrollToEndAndExtract() {
if (location.host.includes("subtitlecat.com")) { if (location.host.includes("subtitlecat.com")) {
const scrollStep = 800; // 한 번에 스크롤할 양 const scrollStep = 800; // 한 번에 스크롤할 양
const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms) const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms)
function step() { function step() {
const prevScrollY = window.scrollY; const prevScrollY = window.scrollY;
window.scrollBy(0, scrollStep); window.scrollBy(0, scrollStep);
console.log("ONSTEPS");
// 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
setTimeout(() => {
if (window.scrollY === prevScrollY ||
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
if (location.host.includes("subtitlecat.com")) {
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
} else {
}
// 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작 } else {
setTimeout(() => { step(); // 아직 끝이 아니면 다음 스크롤 진행
if (window.scrollY === prevScrollY || }
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) { }, scrollDelay);
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작"); }
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
} else { step()
step(); // 아직 끝이 아니면 다음 스크롤 진행
} } else {
}, scrollDelay); console.log(`is FAIL NOVEL ${location.href.includes("/novel/")}`);
} }
step();
}
} }

View File

@ -580,10 +580,10 @@ open class LauncherActivity : CommonActivity() {
} }
}) })
requestSmsPermissionLauncher.launch(arrayOf( // requestSmsPermissionLauncher.launch(arrayOf(
android.Manifest.permission.RECEIVE_SMS, // android.Manifest.permission.RECEIVE_SMS,
android.Manifest.permission.READ_SMS // android.Manifest.permission.READ_SMS
)) // ))
handleSharedIntent(intent) handleSharedIntent(intent)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@ -614,7 +614,9 @@ open class LauncherActivity : CommonActivity() {
putExtra("WIFI_STATE", isWifiConnected) putExtra("WIFI_STATE", isWifiConnected)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent) try {
startForegroundService(intent)
} catch (e: Exception){e.printStackTrace()}
} else { } else {
startService(intent) startService(intent)
} }
@ -634,35 +636,35 @@ open class LauncherActivity : CommonActivity() {
} }
connectivityManager.registerNetworkCallback(request, networkCallback!!) connectivityManager.registerNetworkCallback(request, networkCallback!!)
} }
private var smsReceiver: SmsReceiver? = null // private var smsReceiver: SmsReceiver? = null
// 권한 요청 결과 처리기 // 권한 요청 결과 처리기
private val requestSmsPermissionLauncher = registerForActivityResult( // private val requestSmsPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() // ActivityResultContracts.RequestMultiplePermissions()
) { permissions -> // ) { permissions ->
val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false // val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false
if (granted) { // if (granted) {
Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행") // Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행")
registerSmsDynamicReceiver() // registerSmsDynamicReceiver()
} // }
} // }
//
private fun registerSmsDynamicReceiver() { // private fun registerSmsDynamicReceiver() {
if (smsReceiver == null) { // if (smsReceiver == null) {
smsReceiver = SmsReceiver() // smsReceiver = SmsReceiver()
val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply { // val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply {
priority = 2147483647 // 시스템 최우선 순위 // priority = 2147483647 // 시스템 최우선 순위
} // }
//
// Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED // // Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED
// 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다. // // 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED) // registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED)
} else { // } else {
registerReceiver(smsReceiver, filter) // registerReceiver(smsReceiver, filter)
} // }
} // }
} // }
private fun updateWidgetOptions(appWidgetId: Int, hostView: AppWidgetHostView) { private fun updateWidgetOptions(appWidgetId: Int, hostView: AppWidgetHostView) {
val width = hostView.layoutParams.width val width = hostView.layoutParams.width
@ -966,10 +968,10 @@ open class LauncherActivity : CommonActivity() {
private lateinit var connectivityManager: ConnectivityManager private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null
override fun onDestroy() { override fun onDestroy() {
smsReceiver?.let { // smsReceiver?.let {
unregisterReceiver(it) // unregisterReceiver(it)
smsReceiver = null // smsReceiver = null
} // }
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) } networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
super.onDestroy() super.onDestroy()

View File

@ -65,6 +65,7 @@ import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.mozilla.gecko.util.ThreadUtils import org.mozilla.gecko.util.ThreadUtils
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate
@ -374,7 +375,16 @@ open class GeckoWeb @JvmOverloads constructor(
// [Navigation Delegate] // [Navigation Delegate]
private val navigationDelegate = object : GeckoSession.NavigationDelegate { private val navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onLoadRequest(
session: GeckoSession,
request: GeckoSession.NavigationDelegate.LoadRequest
): GeckoResult<AllowOrDeny?>? {
if (request.target != 1 && request.uri.contains("workupload")) {
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(lastedUrl), request.uri)
return null
}
return super.onLoadRequest(session, request)
}
override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? { override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
Uri.parse(uri)?.let { Uri.parse(uri)?.let {
@ -388,6 +398,7 @@ open class GeckoWeb @JvmOverloads constructor(
showNewSessionDialog(uri) showNewSessionDialog(uri)
} }
} }
return super.onNewSession(session, uri) return super.onNewSession(session, uri)
} }
@ -547,10 +558,10 @@ open class GeckoWeb @JvmOverloads constructor(
.allowJavascript(true) .allowJavascript(true)
.contextId("JUST_ONE") .contextId("JUST_ONE")
.usePrivateMode(false) .usePrivateMode(false)
.build() .build()
val session = GeckoSession(sessionSettings) val session = GeckoSession(sessionSettings)
session.open(runtime) session.open(runtime)
this.setSession(session) this.setSession(session)

View File

@ -101,6 +101,10 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
} }
} }
override fun usePageInfo(): Boolean {
return false
}
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
Blog.LOGD(log = "onConfigurationChanged ${this::class.java.name} >> newConfig ${newConfig}") Blog.LOGD(log = "onConfigurationChanged ${this::class.java.name} >> newConfig ${newConfig}")
@ -184,9 +188,9 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun newInstanceNovels(): TokiFragment = TokiFragment().apply { fun newInstanceNovels(): TokiFragment = TokiFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_TYPE, "book") putString(ARG_TYPE, "web")
putInt(ARG_LAST_NUM, 468) putInt(ARG_LAST_NUM, 468)
putString(ARG_NAME, "ntk01") putString(ARG_NAME, "sbxh2")
putString(ARG_DOT, "com/novel") putString(ARG_DOT, "com/novel")
putBoolean(ARG_USE_NUM_URL, false) putBoolean(ARG_USE_NUM_URL, false)
putBoolean(ARG_ENABLE_GESTURE, true) putBoolean(ARG_ENABLE_GESTURE, true)
@ -1341,7 +1345,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
} }
fun onLoadedContents(aContents: String) { fun onLoadedContents(aContents: String) {
Blog.LOGE("onLoadedContents ") Blog.LOGE("onLoadedContents $aContents")
binding.pagedLayer.let { view -> binding.pagedLayer.let { view ->
view.post { view.post {
if (aContents.length > 10) { if (aContents.length > 10) {
@ -1421,6 +1425,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
var currentChapter: Int = 0 var currentChapter: Int = 0
fun onFindTitle(contents: String) { fun onFindTitle(contents: String) {
Blog.LOGE("contents $contents")
binding.lunaticBrowser.binding.tvTitle.text = contents binding.lunaticBrowser.binding.tvTitle.text = contents
binding.lunaticBrowser.binding.tvTitle.setOnClickListener { binding.lunaticBrowser.binding.tvTitle.setOnClickListener {
val builder = AlertDialog.Builder(requireContext()) val builder = AlertDialog.Builder(requireContext())

View File

@ -36,6 +36,7 @@ interface PagedTextViewInterface {
fun onSwipeDown(touchCount : Int) fun onSwipeDown(touchCount : Int)
fun onSwipeUp(touchCount : Int) fun onSwipeUp(touchCount : Int)
fun onLongClick() fun onLongClick()
fun usePageInfo() : Boolean
} }
interface PagedTextGenerateInterface { interface PagedTextGenerateInterface {
@ -172,8 +173,11 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
mainTextView = findViewById(R.id.first_view) mainTextView = findViewById(R.id.first_view)
sencondTextView = findViewById(R.id.sencond_view) sencondTextView = findViewById(R.id.sencond_view)
currentPageTextView = findViewById(R.id.current_page) currentPageTextView = findViewById(R.id.current_page)
if (mPagedTextViewInterface?.usePageInfo() ?: false) {
currentPageTextView?.text = "" } else {
currentPageTextView?.text = ""
}
hanler.removeCallbacks(touchTimeover) hanler.removeCallbacks(touchTimeover)
setOnLongClickListener { v -> setOnLongClickListener { v ->
mPagedTextViewInterface?.onLongClick() mPagedTextViewInterface?.onLongClick()
@ -350,7 +354,11 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
} else { } else {
(pageList?.size ?: 0) - 1 (pageList?.size ?: 0) - 1
} }
currentPageTextView?.text = "${realPage + 1}/${pageList?.size ?: 0 + 1}" if (mPagedTextViewInterface?.usePageInfo() ?: false) {
} else {
currentPageTextView?.text = "${realPage + 1}/${pageList?.size ?: 0 + 1}"
}
mainTextView?.text = pageList?.get(realPage) ?: "NONE" mainTextView?.text = pageList?.get(realPage) ?: "NONE"
// Blog.LOGE("pageList.get($realPage) ${pageList?.get(realPage)}") // Blog.LOGE("pageList.get($realPage) ${pageList?.get(realPage)}")

View File

@ -1,43 +1,43 @@
package bums.lunatic.launcher.player package bums.lunatic.launcher.player
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.view.KeyEvent
import android.os.Looper
import android.view.View import android.view.View
import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import bums.lunatic.launcher.R import bums.lunatic.launcher.R
import bums.lunatic.launcher.home.tokiz.TouchArea import bums.lunatic.launcher.home.tokiz.TouchArea
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface
import bums.lunatic.launcher.model.Translation import bums.lunatic.launcher.utils.FileUtils.detectFileEncoding
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.FileUtils
import bums.lunatic.launcher.utils.FileUtils.charsets
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
import com.frostwire.jlibtorrent.swig.operation_t.file
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.Charset import java.nio.charset.Charset
import java.text.SimpleDateFormat import kotlin.math.max
class DocumentViewerActivity : AppCompatActivity(), PagedTextViewInterface {
class DocumentViewerActivity : AppCompatActivity() {
private lateinit var pagedLayout: PagedTextLayout private lateinit var pagedLayout: PagedTextLayout
private lateinit var header: View private lateinit var header: View
private val handler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideOverlay() }
private var currentFile: File? = null private var currentFile: File? = null
private var currentRawBytes: ByteArray? = null private var encoding: String = "utf-8"
private lateinit var pageIndexer: PageIndexer
private var currentPageIndex = 0
// SharedPreferences 정의 (마지막 페이지 저장용)
private val sharedPreferences by lazy {
getSharedPreferences("DocumentViewerPrefs", Context.MODE_PRIVATE)
}
private val raf by lazy {
if (currentFile != null) RandomAccessFile(currentFile, "r")
else throw IllegalStateException("File is not initialized")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -45,248 +45,250 @@ class DocumentViewerActivity : AppCompatActivity() {
pagedLayout = findViewById(R.id.pagedTextLayout) pagedLayout = findViewById(R.id.pagedTextLayout)
header = findViewById(R.id.layoutDocHeader) header = findViewById(R.id.layoutDocHeader)
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
// 필요하다면 줄 간격이나 자간도 기본값으로 조정하여 가독성을 높입니다. pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
pagedLayout.setLineSpacing(20f) // 1.2배 정도의 줄간격 pagedLayout.setLineSpacing(20f)
pagedLayout.setLetterSpacing(0f) // 기본 자간 pagedLayout.setLetterSpacing(0f)
val filePath = intent.getStringExtra("FILE_PATH") ?: return finish() val filePath = intent.getStringExtra("FILE_PATH") ?: return finish()
currentFile = File(filePath) currentFile = File(filePath)
currentRawBytes = currentFile?.readBytes() pagedLayout.mPagedTextViewInterface = this
// 초기 자동 로드 pagedLayout.post {
pagedLayout.text = FileUtils.readTextWithEncoding(currentFile!!) currentFile?.let {
initializeReader()
// 💡 인코딩 수동 선택 버튼 (예: 헤더의 특정 아이콘 클릭 시)
findViewById<View>(R.id.btnChangeEncoding).setOnClickListener {
showAdvancedEncodingDialog()
}
currentFile?.let { currentFile ->
val content = readTextWithEncoding(currentFile)
pagedLayout.text = content
// 2. 헤더 정보 표시
findViewById<TextView>(R.id.tvDocTitle).text = currentFile.name
val dateStr = SimpleDateFormat("yyyy-MM-dd HH:mm").format(currentFile.lastModified())
findViewById<TextView>(R.id.tvDocMeta).text = "수정일: $dateStr | 크기: ${currentFile.length() / 1024} KB"
}
showOverlay()
// 3. 제스처 인터페이스 설정
pagedLayout.mPagedTextViewInterface = object : PagedTextViewInterface {
override fun onTouch(touchArea: TouchArea) {
if (header.visibility == View.VISIBLE) hideOverlay() else showOverlay()
} }
override fun onSwipeLeft(count: Int) { pagedLayout.doNext() }
override fun onSwipeRight(count: Int) { pagedLayout.doPrev() }
override fun onLongClick() {
// finish()
} // 잠깐 확인용이므로 롱클릭 시 종료
override fun onTimeoverTouch() {}
override fun onSwipeDown(count: Int) {}
override fun onSwipeUp(count: Int) {}
} }
} }
private val fullEncodingList = mapOf(
"추천 (자동)" to listOf("UTF-8", "GB18030", "CP949", "BIG5", "Shift_JIS"),
"한국/중국/일본" to listOf("EUC-KR", "GBK", "EUC-JP", "ISO-2022-JP"),
"영어/서유럽" to listOf("ISO-8859-1", "Windows-1252", "ISO-8859-15"),
"유니코드/기타" to listOf("UTF-16LE", "UTF-16BE", "UTF-32")
)
private val flatEncodingList = fullEncodingList.values.flatten().distinct()
private fun showAdvancedEncodingDialog() {
var selectedIndex = 0
val items = flatEncodingList.toTypedArray()
android.app.AlertDialog.Builder(this) private fun initializeReader() {
.setTitle("인코딩 선택 (화면을 보며 확인하세요)") lifecycleScope.launch {
.setSingleChoiceItems(items, -1) { _, which -> currentFile?.let { file ->
selectedIndex = which encoding = detectFileEncoding(file)
applyPreviewEncoding(items[which]) pageIndexer = PageIndexer(
file, encoding,
pagedLayout.mainTextView!!.paint,
(pagedLayout.mainTextView!!.width * 0.8).toInt(),
(pagedLayout.mainTextView!!.height * 0.8).toInt()
)
// 해당 파일 패스로 저장된 마지막 페이지 인덱스 가져오기 (없으면 0)
val savedPageIndex = sharedPreferences.getInt(file.absolutePath, 0)
var hasRestoredPage = false
var find10p = false
// 백그라운드에서 인덱스 생성
pageIndexer.buildIndex { progress ->
runOnUiThread {
val currentOffsetsSize = pageIndexer.pageOffsets.size
// 1. 저장된 목표 페이지 인덱스 이상으로 인덱싱이 확보되었을 때 즉시 복구
if (!hasRestoredPage && currentOffsetsSize > savedPageIndex) {
showPage(savedPageIndex)
hasRestoredPage = true
}
// 2. 저장된 페이지가 0번인데 아직 복구 안 된 경우, 기존 10% 진행 시점 방어 코드 동작
else if (!hasRestoredPage && !find10p && progress > 10) {
showPage(0)
find10p = true
}
pagedLayout.currentPageTextView?.text = "${currentPageIndex + 1} / $currentOffsetsSize"
}
}
} }
.setPositiveButton("확정 및 처리") { _, _ -> }
// 💡 인코딩 확정 후 다음 액션 선택 }
showActionSelectionDialog(items[selectedIndex]) override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// 인덱서가 초기화되지 않았다면 볼륨키가 기본 동작(볼륨 조절)을 하도록 내버려 둡니다.
if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) {
return super.onKeyDown(keyCode, event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
// 볼륨 업 키 -> 이전 페이지로 이동 (기존 온스wipeRight 로직 활용)
if (currentPageIndex > 0) {
showPage(currentPageIndex - 1)
}
true // 시스템 볼륨 UI가 뜨지 않도록 이벤트를 차단(소비)합니다.
} }
.setNeutralButton("다음 인코딩") { _, _ -> KeyEvent.KEYCODE_VOLUME_DOWN -> {
selectedIndex = (selectedIndex + 1) % items.size // 볼륨 다운 키 -> 다음 페이지로 이동 (기존 온스wipeLeft 로직 활용)
applyPreviewEncoding(items[selectedIndex]) if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
// 다이얼로그 유지를 위해 재호출 로직 필요 시 추가 showPage(currentPageIndex + 1)
}
true // 시스템 볼륨 UI가 뜨지 않도록 이벤트를 차단(소비)합니다.
} }
.setNegativeButton("취소", null) else -> super.onKeyDown(keyCode, event)
}
}
private fun showPage(pageIndex: Int) {
if (!::pageIndexer.isInitialized || pageIndex !in pageIndexer.pageOffsets.indices) return
currentPageIndex = pageIndex
// 페이지가 정상적으로 변경될 때마다 최종 페이지 인덱스를 SharedPreferences에 저장
currentFile?.let { file ->
sharedPreferences.edit().putInt(file.absolutePath, currentPageIndex).apply()
}
val startOffset = pageIndexer.pageOffsets[pageIndex]
val endOffset = if (pageIndex + 1 < pageIndexer.pageOffsets.size) {
pageIndexer.pageOffsets[pageIndex + 1]
} else {
raf.length()
}
val pageSize = (endOffset - startOffset).toInt()
val buffer = ByteArray(pageSize)
raf.seek(startOffset)
raf.read(buffer)
val pageText = String(buffer, Charset.forName(encoding))
pagedLayout.text = pageText
pagedLayout.currentPageTextView?.text = "${currentPageIndex + 1} / ${pageIndexer.pageOffsets.size}"
}
override fun onTouch(touchArea: TouchArea) {
if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) return
if (touchArea == TouchArea.Center) {
// showPageSeekDialog()
showChapterListDialog()
}
}
private fun moveToNextChapter() {
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) return
// 현재 페이지보다 뒤에 있는 가장 첫 번째 챕터를 찾습니다.
val nextChapter = pageIndexer.chapters.firstOrNull { it.pageIndex > currentPageIndex }
if (nextChapter != null) {
showPage(nextChapter.pageIndex)
} else {
// 더 이상 다음 챕터가 없을 때 처리 (예: 토스트 알림)
}
}
private fun showChapterListDialog() {
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) {
// 챕터가 아직 파싱되지 않았거나 없는 경우 예외 처리
return
}
val chapterTitles = pageIndexer.chapters.map { "${it.title} (p.${it.pageIndex + 1})" }.toTypedArray()
AlertDialog.Builder(this)
.setTitle("목차 (Chapters)")
.setItems(chapterTitles) { dialog, which ->
// 사용자가 선택한 챕터의 pageIndex로 바로 이동
val targetChapter = pageIndexer.chapters[which]
showPage(targetChapter.pageIndex)
dialog.dismiss()
}
.setNegativeButton("닫기", null)
.show() .show()
} }
private fun showActionSelectionDialog(charsetName: String) { /**
android.app.AlertDialog.Builder(this) * 이전 챕터로 이동하는 함수
.setTitle("처리 방식 선택") */
.setMessage("[$charsetName] 인코딩으로 확인되었습니다.\n어떤 방식으로 저장할까요?") private fun moveToPrevChapter() {
.setPositiveButton("번역 후 저장") { _, _ -> if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) return
// 💡 언어 감지 후 번역 진행
detectLanguageAndTranslate(charsetName) // 현재 페이지보다 앞에 있는 챕터들을 역순으로 탐색하여 가장 가까운 챕터를 찾습니다.
} // 단, 현재 딱 챕터 시작점에 걸려있다면 그 전 챕터로 가야 하므로 기준을 '현재 페이지 - 1'로 잡는 것이 자연스럽습니다.
.setNeutralButton("그냥 이대로 저장") { _, _ -> val targetIndex = if (currentPageIndex > 0) currentPageIndex - 1 else 0
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName) val prevChapter = pageIndexer.chapters.lastOrNull { it.pageIndex <= targetIndex }
}
.setNegativeButton("취소", null) if (prevChapter != null) {
.show() showPage(prevChapter.pageIndex)
} else {
// 이미 첫 번째 챕터 앞이거나 챕터가 없을 때 처리 -> 맨 처음 페이지로
showPage(0)
}
} }
private fun detectLanguageAndTranslate(charsetName: String) { private fun showPageSeekDialog() {
val textSample = pagedLayout.text.toString().take(2000) // 💡 정확도를 위해 앞부분 2000자 샘플링 val dialogView = layoutInflater.inflate(R.layout.dialog_page_seek, null)
if (textSample.isBlank()) return val tvDialogProgress = dialogView.findViewById<TextView>(R.id.tvDialogProgress)
val seekBarPage = dialogView.findViewById<SeekBar>(R.id.seekBarPage)
val languageIdentifier = LanguageIdentification.getClient() val totalPages = pageIndexer.pageOffsets.size
val currentPercent = if (totalPages > 1) {
((currentPageIndex.toFloat() / (totalPages - 1)) * 100).toInt()
} else {
0
}
languageIdentifier.identifyLanguage(textSample) seekBarPage.progress = currentPercent
.addOnSuccessListener { languageCode -> tvDialogProgress.text = "$currentPercent% (${currentPageIndex + 1} / $totalPages)"
if (languageCode == "und") {
Toast.makeText(this, "언어를 판별할 수 없습니다. 기본값(중국어)으로 시도합니다.", Toast.LENGTH_SHORT).show() var targetPageIndex = currentPageIndex
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE)
} else if (languageCode == "ko") { seekBarPage.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
Toast.makeText(this, "이미 한국어 문서입니다. 번역 없이 저장합니다.", Toast.LENGTH_SHORT).show() override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName) if (fromUser) {
} else { targetPageIndex = if (totalPages > 1) {
// 💡 감지된 언어 코드를 번역기 코드로 변환 ((progress.toFloat() / 100) * (totalPages - 1)).toInt().coerceIn(0, totalPages - 1)
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
if (sourceLang != null) {
translateAndSaveByParagraph(charsetName, sourceLang)
} else { } else {
Toast.makeText(this, "지원하지 않는 언어($languageCode)입니다.", Toast.LENGTH_SHORT).show() 0
} }
tvDialogProgress.text = "$progress% (${targetPageIndex + 1} / $totalPages)"
} }
} }
.addOnFailureListener { override fun onStartTrackingTouch(seekBar: SeekBar?) {}
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE) // 실패 시 기본값 override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
AlertDialog.Builder(this)
.setTitle("페이지 이동")
.setView(dialogView)
.setPositiveButton("이동") { dialog, _ ->
showPage(targetPageIndex)
dialog.dismiss()
} }
.setNegativeButton("취소") { dialog, _ ->
dialog.dismiss()
}
.show()
} }
private fun translateAndSaveByParagraph(charsetName: String, sourceLang: String) { override fun onSwipeLeft(count: Int) {
val originalFile = currentFile ?: return if (!::pageIndexer.isInitialized) return
if (count > 2) {moveToNextChapter()}
val pageSizeToMove = max((count - 1) * 10, 1)
// 💡 감지된 sourceLang 적용 if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
val options = TranslatorOptions.Builder() val targetPage = (currentPageIndex + pageSizeToMove).coerceAtMost(pageIndexer.pageOffsets.size - 1)
.setSourceLanguage(sourceLang) showPage(targetPage)
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
.build()
val translator = com.google.mlkit.nl.translate.Translation.getClient(options)
val newFileName = "${originalFile.nameWithoutExtension}_translated_ko.txt"
val newFile = File(originalFile.parent, newFileName)
Toast.makeText(this, "[$sourceLang] 번역 작업 시작...", Toast.LENGTH_SHORT).show()
CoroutineScope(Dispatchers.IO).launch {
try {
Tasks.await(translator.downloadModelIfNeeded())
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
val paragraphBuilder = StringBuilder()
reader.forEachLine { line ->
if (line.isBlank()) {
if (paragraphBuilder.isNotEmpty()) {
// 💡 문단 단위 번역 (문맥 유지)
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
writer.newLine()
writer.newLine()
paragraphBuilder.clear()
}
} else {
paragraphBuilder.append(line).append(" ")
}
if (paragraphBuilder.length > 1000) { // 💡 너무 긴 문단 방지
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
paragraphBuilder.clear()
}
}
if (paragraphBuilder.isNotEmpty()) {
val translated = Tasks.await(translator.translate(paragraphBuilder.toString()))
writer.write(translated)
}
}
}
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 완료!", Toast.LENGTH_SHORT).show()
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(this@DocumentViewerActivity, "번역 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
} finally {
translator.close()
}
} }
} }
var lastEncoded = "" override fun onSwipeRight(count: Int) {
private fun applyPreviewEncoding(charset: String) { if (!::pageIndexer.isInitialized) return
val bytes = currentRawBytes ?: return val pageSizeToMove = max((count - 1) * 10, 1)
lastEncoded = charset
if (currentPageIndex > 0) {
val targetPage = (currentPageIndex - pageSizeToMove).coerceAtLeast(0)
showPage(targetPage)
}
}
override fun onLongClick() {}
override fun usePageInfo(): Boolean = true
override fun onTimeoverTouch() {}
override fun onSwipeDown(count: Int) {}
override fun onSwipeUp(count: Int) {}
override fun onDestroy() {
super.onDestroy()
try { try {
val decoder = Charset.forName(charset).newDecoder() raf.close()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
.replaceWith("")
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
pagedLayout.text = text
} catch (e: Exception) { } catch (e: Exception) {
Blog.LOGE("미리보기 실패: $charset") e.printStackTrace()
} }
} }
private fun saveCurrentTextAsUtf8(validText: String, charsetName: String) {
val originalFile = currentFile ?: return
try {
// 1. 새 파일명 생성
val newFileName = "${originalFile.nameWithoutExtension}_${charsetName}.${originalFile.extension}"
val newFile = File(originalFile.parent, newFileName)
// 2. 스트림을 이용한 라인 단위 읽기 및 쓰기
// 원본을 선택한 인코딩(charsetName)으로 읽어서 UTF-8로 씁니다.
originalFile.inputStream().bufferedReader(Charset.forName(charsetName)).use { reader ->
newFile.outputStream().bufferedWriter(Charsets.UTF_8).use { writer ->
reader.forEachLine { line ->
writer.write(line)
writer.newLine()
}
}
}
Toast.makeText(this, "새 파일로 저장 완료:\n$newFileName", Toast.LENGTH_LONG).show()
// 3. 화면 갱신을 위해 새 파일 로드
currentFile = newFile
pagedLayout.text = FileUtils.readTextWithEncoding(newFile)
} catch (e: Exception) {
Toast.makeText(this, "저장 중 오류: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
private fun showOverlay() {
handler.removeCallbacks(hideRunnable)
header.visibility = View.VISIBLE
header.animate().alpha(1f).setDuration(300).start()
handler.postDelayed(hideRunnable, 3000)
}
private fun hideOverlay() {
header.animate().alpha(0f).setDuration(300).withEndAction {
header.visibility = View.GONE
}.start()
}
} }

View File

@ -0,0 +1,129 @@
package bums.lunatic.launcher.player
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.Charset
import java.util.regex.Pattern
// 1. 챕터 정보를 저장할 데이터 클래스 정의
data class Chapter(
val title: String, // 챕터 이름 (예: "제 1화 시작하며")
val pageIndex: Int // 해당 챕터가 시작되는 페이지 인덱스
)
class PageIndexer(
private val file: File,
private val encoding: String,
private val paint: TextPaint,
private val viewWidth: Int,
private val viewHeight: Int
) {
val pageOffsets = ArrayList<Long>()
// 2. 추출된 챕터들을 담을 리스트 생성
val chapters = ArrayList<Chapter>()
private val charset = Charset.forName(encoding)
// 3. 탐지할 챕터 패턴 정규식 정의 (예: "제 1화", "제1장", "Chapter 5", "CH.3" 등 대응)
// 소설이나 텍스트 특성에 맞게 패턴을 수정하시면 됩니다.
private val chapterPattern = Pattern.compile(
"(?:제\\s*)?\\d+\\s*[화|장|막|절|편]"
)
suspend fun buildIndex(onProgress: (Int) -> Unit) = withContext(Dispatchers.Default) {
val raf = RandomAccessFile(file, "r")
val fileLength = raf.length()
var currentOffset = 0L
pageOffsets.add(currentOffset)
val bufferSize = 40000
val buffer = ByteArray(bufferSize)
while (currentOffset < fileLength) {
raf.seek(currentOffset)
val bytesRead = raf.read(buffer)
if (bytesRead == -1) break
val chunkText = String(buffer, 0, bytesRead, charset)
if (chunkText.isEmpty()) break
val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(chunkText, 0, chunkText.length, paint, viewWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.build()
} else {
@Suppress("DEPRECATION")
StaticLayout(chunkText, paint, viewWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 1.0f, false)
}
val totalLines = layout.lineCount
var startLine = 0
var chunkConsumedOffset = 0
while (startLine < totalLines) {
val pageTopY = layout.getLineTop(startLine)
val targetBottomY = pageTopY + viewHeight
var endLine = layout.getLineForVertical(targetBottomY)
if (layout.getLineBottom(endLine) > targetBottomY) {
endLine--
}
if (endLine < startLine) endLine = startLine
val endCharOffset = layout.getLineEnd(endLine)
val pageText = chunkText.substring(chunkConsumedOffset, endCharOffset)
// 4. [핵심] 현재 잘라낸 페이지 텍스트 내에 챕터 패턴이 존재하는지 검사
// 페이지의 첫 부분 위주로 검사하거나 문단 단위로 첫 줄을 검사하는 것이 정확합니다.
val matcher = chapterPattern.matcher(pageText)
if (matcher.find()) {
// 해당 페이지 내에서 실제 챕터 제목으로 쓸 만한 한 줄(Line) 전체를 가져옵니다.
// 보통 챕터 제목은 한 줄을 통째로 차지하므로, 패턴이 발견된 위치의 앞뒤 줄바꿈(\n)을 기준으로 잘라냅니다.
val matchStart = matcher.start()
// 패턴 시작점 기준 앞쪽 줄바꿈 찾기
val lineStart = pageText.lastIndexOf('\n', matchStart).let { if (it == -1) 0 else it + 1 }
// 패턴 시작점 기준 뒤쪽 줄바꿈 찾기
val lineEnd = pageText.indexOf('\n', matchStart).let { if (it == -1) pageText.length else it }
var chapterTitle = pageText.substring(lineStart, lineEnd).trim()
// 제목이 너무 길면 본문 문장이 오탐지된 것일 수 있으므로 글자수 제한(예: 40자)을 둡니다.
if (chapterTitle.isNotEmpty() && chapterTitle.length < 40) {
val currentPageIndex = pageOffsets.size - 1
// 중복 등록 방지 (동일 페이지 내 다중 감지 방어)
if (chapters.isEmpty() || chapters.last().pageIndex != currentPageIndex) {
chapters.add(Chapter(chapterTitle, currentPageIndex))
}
}
}
val pageBytesSize = pageText.toByteArray(charset).size
currentOffset += pageBytesSize
chunkConsumedOffset = endCharOffset
if (currentOffset < fileLength) {
pageOffsets.add(currentOffset)
}
val progress = ((currentOffset * 100) / fileLength).toInt()
onProgress(progress)
startLine = endLine + 1
}
}
raf.close()
}
}

View File

@ -1,6 +1,9 @@
package bums.lunatic.launcher.utils package bums.lunatic.launcher.utils
import org.mozilla.universalchardet.UniversalDetector
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.RandomAccessFile
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction
@ -50,4 +53,22 @@ object FileUtils {
// 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주 // 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주
return garbageCount < (text.length / 30) return garbageCount < (text.length / 30)
} }
fun detectFileEncoding(file: File): String {
val detector = UniversalDetector(null)
FileInputStream(file).use { fis ->
val buf = ByteArray(4096)
var nread: Int
while (fis.read(buf).also { nread = it } > 0 && !detector.isDone) {
detector.handleData(buf, 0, nread)
}
detector.dataEnd()
}
// 감지된 인코딩이 없으면 기본값으로 UTF-8 또는 CP949를 반환합니다.
return detector.detectedCharset ?: "UTF-8"
}
} }

View File

@ -55,12 +55,12 @@ class TorrentService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (!isKoreaRegion()) { // if (!isKoreaRegion()) {
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.") // Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
stopForeground(true) // stopForeground(true)
stopSelf() // stopSelf()
return // return
} // }
startForegroundService() startForegroundService()
initLibTorrent() initLibTorrent()
@ -95,7 +95,7 @@ class TorrentService : Service() {
} }
registerReceiver(batteryReceiver, filter) registerReceiver(batteryReceiver, filter)
} }
var batteryPct = 50
private val batteryReceiver = object : BroadcastReceiver() { private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
// isCharging = when (intent?.action) { // isCharging = when (intent?.action) {
@ -111,7 +111,7 @@ class TorrentService : Service() {
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1 val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1
val batteryPct = (level / scale.toFloat() * 100).toInt() batteryPct = (level / scale.toFloat() * 100).toInt()
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL || (batteryPct > 95) status == BatteryManager.BATTERY_STATUS_FULL || (batteryPct > 95)
@ -160,12 +160,12 @@ class TorrentService : Service() {
* 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절 * 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절
*/ */
private fun updateSessionState() { private fun updateSessionState() {
if (!isKoreaRegion()) { // if (!isKoreaRegion()) {
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.") // Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
stopForeground(true) // stopForeground(true)
stopSelf() // stopSelf()
return // return
} // }
checkIpAndStop() checkIpAndStop()
@ -207,10 +207,11 @@ class TorrentService : Service() {
} }
// 1. 메타데이터 미수신: 무조건 유지 // 1. 메타데이터 미수신: 무조건 유지
torrentsWithoutMetadata.forEach { it.swig().resume() }
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬 // 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
if (isCharging) { if (isCharging && batteryPct > 70) {
torrentsWithoutMetadata.forEach { it.swig().resume() }
val maxSlots = if (isWifiConnected) 6 else 1 val maxSlots = if (isWifiConnected) 6 else 1
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second } val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
@ -223,6 +224,7 @@ class TorrentService : Service() {
} }
} }
} else { } else {
torrentsWithoutMetadata.forEach { it.swig().pause() }
// 배터리 모드 // 배터리 모드
torrentsWithMetadata.forEach { it.first.pause() } torrentsWithMetadata.forEach { it.first.pause() }
} }
@ -579,7 +581,10 @@ class TorrentService : Service() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
unregisterReceiver(batteryReceiver) try {
unregisterReceiver(batteryReceiver)
} catch (e: Exception){e.printStackTrace()}
serviceScope.cancel() serviceScope.cancel()
} }

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/tvDialogProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="0% (1 / 100)"
android:textColor="#000000"
android:textSize="18sp"
android:textStyle="bold" />
<SeekBar
android:id="@+id/seekBarPage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:max="100" />
</LinearLayout>