..
This commit is contained in:
parent
257ddb6776
commit
378f6495be
@ -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")
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
})();
|
//})();
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -866,21 +850,30 @@ function scrollToEndAndExtract() {
|
|||||||
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초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
|
// 💡 0.5초 대기 후 현재 위치가 이전과 같다면(끝에 도달) 추출 시작
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.scrollY === prevScrollY ||
|
if (window.scrollY === prevScrollY ||
|
||||||
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
|
(window.innerHeight + window.scrollY) >= document.body.scrollHeight) {
|
||||||
|
if (location.host.includes("subtitlecat.com")) {
|
||||||
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
|
console.log("✅ 페이지 끝 도달 - 자막 리스트 추출 시작");
|
||||||
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
|
extractSubtitleList(); // 기존에 정의한 추출 함수 호출
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
step(); // 아직 끝이 아니면 다음 스크롤 진행
|
step(); // 아직 끝이 아니면 다음 스크롤 진행
|
||||||
}
|
}
|
||||||
}, scrollDelay);
|
}, scrollDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
step();
|
step()
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log(`is FAIL NOVEL ${location.href.includes("/novel/")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
try {
|
||||||
startForegroundService(intent)
|
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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|
||||||
|
} else {
|
||||||
currentPageTextView?.text = ""
|
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
|
||||||
}
|
}
|
||||||
|
if (mPagedTextViewInterface?.usePageInfo() ?: false) {
|
||||||
|
|
||||||
|
} else {
|
||||||
currentPageTextView?.text = "${realPage + 1}/${pageList?.size ?: 0 + 1}"
|
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)}")
|
||||||
|
|||||||
@ -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()
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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가 뜨지 않도록 이벤트를 차단(소비)합니다.
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||||
|
// 볼륨 다운 키 -> 다음 페이지로 이동 (기존 온스wipeLeft 로직 활용)
|
||||||
|
if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
|
||||||
|
showPage(currentPageIndex + 1)
|
||||||
|
}
|
||||||
|
true // 시스템 볼륨 UI가 뜨지 않도록 이벤트를 차단(소비)합니다.
|
||||||
|
}
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 제스처 인터페이스 설정
|
|
||||||
pagedLayout.mPagedTextViewInterface = object : PagedTextViewInterface {
|
|
||||||
override fun onTouch(touchArea: TouchArea) {
|
override fun onTouch(touchArea: TouchArea) {
|
||||||
if (header.visibility == View.VISIBLE) hideOverlay() else showOverlay()
|
if (!::pageIndexer.isInitialized || pageIndexer.pageOffsets.isEmpty()) return
|
||||||
|
if (touchArea == TouchArea.Center) {
|
||||||
|
// showPageSeekDialog()
|
||||||
|
showChapterListDialog()
|
||||||
}
|
}
|
||||||
override fun onSwipeLeft(count: Int) { pagedLayout.doNext() }
|
}
|
||||||
override fun onSwipeRight(count: Int) { pagedLayout.doPrev() }
|
|
||||||
override fun onLongClick() {
|
private fun moveToNextChapter() {
|
||||||
// finish()
|
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 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 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 totalPages = pageIndexer.pageOffsets.size
|
||||||
|
val currentPercent = if (totalPages > 1) {
|
||||||
|
((currentPageIndex.toFloat() / (totalPages - 1)) * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
tvDialogProgress.text = "$progress% (${targetPageIndex + 1} / $totalPages)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwipeLeft(count: Int) {
|
||||||
|
if (!::pageIndexer.isInitialized) return
|
||||||
|
if (count > 2) {moveToNextChapter()}
|
||||||
|
val pageSizeToMove = max((count - 1) * 10, 1)
|
||||||
|
|
||||||
|
if (currentPageIndex < pageIndexer.pageOffsets.size - 1) {
|
||||||
|
val targetPage = (currentPageIndex + pageSizeToMove).coerceAtMost(pageIndexer.pageOffsets.size - 1)
|
||||||
|
showPage(targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 onTimeoverTouch() {}
|
||||||
override fun onSwipeDown(count: Int) {}
|
override fun onSwipeDown(count: Int) {}
|
||||||
override fun onSwipeUp(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)
|
override fun onDestroy() {
|
||||||
.setTitle("인코딩 선택 (화면을 보며 확인하세요)")
|
super.onDestroy()
|
||||||
.setSingleChoiceItems(items, -1) { _, which ->
|
|
||||||
selectedIndex = which
|
|
||||||
applyPreviewEncoding(items[which])
|
|
||||||
}
|
|
||||||
.setPositiveButton("확정 및 처리") { _, _ ->
|
|
||||||
// 💡 인코딩 확정 후 다음 액션 선택
|
|
||||||
showActionSelectionDialog(items[selectedIndex])
|
|
||||||
}
|
|
||||||
.setNeutralButton("다음 인코딩") { _, _ ->
|
|
||||||
selectedIndex = (selectedIndex + 1) % items.size
|
|
||||||
applyPreviewEncoding(items[selectedIndex])
|
|
||||||
// 다이얼로그 유지를 위해 재호출 로직 필요 시 추가
|
|
||||||
}
|
|
||||||
.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 detectLanguageAndTranslate(charsetName: String) {
|
|
||||||
val textSample = pagedLayout.text.toString().take(2000) // 💡 정확도를 위해 앞부분 2000자 샘플링
|
|
||||||
if (textSample.isBlank()) return
|
|
||||||
|
|
||||||
val languageIdentifier = LanguageIdentification.getClient()
|
|
||||||
|
|
||||||
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)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(this, "지원하지 않는 언어($languageCode)입니다.", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.addOnFailureListener {
|
|
||||||
translateAndSaveByParagraph(charsetName, TranslateLanguage.CHINESE) // 실패 시 기본값
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun translateAndSaveByParagraph(charsetName: String, sourceLang: String) {
|
|
||||||
val originalFile = currentFile ?: return
|
|
||||||
|
|
||||||
// 💡 감지된 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 {
|
try {
|
||||||
Tasks.await(translator.downloadModelIfNeeded())
|
raf.close()
|
||||||
|
|
||||||
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) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) {
|
e.printStackTrace()
|
||||||
Toast.makeText(this@DocumentViewerActivity, "번역 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
translator.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastEncoded = ""
|
|
||||||
private fun applyPreviewEncoding(charset: String) {
|
|
||||||
val bytes = currentRawBytes ?: return
|
|
||||||
lastEncoded = charset
|
|
||||||
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
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Blog.LOGE("미리보기 실패: $charset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
try {
|
||||||
unregisterReceiver(batteryReceiver)
|
unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception){e.printStackTrace()}
|
||||||
|
|
||||||
serviceScope.cancel()
|
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