..
This commit is contained in:
parent
257ddb6776
commit
378f6495be
@ -177,6 +177,8 @@ dependencies {
|
||||
// implementation 'com.vladsch.flexmark:flexmark-all:0.64.8'
|
||||
// implementation("org.opencv:opencv-android:4.11.0")
|
||||
// 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 ("com.github.aeonSolutions:FloatingActionButtonMenuDrag:1.1")
|
||||
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")
|
||||
|
||||
@ -132,7 +132,7 @@
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
|
||||
android:screenOrientation="portrait"
|
||||
android:excludeFromRecents="true"
|
||||
android:excludeFromRecents="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:exported="false">
|
||||
</activity>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
(function() {
|
||||
var meta = document.querySelector('meta[name=viewport]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'viewport';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||||
})();
|
||||
//(function() {
|
||||
// var meta = document.querySelector('meta[name=viewport]');
|
||||
// if (!meta) {
|
||||
// meta = document.createElement('meta');
|
||||
// meta.name = 'viewport';
|
||||
// document.head.appendChild(meta);
|
||||
// }
|
||||
// meta.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||||
//})();
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"run_at": "document_end",
|
||||
"run_at": "document_start",
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["messaging.js","inject-viewport.js"]
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
|
||||
|
||||
|
||||
function pubDateNumber(tdateTime) {
|
||||
let date = new Date();
|
||||
let dateTime = date.getTime();
|
||||
@ -55,11 +56,11 @@ port.onMessage.addListener(response => {
|
||||
var type= response["type"];
|
||||
|
||||
switch (type) {
|
||||
case "GO_TO_SUBTITLE_DETAIL": {
|
||||
const detailUrl = response["url"];
|
||||
location.href = detailUrl; // 상세 페이지로 이동
|
||||
break;
|
||||
}
|
||||
case "GO_TO_SUBTITLE_DETAIL": {
|
||||
const detailUrl = response["url"];
|
||||
location.href = detailUrl; // 상세 페이지로 이동
|
||||
break;
|
||||
}
|
||||
case "SEARCH_SUBTITLE_CAT": {
|
||||
const query = response["query"];
|
||||
const searchUrl = `https://www.subtitlecat.com/index.php?search=${encodeURIComponent(query)}`;
|
||||
@ -72,7 +73,7 @@ port.onMessage.addListener(response => {
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
break;
|
||||
break;
|
||||
case "SEEK_NEXT": // 5초 앞으로
|
||||
{
|
||||
let btn =
|
||||
@ -469,13 +470,9 @@ if(document.querySelector(".list-body") !== null) {
|
||||
var listBody = null
|
||||
try {listBody = document.querySelector(".list-body");}catch (e) {}
|
||||
getList(listBody.children)
|
||||
} else if(document.querySelector(".novel-eps") !== null) {
|
||||
//document.querySelector(".novel-eps").children
|
||||
removeSpecificGifs()
|
||||
var listBody = null
|
||||
try {listBody = document.querySelector(".novel-eps");}catch (e) {}
|
||||
getList2(listBody.children)
|
||||
} else if(document.querySelector("#novel_content") !== null){
|
||||
}
|
||||
|
||||
if(document.querySelector("#novel_content") !== null){
|
||||
removeSpecificGifs()
|
||||
var title = 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")) {
|
||||
sendMessage(
|
||||
{
|
||||
@ -814,7 +796,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
sendCookiesToNative();
|
||||
try{removeSpecificGifs();}catch(e){}
|
||||
} else {
|
||||
|
||||
// 2. 아직 로딩 중이라면 window.onload(모든 리소스 로드 완료) 시점에 실행
|
||||
window.addEventListener('load', sendCookiesToNative);
|
||||
}
|
||||
@ -860,27 +844,36 @@ function extractSubtitleList() {
|
||||
}
|
||||
function scrollToEndAndExtract() {
|
||||
if (location.host.includes("subtitlecat.com")) {
|
||||
const scrollStep = 800; // 한 번에 스크롤할 양
|
||||
const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms)
|
||||
const scrollStep = 800; // 한 번에 스크롤할 양
|
||||
const scrollDelay = 500; // 다음 스크롤까지 대기 시간 (ms)
|
||||
|
||||
function step() {
|
||||
const prevScrollY = window.scrollY;
|
||||
window.scrollBy(0, scrollStep);
|
||||
function step() {
|
||||
const prevScrollY = window.scrollY;
|
||||
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초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
|
||||
setTimeout(() => {
|
||||
if (window.scrollY === prevScrollY ||
|
||||
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
|
||||
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
|
||||
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
|
||||
} else {
|
||||
step(); // 아직 끝이 아니면 다음 스크롤 진행
|
||||
}
|
||||
}, scrollDelay);
|
||||
} else {
|
||||
step(); // 아직 끝이 아니면 다음 스크롤 진행
|
||||
}
|
||||
}, scrollDelay);
|
||||
}
|
||||
|
||||
step()
|
||||
|
||||
} else {
|
||||
console.log(`is FAIL NOVEL ${location.href.includes("/novel/")}`);
|
||||
}
|
||||
|
||||
step();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -580,10 +580,10 @@ open class LauncherActivity : CommonActivity() {
|
||||
}
|
||||
})
|
||||
|
||||
requestSmsPermissionLauncher.launch(arrayOf(
|
||||
android.Manifest.permission.RECEIVE_SMS,
|
||||
android.Manifest.permission.READ_SMS
|
||||
))
|
||||
// requestSmsPermissionLauncher.launch(arrayOf(
|
||||
// android.Manifest.permission.RECEIVE_SMS,
|
||||
// android.Manifest.permission.READ_SMS
|
||||
// ))
|
||||
|
||||
handleSharedIntent(intent)
|
||||
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
@ -614,7 +614,9 @@ open class LauncherActivity : CommonActivity() {
|
||||
putExtra("WIFI_STATE", isWifiConnected)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
try {
|
||||
startForegroundService(intent)
|
||||
} catch (e: Exception){e.printStackTrace()}
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
@ -634,35 +636,35 @@ open class LauncherActivity : CommonActivity() {
|
||||
}
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback!!)
|
||||
}
|
||||
private var smsReceiver: SmsReceiver? = null
|
||||
// private var smsReceiver: SmsReceiver? = null
|
||||
|
||||
// 권한 요청 결과 처리기
|
||||
private val requestSmsPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false
|
||||
if (granted) {
|
||||
Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행")
|
||||
registerSmsDynamicReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSmsDynamicReceiver() {
|
||||
if (smsReceiver == null) {
|
||||
smsReceiver = SmsReceiver()
|
||||
val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply {
|
||||
priority = 2147483647 // 시스템 최우선 순위
|
||||
}
|
||||
|
||||
// Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED
|
||||
// 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(smsReceiver, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
// private val requestSmsPermissionLauncher = registerForActivityResult(
|
||||
// ActivityResultContracts.RequestMultiplePermissions()
|
||||
// ) { permissions ->
|
||||
// val granted = permissions[android.Manifest.permission.RECEIVE_SMS] ?: false
|
||||
// if (granted) {
|
||||
// Blog.LOGE("SMS 권한 승인됨 -> 리시버 동적 등록 실행")
|
||||
// registerSmsDynamicReceiver()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private fun registerSmsDynamicReceiver() {
|
||||
// if (smsReceiver == null) {
|
||||
// smsReceiver = SmsReceiver()
|
||||
// val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED").apply {
|
||||
// priority = 2147483647 // 시스템 최우선 순위
|
||||
// }
|
||||
//
|
||||
// // Android 14 이상(안드 16 포함) 필수 플래그: RECEIVER_EXPORTED
|
||||
// // 외부 앱(시스템 타워)으로부터 브로드캐스트를 받으려면 필수입니다.
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// registerReceiver(smsReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
// } else {
|
||||
// registerReceiver(smsReceiver, filter)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun updateWidgetOptions(appWidgetId: Int, hostView: AppWidgetHostView) {
|
||||
val width = hostView.layoutParams.width
|
||||
@ -966,10 +968,10 @@ open class LauncherActivity : CommonActivity() {
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
override fun onDestroy() {
|
||||
smsReceiver?.let {
|
||||
unregisterReceiver(it)
|
||||
smsReceiver = null
|
||||
}
|
||||
// smsReceiver?.let {
|
||||
// unregisterReceiver(it)
|
||||
// smsReceiver = null
|
||||
// }
|
||||
|
||||
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
|
||||
super.onDestroy()
|
||||
|
||||
@ -65,6 +65,7 @@ import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.Jsoup
|
||||
import org.mozilla.gecko.util.ThreadUtils
|
||||
import org.mozilla.geckoview.AllowOrDeny
|
||||
import org.mozilla.geckoview.GeckoResult
|
||||
import org.mozilla.geckoview.GeckoSession
|
||||
import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate
|
||||
@ -374,7 +375,16 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
|
||||
// [Navigation Delegate]
|
||||
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>? {
|
||||
Uri.parse(uri)?.let {
|
||||
@ -388,6 +398,7 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
showNewSessionDialog(uri)
|
||||
}
|
||||
}
|
||||
|
||||
return super.onNewSession(session, uri)
|
||||
}
|
||||
|
||||
@ -547,10 +558,10 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
.allowJavascript(true)
|
||||
.contextId("JUST_ONE")
|
||||
.usePrivateMode(false)
|
||||
|
||||
.build()
|
||||
val session = GeckoSession(sessionSettings)
|
||||
|
||||
|
||||
session.open(runtime)
|
||||
this.setSession(session)
|
||||
|
||||
|
||||
@ -101,6 +101,10 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
}
|
||||
}
|
||||
|
||||
override fun usePageInfo(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(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 {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_TYPE, "book")
|
||||
putString(ARG_TYPE, "web")
|
||||
putInt(ARG_LAST_NUM, 468)
|
||||
putString(ARG_NAME, "ntk01")
|
||||
putString(ARG_NAME, "sbxh2")
|
||||
putString(ARG_DOT, "com/novel")
|
||||
putBoolean(ARG_USE_NUM_URL, false)
|
||||
putBoolean(ARG_ENABLE_GESTURE, true)
|
||||
@ -1341,7 +1345,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
}
|
||||
|
||||
fun onLoadedContents(aContents: String) {
|
||||
Blog.LOGE("onLoadedContents ")
|
||||
Blog.LOGE("onLoadedContents $aContents")
|
||||
binding.pagedLayer.let { view ->
|
||||
view.post {
|
||||
if (aContents.length > 10) {
|
||||
@ -1421,6 +1425,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
var currentChapter: Int = 0
|
||||
|
||||
fun onFindTitle(contents: String) {
|
||||
Blog.LOGE("contents $contents")
|
||||
binding.lunaticBrowser.binding.tvTitle.text = contents
|
||||
binding.lunaticBrowser.binding.tvTitle.setOnClickListener {
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
|
||||
@ -36,6 +36,7 @@ interface PagedTextViewInterface {
|
||||
fun onSwipeDown(touchCount : Int)
|
||||
fun onSwipeUp(touchCount : Int)
|
||||
fun onLongClick()
|
||||
fun usePageInfo() : Boolean
|
||||
}
|
||||
|
||||
interface PagedTextGenerateInterface {
|
||||
@ -172,8 +173,11 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
||||
mainTextView = findViewById(R.id.first_view)
|
||||
sencondTextView = findViewById(R.id.sencond_view)
|
||||
currentPageTextView = findViewById(R.id.current_page)
|
||||
if (mPagedTextViewInterface?.usePageInfo() ?: false) {
|
||||
|
||||
currentPageTextView?.text = ""
|
||||
} else {
|
||||
currentPageTextView?.text = ""
|
||||
}
|
||||
hanler.removeCallbacks(touchTimeover)
|
||||
setOnLongClickListener { v ->
|
||||
mPagedTextViewInterface?.onLongClick()
|
||||
@ -350,7 +354,11 @@ class PagedTextLayout : ConstraintLayout , PagedTextGenerateInterface {
|
||||
} else {
|
||||
(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"
|
||||
// Blog.LOGE("pageList.get($realPage) ${pageList?.get(realPage)}")
|
||||
|
||||
@ -1,43 +1,43 @@
|
||||
package bums.lunatic.launcher.player
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.home.tokiz.TouchArea
|
||||
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
|
||||
import bums.lunatic.launcher.home.tokiz.view.PagedTextViewInterface
|
||||
import bums.lunatic.launcher.model.Translation
|
||||
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 bums.lunatic.launcher.utils.FileUtils.detectFileEncoding
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.charset.Charset
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
class DocumentViewerActivity : AppCompatActivity() {
|
||||
class DocumentViewerActivity : AppCompatActivity(), PagedTextViewInterface {
|
||||
private lateinit var pagedLayout: PagedTextLayout
|
||||
private lateinit var header: View
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val hideRunnable = Runnable { hideOverlay() }
|
||||
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@ -45,248 +45,250 @@ class DocumentViewerActivity : AppCompatActivity() {
|
||||
|
||||
pagedLayout = findViewById(R.id.pagedTextLayout)
|
||||
header = findViewById(R.id.layoutDocHeader)
|
||||
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
|
||||
|
||||
// 필요하다면 줄 간격이나 자간도 기본값으로 조정하여 가독성을 높입니다.
|
||||
pagedLayout.setLineSpacing(20f) // 1.2배 정도의 줄간격
|
||||
pagedLayout.setLetterSpacing(0f) // 기본 자간
|
||||
pagedLayout.setTypeface(android.graphics.Typeface.DEFAULT)
|
||||
pagedLayout.setLineSpacing(20f)
|
||||
pagedLayout.setLetterSpacing(0f)
|
||||
|
||||
val filePath = intent.getStringExtra("FILE_PATH") ?: return finish()
|
||||
currentFile = File(filePath)
|
||||
|
||||
currentRawBytes = currentFile?.readBytes()
|
||||
pagedLayout.mPagedTextViewInterface = this
|
||||
|
||||
// 초기 자동 로드
|
||||
pagedLayout.text = FileUtils.readTextWithEncoding(currentFile!!)
|
||||
|
||||
// 💡 인코딩 수동 선택 버튼 (예: 헤더의 특정 아이콘 클릭 시)
|
||||
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()
|
||||
pagedLayout.post {
|
||||
currentFile?.let {
|
||||
initializeReader()
|
||||
}
|
||||
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)
|
||||
.setTitle("인코딩 선택 (화면을 보며 확인하세요)")
|
||||
.setSingleChoiceItems(items, -1) { _, which ->
|
||||
selectedIndex = which
|
||||
applyPreviewEncoding(items[which])
|
||||
private fun initializeReader() {
|
||||
lifecycleScope.launch {
|
||||
currentFile?.let { file ->
|
||||
encoding = detectFileEncoding(file)
|
||||
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("다음 인코딩") { _, _ ->
|
||||
selectedIndex = (selectedIndex + 1) % items.size
|
||||
applyPreviewEncoding(items[selectedIndex])
|
||||
// 다이얼로그 유지를 위해 재호출 로직 필요 시 추가
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
// 볼륨 다운 키 -> 다음 페이지로 이동 (기존 온스wipeLeft 로직 활용)
|
||||
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()
|
||||
}
|
||||
|
||||
private fun showActionSelectionDialog(charsetName: String) {
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle("처리 방식 선택")
|
||||
.setMessage("[$charsetName] 인코딩으로 확인되었습니다.\n어떤 방식으로 저장할까요?")
|
||||
.setPositiveButton("번역 후 저장") { _, _ ->
|
||||
// 💡 언어 감지 후 번역 진행
|
||||
detectLanguageAndTranslate(charsetName)
|
||||
}
|
||||
.setNeutralButton("그냥 이대로 저장") { _, _ ->
|
||||
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
/**
|
||||
* 이전 챕터로 이동하는 함수
|
||||
*/
|
||||
private fun moveToPrevChapter() {
|
||||
if (!::pageIndexer.isInitialized || pageIndexer.chapters.isEmpty()) return
|
||||
|
||||
// 현재 페이지보다 앞에 있는 챕터들을 역순으로 탐색하여 가장 가까운 챕터를 찾습니다.
|
||||
// 단, 현재 딱 챕터 시작점에 걸려있다면 그 전 챕터로 가야 하므로 기준을 '현재 페이지 - 1'로 잡는 것이 자연스럽습니다.
|
||||
val targetIndex = if (currentPageIndex > 0) currentPageIndex - 1 else 0
|
||||
val prevChapter = pageIndexer.chapters.lastOrNull { it.pageIndex <= targetIndex }
|
||||
|
||||
if (prevChapter != null) {
|
||||
showPage(prevChapter.pageIndex)
|
||||
} else {
|
||||
// 이미 첫 번째 챕터 앞이거나 챕터가 없을 때 처리 -> 맨 처음 페이지로
|
||||
showPage(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectLanguageAndTranslate(charsetName: String) {
|
||||
val textSample = pagedLayout.text.toString().take(2000) // 💡 정확도를 위해 앞부분 2000자 샘플링
|
||||
if (textSample.isBlank()) return
|
||||
private fun showPageSeekDialog() {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_page_seek, null)
|
||||
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)
|
||||
.addOnSuccessListener { languageCode ->
|
||||
if (languageCode == "und") {
|
||||
Toast.makeText(this, "언어를 판별할 수 없습니다. 기본값(중국어)으로 시도합니다.", Toast.LENGTH_SHORT).show()
|
||||
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE)
|
||||
} else if (languageCode == "ko") {
|
||||
Toast.makeText(this, "이미 한국어 문서입니다. 번역 없이 저장합니다.", Toast.LENGTH_SHORT).show()
|
||||
saveCurrentTextAsUtf8(pagedLayout.text.toString(), charsetName)
|
||||
} else {
|
||||
// 💡 감지된 언어 코드를 번역기 코드로 변환
|
||||
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
||||
if (sourceLang != null) {
|
||||
translateAndSaveByParagraph(charsetName, sourceLang)
|
||||
seekBarPage.progress = currentPercent
|
||||
tvDialogProgress.text = "$currentPercent% (${currentPageIndex + 1} / $totalPages)"
|
||||
|
||||
var targetPageIndex = currentPageIndex
|
||||
|
||||
seekBarPage.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
targetPageIndex = if (totalPages > 1) {
|
||||
((progress.toFloat() / 100) * (totalPages - 1)).toInt().coerceIn(0, totalPages - 1)
|
||||
} else {
|
||||
Toast.makeText(this, "지원하지 않는 언어($languageCode)입니다.", Toast.LENGTH_SHORT).show()
|
||||
0
|
||||
}
|
||||
tvDialogProgress.text = "$progress% (${targetPageIndex + 1} / $totalPages)"
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE) // 실패 시 기본값
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||
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) {
|
||||
val originalFile = currentFile ?: return
|
||||
override fun onSwipeLeft(count: Int) {
|
||||
if (!::pageIndexer.isInitialized) return
|
||||
if (count > 2) {moveToNextChapter()}
|
||||
val pageSizeToMove = max((count - 1) * 10, 1)
|
||||
|
||||
// 💡 감지된 sourceLang 적용
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLang)
|
||||
.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()
|
||||
}
|
||||
if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
|
||||
val targetPage = (currentPageIndex + pageSizeToMove).coerceAtMost(pageIndexer.pageOffsets.size - 1)
|
||||
showPage(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
var lastEncoded = ""
|
||||
private fun applyPreviewEncoding(charset: String) {
|
||||
val bytes = currentRawBytes ?: return
|
||||
lastEncoded = charset
|
||||
override fun onSwipeRight(count: Int) {
|
||||
if (!::pageIndexer.isInitialized) return
|
||||
val pageSizeToMove = max((count - 1) * 10, 1)
|
||||
|
||||
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 {
|
||||
val decoder = Charset.forName(charset).newDecoder()
|
||||
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
|
||||
.replaceWith("")
|
||||
|
||||
val text = decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
|
||||
pagedLayout.text = text
|
||||
raf.close()
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
129
app/src/main/kotlin/bums/lunatic/launcher/player/PageIndexer.kt
Normal file
129
app/src/main/kotlin/bums/lunatic/launcher/player/PageIndexer.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package bums.lunatic.launcher.utils
|
||||
|
||||
import org.mozilla.universalchardet.UniversalDetector
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.CodingErrorAction
|
||||
@ -50,4 +53,22 @@ object FileUtils {
|
||||
// 전체 텍스트에서 저런 문자가 3% 이상 섞여있다면 깨진 것으로 간주
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -55,12 +55,12 @@ class TorrentService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (!isKoreaRegion()) {
|
||||
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
// if (!isKoreaRegion()) {
|
||||
// Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
|
||||
// stopForeground(true)
|
||||
// stopSelf()
|
||||
// return
|
||||
// }
|
||||
startForegroundService()
|
||||
initLibTorrent()
|
||||
|
||||
@ -95,7 +95,7 @@ class TorrentService : Service() {
|
||||
}
|
||||
registerReceiver(batteryReceiver, filter)
|
||||
}
|
||||
|
||||
var batteryPct = 50
|
||||
private val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
// isCharging = when (intent?.action) {
|
||||
@ -111,7 +111,7 @@ class TorrentService : Service() {
|
||||
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -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 ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL || (batteryPct > 95)
|
||||
|
||||
@ -160,12 +160,12 @@ class TorrentService : Service() {
|
||||
* 핵심 제어 로직: 충전 중일 때만 세션을 열고, Wi-Fi 여부에 따라 슬롯 조절
|
||||
*/
|
||||
private fun updateSessionState() {
|
||||
if (!isKoreaRegion()) {
|
||||
Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
// if (!isKoreaRegion()) {
|
||||
// Blog.LOGE("해외 지역 접속 감지: 서비스를 종료합니다.")
|
||||
// stopForeground(true)
|
||||
// stopSelf()
|
||||
// return
|
||||
// }
|
||||
|
||||
checkIpAndStop()
|
||||
|
||||
@ -207,10 +207,11 @@ class TorrentService : Service() {
|
||||
}
|
||||
|
||||
// 1. 메타데이터 미수신: 무조건 유지
|
||||
torrentsWithoutMetadata.forEach { it.swig().resume() }
|
||||
|
||||
|
||||
// 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬
|
||||
if (isCharging) {
|
||||
if (isCharging && batteryPct > 70) {
|
||||
torrentsWithoutMetadata.forEach { it.swig().resume() }
|
||||
val maxSlots = if (isWifiConnected) 6 else 1
|
||||
val sortedByPriority = torrentsWithMetadata.sortedBy { it.second }
|
||||
|
||||
@ -223,6 +224,7 @@ class TorrentService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
torrentsWithoutMetadata.forEach { it.swig().pause() }
|
||||
// 배터리 모드
|
||||
torrentsWithMetadata.forEach { it.first.pause() }
|
||||
}
|
||||
@ -579,7 +581,10 @@ class TorrentService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(batteryReceiver)
|
||||
try {
|
||||
unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception){e.printStackTrace()}
|
||||
|
||||
serviceScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
26
app/src/main/res/layout/dialog_page_seek.xml
Normal file
26
app/src/main/res/layout/dialog_page_seek.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user