This commit is contained in:
2026-08-10 17:24:04 +09:00
parent ad505863b7
commit 14e26702c1
13 changed files with 5931 additions and 82 deletions
@@ -0,0 +1,38 @@
{
sequences: {
"www.nhmembers.co.kr/nhaob": [".btn-direct-menu"],
"multicon.co.kr/couponMall/order/info":[
{ target: () => document.querySelector(".point_inquiry").click(), wait: 5000 },
{ action: () => {
if(document.querySelector(".result_point").innerText.split("P")[0] > 0) {
location.href = "${NH_DOMAIN + APP_LOG}"
}
}, wait: 15000 },
],
"multicon.co.kr/couponMall/goodsDetail":[
{ target: () => document.querySelector(".grey_btn").click(), wait: 5000 }
],
"multicon.co.kr/couponMall/main":[
{ target: () => document.querySelector(".product").parentElement.click(), wait: 5000 }
],
"example.com/login": [
// 1. [순수 함수] 아이디 입력 (실행 후 바로 다음 스텝으로)
{ action: () => { document.querySelector(".userid-input").value = "test_id"; } },
// 2. [순수 함수 + 대기] 비밀번호 입력 후 1초 대기
{
action: () => { document.querySelector(".password-input").value = "1234!!"; },
wait: 1000
},
// 3. [클릭 액션] 로그인 버튼 클릭 (기존 방식들 모두 호환됨)
"#login-btn",
{ target: () => document.getElementById("confirm-modal"), wait: 2000 }
],
"mysite.com/survey": ["input[value='yes']", "button.next-step"],
},
blacklist: {
"any": ["logout", "delete", "remove", "exit","header__prev main_header"],
"runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -129,3 +129,75 @@ window.addEventListener('error', function(event) {
console.warn('Resource Load Error:', target.src || target.href);
}
}, true);
function kkk(){
var sss = document.getElementsByTagName('meta');
for(i=0;i<sss.length;i++) {
if(sss[i].name.includes("viewport")) {
console.log(sss[i]);
sss[i].remove();
}
}
var meta = document.createElement('meta'); meta.setAttribute( 'name', 'viewport' ); meta.setAttribute( 'content', 'width = device-width, initial-scale = 1.0, minimum-scale = 1.0, maximum-scale = 4.0, user-scalable = yes, viewport-fit=cover' );
document.getElementsByTagName('head')[0].appendChild(meta);
var meta = document.createElement('meta'); meta.setAttribute( 'name', 'viewport' ); meta.setAttribute( 'content', 'width = device-width, initial-scale = 1.0, minimum-scale = 1.0, maximum-scale = 4.0, user-scalable = yes, viewport-fit=cover' );
document.getElementsByTagName('head')[0].appendChild(meta);
}
let scale = 1.0; // 현재 확대 비율
let startDistance = 0; // 터치 시작 시점의 두 손가락 거리
let baseScale = 1.0; // 터치 시작 시점의 기존 확대 비율
// 두 손가락 사이의 거리를 계산하는 함수 (피타고라스 정리 활용)
function getDistance(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
const target = document.body;
// transform이 일어날 기준점을 왼쪽 상단(0,0)으로 고정
target.style.transformOrigin = "0 0";
target.style.transition = "transform 0.05s ease-out"; // 부드러운 애니메이션 효과
// 1. 터치 시작 (두 손가락이 닿았을 때)
window.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
// 기본 브라우저 줌인 동작 방지
e.preventDefault();
startDistance = getDistance(e.touches[0], e.touches[1]);
baseScale = scale; // 현재 배율을 기준으로 저장
}
}, { passive: false });
// 2. 터치 이동 (손가락을 벌리거나 오므릴 때)
window.addEventListener('touchmove', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const currentDistance = getDistance(e.touches[0], e.touches[1]);
if (startDistance > 0) {
// 거리 비율만큼 배율 계산
const distanceRatio = currentDistance / startDistance;
let newScale = baseScale * distanceRatio;
// 최소 1배 ~ 최대 3배까지만 확대 제한 (원하는 대로 조절 가능)
newScale = Math.max(1.0, Math.min(newScale, 3.0));
scale = newScale;
target.style.transform = `scale(${scale})`;
console.log('Scale: ', + target.style.transform);
}
}
}, { passive: false });
// 3. 터치 종료 (손가락을 뗐을 때)
window.addEventListener('touchend', (e) => {
if (e.touches.length < 2) {
startDistance = 0; // 거리 초기화
}
});
@@ -1,22 +1,305 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<title>Config Generator</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f4f7f8; padding: 20px; color: #333; }
.container { max-width: 900px; margin: 0 auto; }
h1, h2 { color: #2c3e50; }
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
<script type="text/javascript">
window.addEventListener('load', () => {
history.pushState(null, '', location.href);
window.addEventListener('popstate', function(event){
console.log("Got event");
});
console.log("addEventListener");
});
</script>
/* Drag & Drop 영역 */
.drop-zone { border: 2px dashed #3498db; border-radius: 8px; padding: 30px; text-align: center; background: #ebf5fb; color: #2980b9; font-weight: bold; cursor: pointer; transition: background 0.3s; margin-bottom: 20px; }
.drop-zone.dragover { background: #d6eaf8; border-color: #2980b9; }
.url-block { border: 1px solid #e1e8ed; padding: 15px; border-radius: 6px; margin-bottom: 15px; position: relative; background: #fafbfc; }
.remove-btn { position: absolute; right: 15px; top: 15px; background: #ff4757; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; }
.step-block { display: flex; gap: 10px; align-items: flex-start; background: #fff; border: 1px dashed #ccc; padding: 10px; border-radius: 4px; margin-top: 10px; }
.step-block select, .step-block input, .step-block textarea { padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
.step-block .code-input { flex-grow: 1; }
.step-block textarea { width: 100%; height: 60px; font-family: monospace; resize: vertical; }
.step-block .wait-input { width: 100px; }
.btn { background: #2ed573; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; }
.btn-add { background: #1e90ff; }
.btn-generate { background: #3742fa; font-size: 16px; padding: 12px 20px; width: 48%; }
.btn-export { background: #f39c12; font-size: 16px; padding: 12px 20px; width: 48%; float: right; }
input[type="text"] { width: 300px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
pre { background: #2f3542; color: #f1f2f6; padding: 20px; border-radius: 8px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; }
.btn-row { display: flex; justify-content: space-between; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>봇 설정(Config) 생성기</h1>
<div id="dropZone" class="drop-zone">
📂 여기에 JSON 백업 파일을 드래그하거나 클릭하여 불러오세요.
<input type="file" id="fileInput" accept=".json" style="display: none;">
</div>
<div class="card">
<h2>1. Sequences (순차 처리)</h2>
<div id="sequences-container"></div>
<button class="btn btn-add" onclick="addSequenceUrl()">+ URL 추가</button>
</div>
<div class="card">
<h2>2. Blacklist (클릭 제외)</h2>
<div id="blacklist-container"></div>
<button class="btn btn-add" onclick="addBlacklistUrl()">+ 블랙리스트 URL 추가</button>
</div>
<div class="btn-row">
<button class="btn btn-generate" onclick="generateCode()">🚀 JS 코드 생성하기</button>
<button class="btn btn-export" onclick="exportJson()">💾 프로젝트 JSON 저장</button>
</div>
<div class="card" style="margin-top: 20px;">
<h2>결과 코드 (앱 삽입용)</h2>
<pre><code id="outputCode">// 여기에 자바스크립트 코드가 생성됩니다.</code></pre>
<button class="btn" onclick="copyCode()">코드 복사하기</button>
</div>
</div>
<script>
let sequenceCounter = 0;
let stepCounter = 0;
let blacklistCounter = 0;
// --- 초기 샘플 데이터 ---
const sampleData = {
sequences: {
"multicon.co.kr/couponMall/order/info": [
{ type: "target", value: "document.querySelector('.point_inquiry').click()", wait: "5000" },
{ type: "action", value: 'if(document.querySelector(".result_point").innerText.split("P")[0] > 0) {\n location.href = "${NH_DOMAIN + APP_LOG}";\n}', wait: "15000" }
]
},
blacklist: {
"any": ["logout", "delete", "remove", "exit", "header__prev main_header"],
"runcomm.co.kr": [".adpot_inquiry", ",btn_com1", "닫기"]
}
};
// --- DOM 생성 로직 ---
function addSequenceUrl(url = '') {
const id = sequenceCounter++;
const container = document.getElementById('sequences-container');
const html = `
<div class="url-block" id="seq-${id}">
<button class="remove-btn" onclick="document.getElementById('seq-${id}').remove()">삭제</button>
<label><strong>URL 키워드:</strong></label>
<input type="text" class="seq-url" placeholder="예: example.com/login" value="${url}">
<div id="steps-container-${id}" style="margin-top: 15px;"></div>
<button class="btn btn-add" style="background:#57606f;" onclick="addStep(${id})">+ 스텝(Step) 추가</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
return id;
}
function addStep(urlId, stepData = { type: 'string', value: '', wait: '' }) {
const sId = stepCounter++;
const container = document.getElementById(`steps-container-${urlId}`);
const html = `
<div class="step-block" id="step-${sId}">
<select class="step-type" onchange="changeStepType(${sId})">
<option value="string" ${stepData.type === 'string' ? 'selected' : ''}>기본 클릭 (문자열)</option>
<option value="target" ${stepData.type === 'target' ? 'selected' : ''}>동적 타겟 (Target)</option>
<option value="action" ${stepData.type === 'action' ? 'selected' : ''}>순수 함수 (Action)</option>
</select>
<div id="step-input-area-${sId}" style="flex-grow: 1; display: flex; gap: 10px;"></div>
<button class="remove-btn" style="position:static;" onclick="document.getElementById('step-${sId}').remove()">X</button>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
changeStepType(sId, stepData.value, stepData.wait);
}
function changeStepType(sId, defaultValue = '', defaultWait = '') {
const type = document.querySelector(`#step-${sId} .step-type`).value;
const area = document.getElementById(`step-input-area-${sId}`);
// 기존 값이 있다면 보존, 없다면 인자값 사용
const existingInput = area.querySelector('.seq-val');
const existingWait = area.querySelector('.wait-input');
const val = existingInput ? existingInput.value : defaultValue;
const wait = existingWait ? existingWait.value : defaultWait;
if (type === 'string') {
area.innerHTML = `<input type="text" class="code-input seq-val" placeholder='CSS 선택자 (예: .btn-login)' value="${val}">`;
} else if (type === 'target') {
area.innerHTML = `
<div style="flex-grow:1;">
<span style="font-family:monospace; font-size:12px;">() => </span>
<input type="text" class="code-input seq-val" style="width: calc(100% - 40px);" placeholder="document.querySelector('.btn')" value="${val}">
</div>
<input type="number" class="wait-input" placeholder="Wait(ms)" value="${wait}">
`;
} else if (type === 'action') {
area.innerHTML = `
<textarea class="code-input seq-val" placeholder="if(condition) { location.href='...'; }">${val}</textarea>
<input type="number" class="wait-input" placeholder="Wait(ms)" value="${wait}">
`;
}
}
function addBlacklistUrl(url = '', tagsArray = []) {
const id = blacklistCounter++;
const container = document.getElementById('blacklist-container');
const tags = tagsArray.join(', ');
const html = `
<div class="url-block" id="bl-${id}">
<button class="remove-btn" onclick="document.getElementById('bl-${id}').remove()">삭제</button>
<label><strong>URL 키워드:</strong></label>
<input type="text" class="bl-url" placeholder="예: any 또는 특정 URL" value="${url}">
<div style="margin-top: 10px;">
<label><strong>제외 키워드:</strong> (콤마 ',' 로 구분)</label><br>
<input type="text" class="bl-tags" style="width: 100%; margin-top:5px;" placeholder="logout, delete" value="${tags}">
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
}
// --- JSON Load & Export 로직 ---
function loadFromJson(data) {
document.getElementById('sequences-container').innerHTML = '';
document.getElementById('blacklist-container').innerHTML = '';
if (data.sequences) {
for (const [url, steps] of Object.entries(data.sequences)) {
const urlId = addSequenceUrl(url);
steps.forEach(step => addStep(urlId, step));
}
}
if (data.blacklist) {
for (const [url, tags] of Object.entries(data.blacklist)) {
addBlacklistUrl(url, tags);
}
}
generateCode();
}
function extractCurrentData() {
const data = { sequences: {}, blacklist: {} };
document.querySelectorAll('[id^="seq-"]').forEach(urlBlock => {
const url = urlBlock.querySelector('.seq-url').value.trim();
if (!url) return;
data.sequences[url] = [];
urlBlock.querySelectorAll('.step-block').forEach(stepBlock => {
const type = stepBlock.querySelector('.step-type').value;
const val = stepBlock.querySelector('.seq-val').value.trim();
const waitInput = stepBlock.querySelector('.wait-input');
const wait = waitInput && waitInput.value ? waitInput.value : "";
if (val) data.sequences[url].push({ type, value: val, wait });
});
});
document.querySelectorAll('[id^="bl-"]').forEach(blBlock => {
const url = blBlock.querySelector('.bl-url').value.trim();
const tags = blBlock.querySelector('.bl-tags').value.split(',').map(t => t.trim()).filter(t => t);
if (url && tags.length > 0) data.blacklist[url] = tags;
});
return data;
}
function exportJson() {
const data = extractCurrentData();
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "config_project.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
// --- 자바스크립트 코드(앱용) 생성 로직 ---
function generateCode() {
const data = extractCurrentData();
let code = "const config = {\n sequences: {\n";
for (const [url, steps] of Object.entries(data.sequences)) {
code += ` "${url}": [\n`;
steps.forEach(step => {
const waitStr = step.wait ? `, wait: ${step.wait}` : '';
if (step.type === 'string') {
code += ` "${step.value}",\n`;
} else if (step.type === 'target') {
code += ` { target: () => ${step.value}${waitStr} },\n`;
} else if (step.type === 'action') {
const formattedVal = step.value.includes('\n') ? `\n ${step.value.replace(/\n/g, '\n ')}\n ` : step.value;
code += ` { action: () => { ${formattedVal} }${waitStr} },\n`;
}
});
code += ` ],\n`;
}
code += " },\n blacklist: {\n";
for (const [url, tags] of Object.entries(data.blacklist)) {
const tagsStr = tags.map(t => `"${t}"`).join(', ');
code += ` "${url}": [${tagsStr}],\n`;
}
code += " }\n};";
document.getElementById('outputCode').textContent = code;
}
function copyCode() {
const text = document.getElementById('outputCode').textContent;
navigator.clipboard.writeText(text).then(() => alert('코드가 복사되었습니다!'));
}
// --- Drag & Drop 파일 업로드 로직 ---
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleFile(e.target.files[0]);
});
function handleFile(file) {
if (!file.name.endsWith('.json')) {
alert('JSON 파일만 업로드 가능합니다.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
loadFromJson(json);
alert('데이터를 성공적으로 불러왔습니다.');
} catch (err) {
alert('JSON 파싱에 실패했습니다: ' + err.message);
}
};
reader.readAsText(file);
}
// --- 초기 실행 ---
window.onload = () => {
loadFromJson(sampleData);
};
</script>
</body>
</html>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,8 @@ import androidx.recyclerview.widget.GridLayoutManager
import bums.lunatic.launcher.BuildConfig
import bums.lunatic.launcher.R
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
import bums.lunatic.launcher.home.NeoRssActivity
import bums.lunatic.launcher.home.WebReaderActivity
import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.model.SimpleContact
import bums.lunatic.launcher.utils.Blog
@@ -93,6 +95,10 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
fun showReader() {
startActivity(Intent(context, WebReaderActivity::class.java))
}
private var currentScope: String = CategoryGrouper.SCOPE_ALL
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -245,6 +251,10 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
filterAppsList(keyword)
}
binding.searchReader.setOnClickListener {
showReader()
dismiss()
}
binding.searchTaxi.setOnClickListener {
@@ -630,7 +630,7 @@ open class NeoRssActivity : CommonActivity() {
targetFragment = when(id) {
R.id.feeds -> RssHome()
// R.id.webtoons -> TokiFragment.newInstanceWebtoons()
R.id.webtoons -> TokiFragment.newInstanceNovels()
// R.id.comics -> TokiFragment.newInstanceComics()
R.id.youtube -> TokiFragment.newInstanceYouTube()
// R.id.perplexity -> TokiFragment.newInstancePerplexity()
@@ -1,127 +1,265 @@
package bums.lunatic.launcher.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import bums.lunatic.launcher.R
import androidx.core.net.toUri
import bums.lunatic.launcher.utils.Blog
class WebReaderActivity : AppCompatActivity() {
private lateinit var webView: WebView
// 웹뷰에서 파일을 선택할 때 결과를 받을 콜백 변수
private var filePathCallback: ValueCallback<Array<Uri>>? = null
// 원본 서버 주소 (이 주소로 위조할 예정)
private val SERVER_URL = "https://git.lunaticbum.kr/reader.html"
private val BASE_URL = "https://git.lunaticbum.kr/"
// 백업 데이터를 임시로 저장할 변수
private var pendingBackupData: String? = null
// 파일 탐색기 결과를 처리하는 런처 (최신 Activity Result API 사용)
// 1. 파일 열기 (업로드/복원용) 런처
private val fileChooserLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val uriList = if (data?.data != null) {
arrayOf(data.data!!)
} else {
null
}
// 웹뷰로 선택한 파일 URI 전달
val uriList = if (data?.data != null) arrayOf(data.data!!) else null
filePathCallback?.onReceiveValue(uriList)
} else {
// 취소했을 경우 반드시 null을 전달해야 웹뷰가 멈추지 않음
filePathCallback?.onReceiveValue(null)
}
filePathCallback = null
}
private fun isNetworkConnected(): Boolean {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
return when {
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true
else -> false
}
}
// 2. 파일 저장 (백업용) 런처 - 안드로이드 표준 저장 탐색기 호출
private val fileSaveLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri: Uri? ->
uri?.let { saveUri ->
pendingBackupData?.let { jsonData ->
try {
// 선택한 경로에 백업 데이터 쓰기
contentResolver.openOutputStream(saveUri)?.use { outputStream ->
outputStream.write(jsonData.toByteArray(Charsets.UTF_8))
}
Toast.makeText(this, "서재 백업이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "백업 저장에 실패했습니다.", Toast.LENGTH_SHORT).show()
e.printStackTrace()
}
}
}
pendingBackupData = null // 작업 후 초기화
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_wr)
webView = findViewById(R.id.webView)
setupWebView()
// assets 폴더에 넣은 HTML 파일 로드
webView.loadUrl("https://git.lunaticbum.kr/reader.html")
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
// 볼륨 다운 버튼 -> 다음 페이지 (오른쪽 화살표)
// 웹뷰에 JavaScript 키보드 이벤트를 강제로 발생시킴
webView.evaluateJavascript(
"window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowRight'}));",
null
)
return true // true를 반환하면 시스템 볼륨이 변경되지 않고 이벤트가 소비됨
}
KeyEvent.KEYCODE_VOLUME_UP -> {
// 볼륨 업 버튼 -> 이전 페이지 (왼쪽 화살표)
webView.evaluateJavascript(
"window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowLeft'}));",
null
)
return true // true를 반환하면 시스템 볼륨이 변경되지 않음
}
// 🌟 수정된 부분: 인터넷 상태를 먼저 체크합니다!
if (isNetworkConnected()) {
// 온라인이면 서버로 접속
webView.loadUrl(SERVER_URL)
} else {
// 오프라인이면 즉시 로컬 에셋(위조 모드)으로 실행
loadLocalHtmlAsServer()
}
// 볼륨 키가 아닌 다른 키(뒤로가기 등)는 기본 안드로이드 동작을 수행하도록 넘김
return super.onKeyDown(keyCode, event)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
var uri : Uri? = webView.url?.toUri()
if (webView.canGoBack() && (uri?.queryParameterNames?.size ?: 0) > 0) {
webView.loadUrl(SERVER_URL)
} else {
this@WebReaderActivity.finish()
}
}
})
}
private fun setupWebView() {
// 1. 웹뷰 기본 설정
webView.settings.apply {
javaScriptEnabled = true // 자바스크립트 필수
domStorageEnabled = true // localStorage, IndexedDB 사용을 위해 필수
allowFileAccess = true // 로컬 파일 접근 허용
allowContentAccess = true // Content URI 접근 허용
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // 보안 정책 우회 (필요시)
// 모바일 화면에 맞게 뷰포트 설정
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
useWideViewPort = true
loadWithOverviewMode = true
}
// 2. 새 창 열림 방지 (앱 내부에서만 렌더링)
webView.webViewClient = WebViewClient()
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url.toString()
// 1. 현재 오프라인 상태이고
// 2. 이동하려는 주소가 내 서버 주소(예: reader.html?id=123)일 때
if (!isNetworkConnected() && url.startsWith(BASE_URL)) {
// 실제 네트워크 통신을 차단하고, 해당 URL로 위조된 로컬 HTML을 다시 로드
loadLocalHtmlAsServer(url)
// return true를 하면 웹뷰가 자체적인 네트워크 통신을 진행하지 않음
return true
}
return super.shouldOverrideUrlLoading(view, request)
}
// 🌐 에러 감지 1: 비행기 모드, 인터넷 끊김 등
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
super.onReceivedError(view, request, error)
if (request?.isForMainFrame == true) {
loadLocalHtmlAsServer()
}
}
// 🌐 에러 감지 2: 서버 점검, 404/500 에러 등
override fun onReceivedHttpError(
view: WebView?,
request: WebResourceRequest?,
errorResponse: WebResourceResponse?
) {
super.onReceivedHttpError(view, request, errorResponse)
if (request?.isForMainFrame == true) {
loadLocalHtmlAsServer()
}
}
// 🚨 핵심 포인트: JS 파일 가로채기
// HTML이 로컬에서 로드되더라도 주소가 위조되었기 때문에 JS파일도 서버에 요청해버립니다.
// 이를 중간에 가로채서 로컬 assets 폴더의 jszip.min.js를 던져줍니다.
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url.toString()
if (url.contains("jszip.min.js")) {
return try {
val inputStream = assets.open("jszip.min.js")
WebResourceResponse("text/javascript", "UTF-8", inputStream)
} catch (e: Exception) {
null
}
}
return super.shouldInterceptRequest(view, request)
}
}
// 3. 웹 -> 안드로이드 통신을 위한 브릿지 연결
webView.addJavascriptInterface(WebAppInterface(), "AndroidBridge")
// 3. 파일 업로드(<input type="file">) 처리를 위한 WebChromeClient 설정
webView.webChromeClient = object : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
// 이전 콜백이 남아있다면 취소 처리
this@WebReaderActivity.filePathCallback?.onReceiveValue(null)
this@WebReaderActivity.filePathCallback = filePathCallback
// 안드로이드 파일 탐색기 호출 Intent
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
// txt, epub만 선택하게 하려면 아래처럼 mimeTypes 지정 가능
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("text/plain", "application/epub+zip"))
}
fileChooserLauncher.launch(intent)
return true
}
}
}
// 뒤로가기 버튼 처리 (웹뷰 내에서 뒤로 갈 수 있으면 웹뷰 뒤로가기 실행)
/**
* assets 폴더의 html 파일을 읽어와서 서버 주소로 위조하여 웹뷰에 로드합니다.
*/
private fun loadLocalHtmlAsServer(targetUrl: String = SERVER_URL) {
try {
Blog.LOGE("targetUrl ${targetUrl}")
val htmlString = assets.open("reader.html").bufferedReader().use { it.readText() }
webView.loadDataWithBaseURL(
targetUrl,
htmlString,
"text/html",
"UTF-8",
targetUrl // 🌟 핵심: ?id= 파라미터가 붙은 주소로 히스토리를 위조!
)
Toast.makeText(this, "오프라인 모드로 실행되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this, "로컬 파일을 불러오지 못했습니다.", Toast.LENGTH_SHORT).show()
}
}
// 웹에서 호출할 수 있는 인터페이스 클래스
inner class WebAppInterface {
@JavascriptInterface
fun saveBackup(jsonData: String) {
// 웹에서 전달받은 JSON 데이터를 임시 저장
pendingBackupData = jsonData
// UI 스레드에서 파일 저장 탐색기 실행
runOnUiThread {
val fileName = "web_reader_backup_${System.currentTimeMillis()}.json"
fileSaveLauncher.launch(fileName)
}
}
}
// 볼륨 키 이벤트 가로채기 (페이지 넘김)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
webView.evaluateJavascript("window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowRight'}));", null)
return true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
webView.evaluateJavascript("window.dispatchEvent(new KeyboardEvent('keydown', {'key': 'ArrowLeft'}));", null)
return true
}
}
return super.onKeyDown(keyCode, event)
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
@@ -189,9 +189,9 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
fun newInstanceNovels(): TokiFragment = TokiFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, "web")
putInt(ARG_LAST_NUM, 468)
putString(ARG_NAME, "git.lunaticbum")
putString(ARG_DOT, ".kr/reader.html")
putInt(ARG_LAST_NUM, 26)
putString(ARG_NAME, "bookto26®")
putString(ARG_DOT, ".com")
putBoolean(ARG_USE_NUM_URL, false)
putBoolean(ARG_ENABLE_GESTURE, true)
}
@@ -131,6 +131,12 @@
android:background="@color/black"
android:gravity="center_vertical"
>
<TextView
app:autoSizeTextType="uniform"
style="@style/SearchAccs"
android:id="@+id/search_reader"
android:text="READER"
/>
<androidx.appcompat.widget.AppCompatSpinner
style="@style/SearchAccs"
android:layout_marginLeft="6dp"
+4 -4
View File
@@ -46,10 +46,10 @@
app:fab_label="📚"
style="@style/CommonFabStyle"
android:id="@+id/books"/>
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
<!-- app:fab_label="🎨"-->
<!-- style="@style/CommonFabStyle"-->
<!-- android:id="@+id/webtoons"/>-->
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🎨"
style="@style/CommonFabStyle"
android:id="@+id/webtoons"/>
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
<!-- app:fab_label="🗯️"-->
<!-- style="@style/CommonFabStyle"-->