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("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")

View File

@ -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>

View File

@ -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');
//})();

View File

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

View File

@ -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();
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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())

View File

@ -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)}")

View File

@ -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()
}
}

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
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"
}
}

View File

@ -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()
}

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>