...
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
|
||||
Vendored
+13
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"
|
||||
|
||||
@@ -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"-->
|
||||
|
||||
Reference in New Issue
Block a user