...
This commit is contained in:
@@ -144,12 +144,12 @@
|
|||||||
android:theme="@style/Theme.Player"
|
android:theme="@style/Theme.Player"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:roundIcon="@drawable/ic_launcher"
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
android:label="리더"
|
android:label="파일즈"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|fontScale|keyboardHidden|keyboard|layoutDirection|navigation"
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|fontScale|keyboardHidden|keyboard|layoutDirection|navigation"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:excludeFromRecents="false"
|
android:excludeFromRecents="false"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:taskAffinity="bums.lunatic.launcher.home.WebReaderActivity"
|
android:taskAffinity="bums.lunatic.launcher.home.CompletedFilesActivity"
|
||||||
android:launchMode="singleTask"> <intent-filter>
|
android:launchMode="singleTask"> <intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
Binary file not shown.
@@ -22,7 +22,8 @@
|
|||||||
"*://newtoki1.org/novel/*",
|
"*://newtoki1.org/novel/*",
|
||||||
"*://*.newtoki1.org/novel/*",
|
"*://*.newtoki1.org/novel/*",
|
||||||
"*://toki35.com/novel/*",
|
"*://toki35.com/novel/*",
|
||||||
"*://sbxh9.com/novel/*"
|
"*://sbxh9.com/novel/*",
|
||||||
|
"*://toki36.com/novel/*"
|
||||||
],
|
],
|
||||||
"js": ["inject.js"],
|
"js": ["inject.js"],
|
||||||
"run_at": "document_start",
|
"run_at": "document_start",
|
||||||
@@ -33,7 +34,8 @@
|
|||||||
"*://newtoki1.org/novel/*",
|
"*://newtoki1.org/novel/*",
|
||||||
"*://*.newtoki1.org/novel/*",
|
"*://*.newtoki1.org/novel/*",
|
||||||
"*://toki35.com/novel/*",
|
"*://toki35.com/novel/*",
|
||||||
"*://sbxh9.com/novel/*"
|
"*://sbxh9.com/novel/*",
|
||||||
|
"*://toki36.com/novel/*"
|
||||||
],
|
],
|
||||||
"js": ["content.js"],
|
"js": ["content.js"],
|
||||||
"run_at": "document_end"
|
"run_at": "document_end"
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
@@ -1,16 +1,2 @@
|
|||||||
chrome.webRequest.onBeforeRequest.addListener(
|
// Manifest V3에서는 네트워크 차단을 rules.json이 담당하므로 이 파일은 비워둡니다.
|
||||||
function(details) {
|
console.log("Darama AdBlocker 서비스 워커가 실행되었습니다.");
|
||||||
if (details.type === "main_frame") {
|
|
||||||
const currentUrl = details.originUrl || details.documentUrl || "";
|
|
||||||
|
|
||||||
// 현재 URL에 'darama'가 포함되어 있는데, 이동할 URL에는 'darama'가 없다면 차단
|
|
||||||
if (currentUrl.includes("darama") && !details.url.includes("darama")) {
|
|
||||||
console.log("Redirect blocked for URL:", details.url);
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
{ urls: ["<all_urls>"] },
|
|
||||||
["blocking"] // 요청을 가로채고 취소할 수 있는 권한
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name Darama Safe Blocker
|
||||||
|
// @namespace http://tampermonkey.net/
|
||||||
|
// @version 1.7
|
||||||
|
// @description 사파리 @allFrames 적용 및 투명 iframe/새 탭 링크 원천 제거
|
||||||
|
// @match *://*/*
|
||||||
|
// @run-at document-start
|
||||||
|
// @inject-into page
|
||||||
|
// @allFrames true
|
||||||
|
// @grant none
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 1. 페이지가 그려지기도 전에 CSS로 투명 오버레이 iframe과 외부 새 탭 링크의 터치 판정을 완전히 제거
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
/* 처음에 발견된 전체화면 투명 오버레이 iframe 무력화 */
|
||||||
|
iframe[style*="2147483647"],
|
||||||
|
iframe[style*="position: fixed"],
|
||||||
|
iframe[style*="position:fixed"],
|
||||||
|
iframe[src="about:blank"],
|
||||||
|
iframe:not([src]),
|
||||||
|
/* 외부 도메인으로 새 탭을 여는 투명 링크들의 터치 판정 제거 */
|
||||||
|
a[target="_blank"]:not([href*="dramadunyam.com"]) {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
z-index: -999999 !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
|
||||||
|
// 2. window.open 및 동적 a 태그 생성 원천 봉쇄
|
||||||
|
window.open = function() { return null; };
|
||||||
|
|
||||||
|
const origCreateElement = document.createElement;
|
||||||
|
document.createElement = function(tagName) {
|
||||||
|
const el = origCreateElement.apply(this, arguments);
|
||||||
|
if (String(tagName).toLowerCase() === 'a') {
|
||||||
|
// 광고 스크립트가 메모리 상에서 몰래 <a> 태그를 만들어 클릭시키는 수법 차단
|
||||||
|
Object.defineProperty(el, 'target', {
|
||||||
|
get: () => '_self',
|
||||||
|
set: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 손가락이 화면에 닿는 순간(touchstart, pointerdown) 모든 a 태그의 새 탭 속성 강제 삭제
|
||||||
|
const neutralizeLink = (e) => {
|
||||||
|
// Shadow DOM 내부의 링크까지 추적
|
||||||
|
const path = e.composedPath ? e.composedPath() : [e.target];
|
||||||
|
for (const node of path) {
|
||||||
|
if (node && node.tagName === 'A') {
|
||||||
|
const href = node.href || '';
|
||||||
|
if (node.target === '_blank' || (href.startsWith('http') && !href.includes('dramadunyam.com'))) {
|
||||||
|
node.removeAttribute('target');
|
||||||
|
node.removeAttribute('href');
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
['touchstart', 'pointerdown', 'mousedown', 'touchend', 'mouseup', 'click'].forEach(evt => {
|
||||||
|
window.addEventListener(evt, neutralizeLink, { capture: true, passive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 동적으로 삽입되는 투명 iframe 실시간 제거 (일반 div나 플레이어는 건드리지 않음)
|
||||||
|
const killAdIframes = () => {
|
||||||
|
// 모든 외부 _blank 링크 무력화
|
||||||
|
document.querySelectorAll('a[target="_blank"]').forEach(a => {
|
||||||
|
if (!a.href || !a.href.includes('dramadunyam.com')) {
|
||||||
|
a.removeAttribute('target');
|
||||||
|
a.removeAttribute('href');
|
||||||
|
a.style.setProperty('pointer-events', 'none', 'important');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 투명 덮개 iframe만 정밀 삭제
|
||||||
|
document.querySelectorAll('iframe').forEach(iframe => {
|
||||||
|
const s = iframe.getAttribute('style') || '';
|
||||||
|
const src = iframe.getAttribute('src') || '';
|
||||||
|
const comp = window.getComputedStyle(iframe);
|
||||||
|
|
||||||
|
// 화면에 고정(fixed)되어 있거나 z-index가 비정상적으로 높은 투명 iframe 타격
|
||||||
|
if (
|
||||||
|
s.includes('2147483647') ||
|
||||||
|
s.includes('fixed') ||
|
||||||
|
comp.position === 'fixed' ||
|
||||||
|
parseInt(comp.zIndex) > 9999 ||
|
||||||
|
!src ||
|
||||||
|
src === 'about:blank'
|
||||||
|
) {
|
||||||
|
iframe.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new MutationObserver(killAdIframes);
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
||||||
|
setInterval(killAdIframes, 150);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
chrome.webRequest.onBeforeRequest.addListener(
|
||||||
|
function(details) {
|
||||||
|
if (details.type === "main_frame") {
|
||||||
|
const currentUrl = details.originUrl || details.documentUrl || "";
|
||||||
|
|
||||||
|
// 현재 URL에 'dramadunyam'이 포함되어 있는데, 이동할 URL에는 'dramadunyam'이 없다면 외부 사이트로 간주하고 차단
|
||||||
|
if (currentUrl.includes("dramadunyam") && !details.url.includes("dramadunyam")) {
|
||||||
|
console.log("외부 팝업/리다이렉트 차단됨:", details.url);
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
{ urls: ["<all_urls>"] },
|
||||||
|
["blocking"]
|
||||||
|
);
|
||||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"version": "1.0",
|
||||||
|
"name": "Darama AdBlocker (Orion)",
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"]
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"run_at": "document_start",
|
||||||
|
"matches": ["<all_urls>"],
|
||||||
|
"js": ["messaging.js"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"webRequest",
|
||||||
|
"webRequestBlocking",
|
||||||
|
"<all_urls>"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// 1. 투명 링크(a 태그)를 통한 새 탭 열기 완벽 차단
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const aTag = event.target.closest('a');
|
||||||
|
|
||||||
|
if (aTag) {
|
||||||
|
const href = aTag.href || "";
|
||||||
|
const currentDomain = location.hostname.replace("www.", "");
|
||||||
|
|
||||||
|
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
console.log("투명 a 태그 외부 광고 클릭 차단됨:", href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
|
||||||
|
// 2. 자바스크립트(window.open)를 통한 팝업/새 탭 열기 강제 무력화
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.textContent = `
|
||||||
|
window.open = function() {
|
||||||
|
console.log("window.open을 통한 새 탭/팝업 열기 차단됨");
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
script.remove();
|
||||||
|
|
||||||
|
|
||||||
|
// 3. 투명 오버레이 덮개 광고 강제 삭제
|
||||||
|
function removeInvisibleOverlays() {
|
||||||
|
const elements = document.querySelectorAll('iframe, div, a');
|
||||||
|
elements.forEach(el => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
style.zIndex >= 999 &&
|
||||||
|
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||||
|
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||||
|
) {
|
||||||
|
console.log("화면 덮개형 광고 요소 강제 삭제됨:", el);
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 4. 공통 광고 요소 및 오버레이 제거 실행 함수
|
||||||
|
function handleCommon(parentElement) {
|
||||||
|
if (!parentElement.querySelectorAll) return;
|
||||||
|
removeInvisibleOverlays();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 5. 페이지 로드 시 초기 정리 작업 실행
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
handleCommon(document);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 6. DOM 변화를 감시하여 동적으로 추가되는 아이프레임 광고 차단
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
|
||||||
|
// dramadunyam 도메인에서 동적으로 삽입되는 iframe 즉시 삭제
|
||||||
|
if (location.href.includes("dramadunyam")) {
|
||||||
|
if (node.tagName === 'IFRAME') {
|
||||||
|
node.remove();
|
||||||
|
} else if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll('iframe').forEach(iframe => iframe.remove());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCommon(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 1. 투명 오버레이 덮개 광고 및 불필요한 iframe 제거
|
||||||
|
function cleanUpAds() {
|
||||||
|
// 화면 전체를 덮는 투명 막(div, a, iframe) 제거
|
||||||
|
const elements = document.querySelectorAll('iframe, div, a');
|
||||||
|
elements.forEach(el => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
parseInt(style.zIndex) >= 999 &&
|
||||||
|
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||||
|
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||||
|
) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 동적으로 삽입되는 광고용 iframe 제거
|
||||||
|
document.querySelectorAll('iframe').forEach(iframe => {
|
||||||
|
const src = iframe.getAttribute('src') || '';
|
||||||
|
// 본문 영상 플레이어가 아닌 빈 iframe이나 광고성 iframe 삭제
|
||||||
|
if (!src || src === 'about:blank' || parseInt(window.getComputedStyle(iframe).zIndex) > 100) {
|
||||||
|
iframe.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 실행
|
||||||
|
cleanUpAds();
|
||||||
|
|
||||||
|
// 2. 화면 조작(클릭 등) 시 동적으로 추가되는 광고 실시간 감시 및 삭제
|
||||||
|
if (document.body) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
cleanUpAds();
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 1. 자바스크립트 window.open 무력화
|
||||||
|
window.open = function() {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 외부로 이동하는 새 탭(_blank) 링크 클릭 가로채기
|
||||||
|
window.addEventListener('click', function(event) {
|
||||||
|
const aTag = event.target.closest ? event.target.closest('a') : null;
|
||||||
|
if (aTag) {
|
||||||
|
const href = aTag.href || "";
|
||||||
|
const currentDomain = location.hostname.replace("www.", "");
|
||||||
|
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Darama support",
|
||||||
|
"version": "1.2",
|
||||||
|
"description": "dramadunyam.com 전용 투명 아이프레임 및 새 탭 광고 차단기",
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"activeTab",
|
||||||
|
"scripting",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://dramadunyam.com/*",
|
||||||
|
"*://*.dramadunyam.com/*"
|
||||||
|
],
|
||||||
|
"js": ["inject.js"],
|
||||||
|
"run_at": "document_start",
|
||||||
|
"world": "MAIN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://dramadunyam.com/*",
|
||||||
|
"*://*.dramadunyam.com/*"
|
||||||
|
],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_end"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body { width: 200px; padding: 12px; font-family: sans-serif; text-align: center; background: #f9f9f9; }
|
||||||
|
.status { color: #27ae60; font-weight: bold; font-size: 14px; margin-bottom: 5px; }
|
||||||
|
.desc { font-size: 12px; color: #666; margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="status">✓ Darama 차단기 작동 중</div>
|
||||||
|
<p class="desc">dramadunyam.com의 새 탭 및 오버레이 광고를 방어합니다.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 3,
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"name": "Darama AdBlocker",
|
"name": "Darama AdBlocker",
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.js"]
|
"service_worker": "background.js"
|
||||||
},
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -13,9 +13,18 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"cookies",
|
"declarativeNetRequest"
|
||||||
"<all_urls>",
|
],
|
||||||
"webRequest",
|
"host_permissions": [
|
||||||
"webRequestBlocking"
|
"<all_urls>"
|
||||||
]
|
],
|
||||||
|
"declarative_net_request": {
|
||||||
|
"rule_resources": [
|
||||||
|
{
|
||||||
|
"id": "block_redirects",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "rules.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,70 @@
|
|||||||
// 1. 페이지 로드 시 정적 광고 및 불필요한 요소 즉시 제거
|
// 1. 투명 링크(a 태그)를 통한 새 탭 열기 완벽 차단
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const aTag = event.target.closest('a');
|
||||||
|
|
||||||
|
if (aTag) {
|
||||||
|
const href = aTag.href || "";
|
||||||
|
const currentDomain = location.hostname.replace("www.", "");
|
||||||
|
|
||||||
|
if (aTag.target === '_blank' && href && !href.includes(currentDomain) && !href.startsWith("/")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
console.log("투명 a 태그 외부 광고 클릭 차단됨:", href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
|
||||||
|
// 2. 자바스크립트(window.open)를 통한 팝업/새 탭 열기 강제 무력화
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.textContent = `
|
||||||
|
window.open = function() {
|
||||||
|
console.log("window.open을 통한 새 탭/팝업 열기 차단됨");
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
script.remove();
|
||||||
|
|
||||||
|
|
||||||
|
// 3. 투명 오버레이 덮개 광고 강제 삭제
|
||||||
|
function removeInvisibleOverlays() {
|
||||||
|
const elements = document.querySelectorAll('iframe, div, a');
|
||||||
|
elements.forEach(el => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (
|
||||||
|
style.zIndex >= 999 &&
|
||||||
|
(style.position === 'fixed' || style.position === 'absolute') &&
|
||||||
|
(style.width === '100%' || parseInt(style.width) >= window.innerWidth * 0.9)
|
||||||
|
) {
|
||||||
|
console.log("화면 덮개형 광고 요소 강제 삭제됨:", el);
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 4. 공통 광고 요소 및 오버레이 제거 실행 함수
|
||||||
|
function handleCommon(parentElement) {
|
||||||
|
if (!parentElement.querySelectorAll) return;
|
||||||
|
removeInvisibleOverlays();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 5. 페이지 로드 시 초기 정리 작업 실행
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
handleCommon(document);
|
handleCommon(document);
|
||||||
// applyDomainRules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. DOM 변화를 실시간 감시하여 동적 아이프레임 및 광고 추가 완벽 차단
|
|
||||||
|
// 6. DOM 변화를 감시하여 동적으로 추가되는 아이프레임 광고 차단
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
|
||||||
// [darama 도메인] 동적으로 삽입되는 iframe 즉시 삭제
|
// dramadunyam 도메인에서 동적으로 삽입되는 iframe 즉시 삭제
|
||||||
if (location.href.includes("darama")) {
|
if (location.href.includes("dramadunyam")) {
|
||||||
if (node.tagName === 'IFRAME') {
|
if (node.tagName === 'IFRAME') {
|
||||||
node.remove();
|
node.remove();
|
||||||
} else if (node.querySelectorAll) {
|
} else if (node.querySelectorAll) {
|
||||||
@@ -19,7 +72,6 @@ const observer = new MutationObserver((mutations) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적으로 삽입되는 공통 광고 노드 지속 삭제
|
|
||||||
handleCommon(node);
|
handleCommon(node);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,50 +81,3 @@ observer.observe(document.documentElement, {
|
|||||||
childList: true,
|
childList: true,
|
||||||
subtree: true
|
subtree: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 공통 광고 요소 제거 함수 (정적/동적 DOM 모두 대응)
|
|
||||||
function handleCommon(parentElement) {
|
|
||||||
if (!parentElement.querySelectorAll) return;
|
|
||||||
|
|
||||||
// const adSelectors = [
|
|
||||||
// ".top_google_ad_space", ".adv-group", "[id^='div-gpt-ad']",
|
|
||||||
// "[id^='div_adnmore_area']", "[class^='adv-groupno']", ".code-block",
|
|
||||||
// ".ad-template", "#xpromo-bottom-sheet", "[data-banner^='coupang-']",
|
|
||||||
// "[id^='mobonDivBanner']", "[id^='pnlTopAd']", "[class^='ad-tag']",
|
|
||||||
// "[class^='science-banner-area']"
|
|
||||||
// ];
|
|
||||||
//
|
|
||||||
// adSelectors.forEach(selector => {
|
|
||||||
// parentElement.querySelectorAll(selector).forEach(e => e.remove());
|
|
||||||
// });
|
|
||||||
|
|
||||||
// parentElement.querySelectorAll('iframe').forEach(e => {
|
|
||||||
// const src = e.getAttribute("src");
|
|
||||||
// if (src && (src.includes("ads") || src.includes("coupang"))) {
|
|
||||||
// e.remove();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 도메인별 맞춤형 요소 제거 (기존 룰 단순화)
|
|
||||||
function applyDomainRules() {
|
|
||||||
const url = location.href;
|
|
||||||
|
|
||||||
if (url.includes("nate.com")) {
|
|
||||||
document.querySelectorAll('[class^="float-adv-wrap"], [id^="ad_"], #ad_sponsorBar, #newsSidebar, iframe[id^="ad"]').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("zdnet.co.kr")) {
|
|
||||||
document.querySelectorAll('div[id*="FloatCon"]').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("inews24")) {
|
|
||||||
document.querySelectorAll('ad[alt^="a"], div[class^="topnews"], form[class^="talkplus"], ins, footer').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("starnewskorea")) {
|
|
||||||
document.querySelectorAll('div[class^="floating-top-ad"], div[id^="dablewidget_"], footer').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("khan.co.kr")) {
|
|
||||||
document.querySelectorAll('[class^="banner-"], [class^="google-auto-placed"], footer, iframe').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("dcinside.com")) {
|
|
||||||
document.querySelectorAll('[id^="view_btn_area"], [class^="trend-rank"], [class^="adv-group"], [id^="div_adnmore_area"]').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("fmkorea.com")) {
|
|
||||||
document.querySelectorAll('[class^="fmad_wrapper"], [class^="ad ad_wrapper"], [class^="m_top_hotdeal"]').forEach(e => e.remove());
|
|
||||||
} else if (url.includes("theqoo.net")) {
|
|
||||||
document.querySelectorAll('[class^="board_content_google_ad"], ins[class^="adsbygoogle"]').forEach(e => e.remove());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"priority": 1,
|
||||||
|
"action": {
|
||||||
|
"type": "block"
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"urlFilter": "*",
|
||||||
|
"excludedUrlFilter": "*dramadunyam*",
|
||||||
|
"initiatorDomains": ["dramadunyam.com"],
|
||||||
|
"resourceTypes": ["main_frame"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||||||
import bums.lunatic.launcher.BuildConfig
|
import bums.lunatic.launcher.BuildConfig
|
||||||
import bums.lunatic.launcher.R
|
import bums.lunatic.launcher.R
|
||||||
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
|
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
|
||||||
import bums.lunatic.launcher.home.NeoRssActivity
|
|
||||||
import bums.lunatic.launcher.home.WebReaderActivity
|
import bums.lunatic.launcher.home.WebReaderActivity
|
||||||
import bums.lunatic.launcher.model.AppInfo
|
import bums.lunatic.launcher.model.AppInfo
|
||||||
import bums.lunatic.launcher.model.SimpleContact
|
import bums.lunatic.launcher.model.SimpleContact
|
||||||
@@ -39,7 +38,9 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
|
|||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import io.realm.kotlin.ext.query
|
import io.realm.kotlin.ext.query
|
||||||
import io.realm.kotlin.query.Sort
|
import io.realm.kotlin.query.Sort
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
@@ -51,8 +52,16 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private var appsAdapter: AppsAdapter? = null
|
private var appsAdapter: AppsAdapter? = null
|
||||||
private var contactAdapter: ContactAdapter? = null
|
private var contactAdapter: ContactAdapter? = null
|
||||||
|
private var recAdapter: RecommendedAppsAdapter? = null
|
||||||
|
|
||||||
private val packageList = mutableListOf<AppInfo>()
|
private val packageList = mutableListOf<AppInfo>()
|
||||||
private val contactList = arrayListOf<SimpleContact>() // SimpleContact 클래스가 import 되어야 함
|
private val contactList = arrayListOf<SimpleContact>()
|
||||||
|
|
||||||
|
private var currentScope: String = CategoryGrouper.SCOPE_ALL
|
||||||
|
private var currentCategoryKey: String = "ALL"
|
||||||
|
|
||||||
|
private var fetchJob: Job? = null
|
||||||
|
private var isSpinnerInitialized = false
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "AppDrawerBottomSheet"
|
const val TAG = "AppDrawerBottomSheet"
|
||||||
@@ -62,19 +71,15 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배경을 투명하게 하거나 스타일을 적용하기 위해
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// res/values/themes.xml 등에 BottomSheet 스타일 정의 필요 (모서리 둥글게 등)
|
|
||||||
setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme)
|
setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 키보드가 올라올 때 바텀시트가 가려지지 않도록 설정
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val dialog = super.onCreateDialog(savedInstanceState)
|
val dialog = super.onCreateDialog(savedInstanceState)
|
||||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||||
|
|
||||||
// 바텀시트가 완전히 펼쳐진 상태로 시작하게 설정
|
|
||||||
dialog.setOnShowListener { dialogInterface ->
|
dialog.setOnShowListener { dialogInterface ->
|
||||||
val bottomSheetDialog = dialogInterface as BottomSheetDialog
|
val bottomSheetDialog = dialogInterface as BottomSheetDialog
|
||||||
val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
||||||
@@ -94,80 +99,67 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showReader() {
|
fun showReader() {
|
||||||
startActivity(Intent(context, WebReaderActivity::class.java))
|
startActivity(Intent(context, WebReaderActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var currentScope: String = CategoryGrouper.SCOPE_ALL
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
isSpinnerInitialized = false
|
||||||
setupAdapters()
|
setupAdapters()
|
||||||
setupSearchButtons()
|
setupSearchButtons()
|
||||||
setupListeners()
|
setupListeners()
|
||||||
setupKeyboardObserver()
|
setupKeyboardObserver()
|
||||||
// 초기 데이터 로드
|
|
||||||
|
|
||||||
|
|
||||||
|
// 초기 데이터 1회 로드 및 스피너 초기화
|
||||||
fetchApps()
|
fetchApps()
|
||||||
setupCategorySpinner()
|
setupCategorySpinner()
|
||||||
}
|
}
|
||||||
private var currentCategoryKey: String = "ALL"
|
|
||||||
private fun setupCategorySpinner() {
|
private fun setupCategorySpinner() {
|
||||||
// 1. 스피너에 들어갈 데이터(표시 이름들) 준비
|
|
||||||
val displayList = CategoryManualMapper.CATEGORY_MAP.keys.toList()
|
val displayList = CategoryManualMapper.CATEGORY_MAP.keys.toList()
|
||||||
|
|
||||||
// 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용)
|
|
||||||
val adapter = object : ArrayAdapter<String>(
|
val adapter = object : ArrayAdapter<String>(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
R.layout.spinner_item_dark,
|
R.layout.spinner_item_dark,
|
||||||
displayList
|
displayList
|
||||||
){
|
) {
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
val view = super.getView(position, convertView, parent)
|
val view = super.getView(position, convertView, parent)
|
||||||
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
|
view.setBackgroundColor(Color.parseColor("#000000"))
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
val view = super.getDropDownView(position, convertView, parent)
|
val view = super.getDropDownView(position, convertView, parent)
|
||||||
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
|
view.setBackgroundColor(Color.parseColor("#000000"))
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
adapter.setDropDownViewResource(R.layout.spinner_item_dark)
|
adapter.setDropDownViewResource(R.layout.spinner_item_dark)
|
||||||
binding.categorySpinner.adapter = adapter
|
binding.categorySpinner.adapter = adapter
|
||||||
|
|
||||||
// 3. 선택 리스너 설정
|
|
||||||
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||||
|
// 스피너 어댑터 연결 직후 발생하는 최초 자동 호출을 건너뛰어 중복 로딩 차단
|
||||||
|
if (!isSpinnerInitialized) {
|
||||||
|
isSpinnerInitialized = true
|
||||||
|
return
|
||||||
|
}
|
||||||
val selectedDisplayName = displayList[position]
|
val selectedDisplayName = displayList[position]
|
||||||
currentCategoryKey = CategoryManualMapper.CATEGORY_MAP[selectedDisplayName] ?: "ALL"
|
currentCategoryKey = CategoryManualMapper.CATEGORY_MAP[selectedDisplayName] ?: "ALL"
|
||||||
|
|
||||||
// 선택 변경 시 목록 갱신 (검색어 유지)
|
|
||||||
fetchApps(binding.searchInput.text.toString())
|
fetchApps(binding.searchInput.text.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupKeyboardObserver() {
|
private fun setupKeyboardObserver() {
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
|
||||||
// 키보드(IME)가 보이는지 확인
|
|
||||||
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||||
|
binding.quickSearch.visibility = if (isKeyboardVisible) View.VISIBLE else View.GONE
|
||||||
if (isKeyboardVisible) {
|
|
||||||
binding.quickSearch.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.quickSearch.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
// 키보드 높이만큼 패딩 처리 등을 자동으로 하게 둠
|
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,48 +167,41 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
|
|
||||||
val dialog = dialog as? com.google.android.material.bottomsheet.BottomSheetDialog
|
val dialog = dialog as? BottomSheetDialog
|
||||||
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
|
dialog?.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.let { bottomSheet ->
|
||||||
|
val behavior = BottomSheetBehavior.from(bottomSheet)
|
||||||
// 1. 동작 설정 (완전히 펼치기)
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bottomSheet)
|
|
||||||
behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
behavior.skipCollapsed = true
|
behavior.skipCollapsed = true
|
||||||
|
|
||||||
// 2. [핵심] 높이 강제 설정 (이게 없으면 리스트가 0dp일 때 안 보임)
|
|
||||||
val layoutParams = bottomSheet.layoutParams
|
val layoutParams = bottomSheet.layoutParams
|
||||||
layoutParams.height = android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
bottomSheet.layoutParams = layoutParams
|
bottomSheet.layoutParams = layoutParams
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private var recAdapter: RecommendedAppsAdapter? = null
|
|
||||||
private fun setupAdapters() {
|
private fun setupAdapters() {
|
||||||
// 기존 Activity의 packageManager 대신 requireContext().packageManager 사용
|
|
||||||
val pm = requireContext().packageManager
|
val pm = requireContext().packageManager
|
||||||
appsAdapter = AppsAdapter(pm, childFragmentManager, binding.appsCount)
|
appsAdapter = AppsAdapter(pm, childFragmentManager, binding.appsCount)
|
||||||
contactAdapter = ContactAdapter(requireContext(), childFragmentManager)
|
contactAdapter = ContactAdapter(requireContext(), childFragmentManager)
|
||||||
|
|
||||||
// 가로 그리드 개수 4~5개 정도로 조정 (기존 2개였으면 그대로 유지)
|
binding.appsList.layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false)
|
||||||
binding.appsList.layoutManager = GridLayoutManager(context, 3,GridLayoutManager.HORIZONTAL,false)
|
|
||||||
binding.appsList.adapter = appsAdapter
|
binding.appsList.adapter = appsAdapter
|
||||||
binding.appsList.setItemViewCacheSize(20)
|
binding.appsList.setItemViewCacheSize(20)
|
||||||
binding.appsList.setHasFixedSize(true) //
|
binding.appsList.setHasFixedSize(true)
|
||||||
|
|
||||||
binding.contactList.layoutManager = GridLayoutManager(context, 2,GridLayoutManager.HORIZONTAL,false)
|
binding.contactList.layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
|
||||||
binding.contactList.adapter = contactAdapter
|
binding.contactList.adapter = contactAdapter
|
||||||
|
|
||||||
recAdapter = RecommendedAppsAdapter(requireContext(),pm,childFragmentManager)
|
recAdapter = RecommendedAppsAdapter(requireContext(), pm, childFragmentManager)
|
||||||
binding.recAppsList.layoutManager = GridLayoutManager(context, 2,GridLayoutManager.HORIZONTAL,false)
|
binding.recAppsList.layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
|
||||||
binding.recAppsList.adapter = recAdapter
|
binding.recAppsList.adapter = recAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupListeners() {
|
private fun setupListeners() {
|
||||||
// 검색어 입력 리스너
|
|
||||||
binding.searchInput.doOnTextChanged { inputText, _, _, _ ->
|
binding.searchInput.doOnTextChanged { inputText, _, _, _ ->
|
||||||
filterAppsList(inputText.toString())
|
filterAppsList(inputText.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 엔터키 입력 시 검색 실행
|
|
||||||
binding.searchInput.setOnKeyListener { _, keyCode, event ->
|
binding.searchInput.setOnKeyListener { _, keyCode, event ->
|
||||||
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
|
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
|
||||||
checkResult(binding.searchInput.text.toString())
|
checkResult(binding.searchInput.text.toString())
|
||||||
@@ -226,20 +211,16 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로고침 버튼
|
|
||||||
binding.reset.setOnClickListener {
|
binding.reset.setOnClickListener {
|
||||||
binding.searchInput.text?.clear()
|
binding.searchInput.text?.clear()
|
||||||
fetchApps()
|
fetchApps()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val keyword : String
|
val keyword: String
|
||||||
get() {
|
get() = binding.searchInput.text.toString()
|
||||||
return binding.searchInput.text.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupSearchButtons() {
|
private fun setupSearchButtons() {
|
||||||
// 기존 AppDrawer의 버튼 연결
|
|
||||||
binding.searchNmap.setOnClickListener {
|
binding.searchNmap.setOnClickListener {
|
||||||
openSearchApps("nmap://search?query=${keyword}&appname=${BuildConfig.APPLICATION_ID}", "com.nhn.android.nmap")
|
openSearchApps("nmap://search?query=${keyword}&appname=${BuildConfig.APPLICATION_ID}", "com.nhn.android.nmap")
|
||||||
}
|
}
|
||||||
@@ -249,58 +230,44 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
binding.hidden.setOnClickListener {
|
binding.hidden.setOnClickListener {
|
||||||
binding.hidden.isSelected = !binding.hidden.isSelected
|
binding.hidden.isSelected = !binding.hidden.isSelected
|
||||||
filterAppsList(keyword)
|
filterAppsList(keyword)
|
||||||
|
|
||||||
}
|
}
|
||||||
binding.searchReader.setOnClickListener {
|
binding.searchReader.setOnClickListener {
|
||||||
showReader()
|
showReader()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
binding.searchTaxi.setOnClickListener {
|
binding.searchTaxi.setOnClickListener {
|
||||||
//// val destName = Uri.encode("서울역")
|
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696", "UTF-8")}&goaly=${URLEncoder.encode("127.101063", "UTF-8")}", "com.kakao.taxi")
|
||||||
//// val url = "kakaot://taxi?dest_lat=${URLEncoder.encode("37.467696")}&dest_lng=${URLEncoder.encode("127.101063")}&dest_name=세곡동557"
|
|
||||||
// val url = "kakaomap://search?q=세곡동 557"
|
|
||||||
// val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
|
||||||
// startActivity(intent)
|
|
||||||
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696")}&goaly=${URLEncoder.encode("127.101063")}","com.kakao.taxi")
|
|
||||||
}
|
}
|
||||||
binding.searchMusic.setOnClickListener {
|
binding.searchMusic.setOnClickListener {
|
||||||
try {
|
try {
|
||||||
val intent: Intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
|
val intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply {
|
||||||
|
putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
|
||||||
intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
|
putExtra(SearchManager.QUERY, keyword)
|
||||||
intent.putExtra(SearchManager.QUERY, keyword) // 검색하고자 하는 곡명이나 아티스트명
|
setPackage("com.apple.android.music")
|
||||||
|
}
|
||||||
intent.setPackage("com.apple.android.music") // 애플 뮤직 패키지 명시
|
if (intent.resolveActivity(requireContext().packageManager) != null) {
|
||||||
|
|
||||||
if (intent.resolveActivity(requireContext().getPackageManager()) != null) {
|
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 앱이 없을 경우 플레이스토어로 이동
|
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music")).apply {
|
||||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music"))
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
}
|
||||||
requireContext().startActivity(marketIntent)
|
requireContext().startActivity(marketIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.searchTmap.setOnClickListener {
|
binding.searchTmap.setOnClickListener {
|
||||||
try {
|
try {
|
||||||
// 한글 검색어를 위해 URL 인코딩 필수
|
|
||||||
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
|
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
|
||||||
val uri = Uri.parse("tmap://search?name=$encodedKeyword")
|
val uri = Uri.parse("tmap://search?name=$encodedKeyword")
|
||||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
requireContext().startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||||
requireContext().startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 앱이 없을 경우 스토어로 이동
|
|
||||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.skt.tmap.ku"))
|
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.skt.tmap.ku"))
|
||||||
requireContext().startActivity(marketIntent)
|
requireContext().startActivity(marketIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.searchGoogle.setOnClickListener {
|
binding.searchGoogle.setOnClickListener {
|
||||||
val intent = Intent(Intent.ACTION_WEB_SEARCH).apply {
|
val intent = Intent(Intent.ACTION_WEB_SEARCH).apply {
|
||||||
putExtra(SearchManager.QUERY, keyword) // 질문 전달
|
putExtra(SearchManager.QUERY, keyword)
|
||||||
}
|
}
|
||||||
if (intent.resolveActivity(requireContext().packageManager) != null) {
|
if (intent.resolveActivity(requireContext().packageManager) != null) {
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -309,10 +276,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
binding.searchNaver.setOnClickListener {
|
binding.searchNaver.setOnClickListener {
|
||||||
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
|
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun openSearchApps(schemeString: String, packageName: String? = null) {
|
private fun openSearchApps(schemeString: String, packageName: String? = null) {
|
||||||
try {
|
try {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(schemeString))
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(schemeString))
|
||||||
@@ -321,31 +286,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
WorkersDb.logAppUsage(packageName, UsageLogType.APP, UsageUpdateType.DATETIME)
|
WorkersDb.logAppUsage(packageName, UsageLogType.APP, UsageUpdateType.DATETIME)
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
dismiss() // 실행 후 닫기
|
dismiss()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
// 앱이 없을 경우 웹으로 열기 등의 예외처리 권장
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkResult(keyword: String) {
|
private fun checkResult(keyword: String) {
|
||||||
// 1. 현재 필터링된 리스트에서 첫 번째 아이템들을 가져옴
|
|
||||||
val filteredApp = appsAdapter?.oldList?.firstOrNull()
|
val filteredApp = appsAdapter?.oldList?.firstOrNull()
|
||||||
val filteredContact = contactAdapter?.oldList?.firstOrNull()
|
val filteredContact = contactAdapter?.oldList?.firstOrNull()
|
||||||
|
|
||||||
val appCount = appsAdapter?.oldList?.size ?: 0
|
val appCount = appsAdapter?.oldList?.size ?: 0
|
||||||
val contactCount = contactAdapter?.oldList?.size ?: 0
|
val contactCount = contactAdapter?.oldList?.size ?: 0
|
||||||
|
|
||||||
// Case 1: 앱만 하나 검색된 경우
|
|
||||||
if (appCount == 1 && contactCount != 1) {
|
if (appCount == 1 && contactCount != 1) {
|
||||||
launchApp(filteredApp)
|
launchApp(filteredApp)
|
||||||
}
|
} else if (contactCount == 1 && appCount != 1) {
|
||||||
// Case 2: 연락처만 하나 검색된 경우
|
|
||||||
else if (contactCount == 1 && appCount != 1) {
|
|
||||||
launchContact(filteredContact)
|
launchContact(filteredContact)
|
||||||
}
|
} else if (appCount == 1 && contactCount == 1) {
|
||||||
// Case 3: 앱과 연락처가 동시에 하나씩 검색된 경우 (핵심 로직)
|
|
||||||
else if (appCount == 1 && contactCount == 1) {
|
|
||||||
val appUsage = filteredApp?.clickCount ?: 0
|
val appUsage = filteredApp?.clickCount ?: 0
|
||||||
val contactUsage = filteredContact?.touchCount ?: 0
|
val contactUsage = filteredContact?.touchCount ?: 0
|
||||||
if (contactUsage > appUsage) {
|
if (contactUsage > appUsage) {
|
||||||
@@ -353,29 +311,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
} else {
|
} else {
|
||||||
launchApp(filteredApp)
|
launchApp(filteredApp)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
openSearchApps("https://www.google.com/search?q=${keyword}", "com.android.chrome")
|
openSearchApps("https://www.google.com/search?q=${keyword}", "com.android.chrome")
|
||||||
// openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
|
|
||||||
// dismiss()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun containsKorean(text: String): Boolean {
|
private fun containsKorean(text: String): Boolean {
|
||||||
return text.any { it.code in 0xAC00..0xD7AF }
|
return text.any { it.code in 0xAC00..0xD7AF }
|
||||||
}
|
}
|
||||||
private fun askGemini(keyword: String) {
|
|
||||||
|
|
||||||
|
private fun askGemini(keyword: String) {
|
||||||
try {
|
try {
|
||||||
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
||||||
|
|
||||||
// 반대 언어 키워드 생성
|
|
||||||
val converted = if (isKorean) {
|
val converted = if (isKorean) {
|
||||||
SimpleTransliterater.koToEn(keyword) // 한글 -> 영타
|
SimpleTransliterater.koToEn(keyword)
|
||||||
} else {
|
} else {
|
||||||
SimpleTransliterater.enToKo(keyword) // 영어 -> 한타(자모나열)
|
SimpleTransliterater.enToKo(keyword)
|
||||||
}
|
}
|
||||||
Blog.LOGE("keyword >>>> ${keyword} converted >>> ${converted}")
|
Blog.LOGE("keyword >>>> ${keyword} converted >>> ${converted}")
|
||||||
// 제미나이는 'ㅇㅏㄴㄴㅕㅇ' 같은 자모 나열도 맥락상 '안녕'으로 매우 잘 이해합니다.
|
|
||||||
val combinedQuery = if (converted != keyword) {
|
val combinedQuery = if (converted != keyword) {
|
||||||
"$keyword ($converted) 에 대해 알려줘"
|
"$keyword ($converted) 에 대해 알려줘"
|
||||||
} else {
|
} else {
|
||||||
@@ -389,29 +342,22 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 실패 시 일반 구글 검색으로 폴백
|
|
||||||
openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
|
openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 코드 가독성을 위한 헬퍼 함수: 앱 실행
|
|
||||||
private fun launchApp(app: AppInfo?) {
|
private fun launchApp(app: AppInfo?) {
|
||||||
app?.pkgName?.let { pkg ->
|
app?.pkgName?.let { pkg ->
|
||||||
// 사용 기록 로깅
|
|
||||||
WorkersDb.logAppUsage(pkg, UsageLogType.APP, UsageUpdateType.DATETIME)
|
WorkersDb.logAppUsage(pkg, UsageLogType.APP, UsageUpdateType.DATETIME)
|
||||||
// 앱 실행
|
|
||||||
val intent = requireContext().packageManager.getLaunchIntentForPackage(pkg)
|
val intent = requireContext().packageManager.getLaunchIntentForPackage(pkg)
|
||||||
intent?.let { startActivity(it) }
|
intent?.let { startActivity(it) }
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 코드 가독성을 위한 헬퍼 함수: 연락처 다이얼러 실행
|
|
||||||
private fun launchContact(contact: SimpleContact?) {
|
private fun launchContact(contact: SimpleContact?) {
|
||||||
contact?.let { c ->
|
contact?.let { c ->
|
||||||
// 사용 기록 로깅
|
|
||||||
c.id?.let { id -> WorkersDb.logAppUsage(id, UsageLogType.CONTACT, UsageUpdateType.DATETIME) }
|
c.id?.let { id -> WorkersDb.logAppUsage(id, UsageLogType.CONTACT, UsageUpdateType.DATETIME) }
|
||||||
// 전화 걸기 화면 실행
|
|
||||||
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${c.phoneNumber}"))
|
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${c.phoneNumber}"))
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -422,186 +368,137 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
fetchApps(searchString)
|
fetchApps(searchString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱 목록을 불러오는 함수
|
* 앱 및 연락처 목록을 비동기로 불러오는 함수
|
||||||
* - 검색어(keyword)가 있으면 필터링을 수행합니다.
|
* - 이전 작업 취소(fetchJob?.cancel)로 연속 입력 시 병목 방지
|
||||||
* - 검색어가 없으면 '문맥 기반 추천 앱'을 최상단에 배치합니다.
|
* - queryIntentActivities 단 1회 호출로 런처 실행 가능 패키지 Set 캐싱
|
||||||
* - IO 스레드에서 실행하여 UI 끊김을 방지합니다.
|
* - 추천 목록 준비 즉시 UI 선반영
|
||||||
*/
|
*/
|
||||||
private fun fetchApps(keyword: String? = null) {
|
private fun fetchApps(keyword: String? = null) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
fetchJob?.cancel()
|
||||||
val realm = WorkersDb.getRealm()
|
fetchJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val pm = requireContext().packageManager
|
try {
|
||||||
|
val context = context ?: return@launch
|
||||||
|
val realm = WorkersDb.getRealm()
|
||||||
|
val pm = context.packageManager
|
||||||
|
|
||||||
// 1. [추천 로직] 에러가 나도 앱 목록 로딩은 진행되도록 try-catch 분리
|
// 1. 런처에서 실행 가능한 전체 패키지 Set 확보 (단 1회 IPC)
|
||||||
val scoredItems = WorkersDb.getContextualRecommendations(limit = 18)
|
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
|
||||||
|
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
}
|
||||||
|
val launchablePackages = pm.queryIntentActivities(mainIntent, 0)
|
||||||
|
.mapNotNull { it.activityInfo?.packageName }
|
||||||
|
.toHashSet()
|
||||||
|
|
||||||
val unifiedList = mutableListOf<RecommendationItem>()
|
// 2. 추천 목록 먼저 계산 후 UI 선반영
|
||||||
|
val scoredItems = WorkersDb.getContextualRecommendations(limit = 18)
|
||||||
|
val unifiedList = mutableListOf<RecommendationItem>()
|
||||||
|
|
||||||
for (item in scoredItems) {
|
for (item in scoredItems) {
|
||||||
if (item.type == "APP") {
|
if (item.type == "APP") {
|
||||||
val app = realm.query<AppInfo>("pkgName == $0", item.key).first().find()
|
val app = realm.query<AppInfo>("pkgName == $0", item.key).first().find()
|
||||||
|
if (app != null && (app.blockRecommend == false || (binding.hidden.isSelected == app.blockRecommend))) {
|
||||||
if (app != null && (app.blockRecommend == false || (binding.hidden.isSelected == app.blockRecommend))) {
|
if (launchablePackages.contains(app.pkgName)) {
|
||||||
try { if(pm.getLaunchIntentForPackage(app.pkgName ?: "") != null) {
|
unifiedList.add(RecommendationItem.AppItem(realm.copyFromRealm(app)))
|
||||||
unifiedList.add(RecommendationItem.AppItem(realm.copyFromRealm(app)))
|
}
|
||||||
}} catch (e: Exception) { }
|
}
|
||||||
}
|
} else if (item.type == "CONTACT") {
|
||||||
} else if (item.type == "CONTACT") {
|
val contact = realm.query<SimpleContact>("id == $0", item.key).first().find()
|
||||||
// 연락처 ID나 전화번호로 조회 (Log 저장 시 key가 무엇인지에 따라 다름)
|
if (contact != null) {
|
||||||
val contact = realm.query<SimpleContact>("id == $0", item.key).first().find()
|
unifiedList.add(RecommendationItem.ContactItem(realm.copyFromRealm(contact)))
|
||||||
if (contact != null) {
|
}
|
||||||
unifiedList.add(RecommendationItem.ContactItem(realm.copyFromRealm(contact)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
withContext(Dispatchers.Main) {
|
||||||
// =========================================================
|
if (_binding != null && unifiedList.isNotEmpty()) {
|
||||||
// 2. [앱 검색 로직 변경] DB 쿼리 -> 메모리 필터링
|
binding.recAppsList.visibility = View.VISIBLE
|
||||||
// =========================================================
|
recAdapter?.submitList(unifiedList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2-1. 일단 모든 앱을 가져옵니다. (기본 정렬: 클릭수, 최근사용순)
|
// 3. 전체 앱 목록 쿼리 및 메모리 필터링
|
||||||
// 쿼리 조건 없이 모두 가져옴
|
|
||||||
val baseAppQuery = realm.query<AppInfo>()
|
val baseAppQuery = realm.query<AppInfo>()
|
||||||
.sort("clickCount", Sort.DESCENDING)
|
.sort("clickCount", Sort.DESCENDING)
|
||||||
.sort("lastUseDate", Sort.DESCENDING)
|
.sort("lastUseDate", Sort.DESCENDING)
|
||||||
.find()
|
.find()
|
||||||
|
|
||||||
val scopedAppsList = baseAppQuery.map {
|
val scopedAppsList = baseAppQuery.mapNotNull { app ->
|
||||||
val appCopy = realm.copyFromRealm(it)
|
val pkg = app.pkgName ?: ""
|
||||||
|
if (!launchablePackages.contains(pkg)) return@mapNotNull null
|
||||||
|
|
||||||
// 💡 수동 매퍼를 사용하여 카테고리 강제 재할당
|
val isVisible = binding.hidden.isSelected || (app.visibilityMode == 0)
|
||||||
// it.systemCategoryInt는 AppInfo 모델에 시스템 카테고리 값이 저장되어 있다고 가정함
|
if (!isVisible) return@mapNotNull null
|
||||||
val fixedCategory = CategoryManualMapper.getFixedCategory(it.pkgName ?:"", it.appName, it.systemCategoryInt)
|
|
||||||
appCopy.category = fixedCategory
|
val fixedCategory = CategoryManualMapper.getFixedCategory(pkg, app.appName, app.systemCategoryInt)
|
||||||
appCopy
|
if (currentCategoryKey != "ALL" && fixedCategory != currentCategoryKey) return@mapNotNull null
|
||||||
|
|
||||||
|
realm.copyFromRealm(app).apply { category = fixedCategory }
|
||||||
}
|
}
|
||||||
.filter { appInfo ->
|
|
||||||
val isLaunchable = try {
|
|
||||||
pm.getLaunchIntentForPackage(appInfo.pkgName ?: "") != null
|
|
||||||
} catch (e: Exception) { false }
|
|
||||||
|
|
||||||
val isVisible = binding.hidden.isSelected || (appInfo.visibilityMode == 0)
|
|
||||||
|
|
||||||
// [핵심 변경] 단순 문자열 비교 필터링
|
|
||||||
val isCategoryMatch = if (currentCategoryKey == "ALL") {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
appInfo.category == currentCategoryKey
|
|
||||||
}
|
|
||||||
|
|
||||||
isLaunchable && isVisible && isCategoryMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2-2. Realm 객체를 메모리 객체로 변환 (속도 향상 및 스레드 안전)
|
|
||||||
// 여기서 visible 필터링 등을 미리 해도 좋습니다.
|
|
||||||
|
|
||||||
|
|
||||||
// 2-3. 키워드에 따라 필터링 및 정렬 (유사도 적용)
|
|
||||||
val filteredApps = if (keyword.isNullOrEmpty()) {
|
val filteredApps = if (keyword.isNullOrEmpty()) {
|
||||||
// 키워드가 없으면 기본 정렬 그대로 사용
|
|
||||||
scopedAppsList
|
scopedAppsList
|
||||||
} else {
|
} else {
|
||||||
// 검색어 전처리 (한/영 변환 등)
|
|
||||||
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
|
||||||
val converted = if (isKorean) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
|
val converted = if (isKorean) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
|
||||||
// 검색어 후보군 (예: "카카오", "zkzk")
|
|
||||||
val searchTerms = listOfNotNull(keyword, converted).distinct()
|
val searchTerms = listOfNotNull(keyword, converted).distinct()
|
||||||
|
|
||||||
// [핵심] 각 앱에 대해 점수 계산
|
|
||||||
scopedAppsList.mapNotNull { app ->
|
scopedAppsList.mapNotNull { app ->
|
||||||
// 1. 검색 대상 필드들을 리스트로 묶습니다. (null인 필드는 자동으로 제외됨)
|
val targetFields = listOfNotNull(app.appName, app.pkgName, app.category)
|
||||||
val targetFields = listOfNotNull(
|
|
||||||
app.appName, // 앱 이름
|
|
||||||
app.pkgName, // 패키지명 (예: com.kakao.talk)
|
|
||||||
app.category // 카테고리 (예: Social, Game)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. 여러 검색어(term) 중 가장 높은 점수를 계산
|
|
||||||
val maxScore = searchTerms.maxOf { term ->
|
val maxScore = searchTerms.maxOf { term ->
|
||||||
|
|
||||||
// [1단계] 서치 인덱스 확인 (가장 빠름)
|
|
||||||
// 만약 makeSearchIndex에 패키지명 등을 이미 포함시켰다면 여기서 바로 잡힙니다.
|
|
||||||
if (app.searchIndex.contains(term, true)) return@maxOf 1.0
|
if (app.searchIndex.contains(term, true)) return@maxOf 1.0
|
||||||
|
targetFields.maxOf { field ->
|
||||||
// [2단계] 각 필드(이름, 패키지, 카테고리)에 대해 점수 계산 후 가장 높은 것 선택
|
if (SimpleTransliterater.match(field, term)) 1.0
|
||||||
// targetFields 리스트를 순회하며 가장 높은 매칭 점수를 찾습니다.
|
else SimpleTransliterater.getSimilarity(field, term)
|
||||||
val fieldMaxScore = targetFields.maxOf { field ->
|
|
||||||
// A. 단순 포함 or 초성 매칭 (100점)
|
|
||||||
if (SimpleTransliterater.match(field, term)) {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
// B. 유사도 계산 (0.0 ~ 1.0점)
|
|
||||||
SimpleTransliterater.getSimilarity(field, term)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldMaxScore // 해당 검색어(term)에 대한 최종 점수 반환
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 유사도 임계값 체크 (0.3점 이상만 통과)
|
|
||||||
if (maxScore > 0.3) {
|
|
||||||
Pair(app, maxScore)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
if (maxScore > 0.3) Pair(app, maxScore) else null
|
||||||
}
|
}
|
||||||
.sortedByDescending { it.second } // 점수 높은 순 정렬
|
.sortedByDescending { it.second }
|
||||||
.map { it.first } // 결과 반환// 앱 정보만 추출
|
.map { it.first }
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================
|
// 4. 연락처 목록 필터링 (검색어 없을 때는 상위 10건만 DB 조회)
|
||||||
// 3. [연락처 검색 로직 변경] 동일한 방식 적용
|
val filteredContacts: List<SimpleContact> = if (keyword.isNullOrEmpty()) {
|
||||||
// =========================================================
|
realm.query<SimpleContact>()
|
||||||
|
.sort("touchCount", Sort.DESCENDING)
|
||||||
|
.limit(10)
|
||||||
|
.find()
|
||||||
|
.map { realm.copyFromRealm(it) }
|
||||||
|
} else {
|
||||||
|
val allContacts = realm.query<SimpleContact>()
|
||||||
|
.sort("touchCount", Sort.DESCENDING)
|
||||||
|
.find()
|
||||||
|
.map { realm.copyFromRealm(it) }
|
||||||
|
|
||||||
var filteredContacts: List<SimpleContact> = emptyList()
|
val searchTerms = listOfNotNull(
|
||||||
val allContacts = realm.query<SimpleContact>()
|
keyword,
|
||||||
.sort("touchCount", Sort.DESCENDING)
|
if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
|
||||||
.find()
|
).distinct()
|
||||||
.map { realm.copyFromRealm(it) } // 메모리로 복사
|
|
||||||
if (!keyword.isNullOrEmpty()) {
|
|
||||||
// 연락처도 전체 로드 (보통 수천 개 단위까지는 문제 없음)
|
|
||||||
val searchTerms = listOfNotNull(keyword, if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)).distinct()
|
|
||||||
|
|
||||||
filteredContacts = allContacts.mapNotNull { contact ->
|
allContacts.mapNotNull { contact ->
|
||||||
val maxScore = searchTerms.maxOf { term ->
|
val maxScore = searchTerms.maxOf { term ->
|
||||||
// 연락처 이름이나 번호 매칭 확인
|
|
||||||
if (SimpleTransliterater.match(contact.searchIndex ?: "", term)) return@maxOf 1.0
|
if (SimpleTransliterater.match(contact.searchIndex ?: "", term)) return@maxOf 1.0
|
||||||
if (SimpleTransliterater.match(contact.name ?: "", term)) return@maxOf 1.0
|
if (SimpleTransliterater.match(contact.name ?: "", term)) return@maxOf 1.0
|
||||||
if (contact.phoneNumber?.contains(term) == true) return@maxOf 1.0
|
if (contact.phoneNumber?.contains(term) == true) return@maxOf 1.0
|
||||||
|
|
||||||
// 유사도 확인
|
|
||||||
SimpleTransliterater.getSimilarity(contact.name ?: "", term)
|
SimpleTransliterater.getSimilarity(contact.name ?: "", term)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxScore > 0.4) Pair(contact, maxScore) else null
|
if (maxScore > 0.4) Pair(contact, maxScore) else null
|
||||||
}
|
}
|
||||||
.sortedByDescending { it.second }
|
.sortedByDescending { it.second }
|
||||||
.take(10) // 상위 10개만
|
.take(10)
|
||||||
.map { it.first }
|
.map { it.first }
|
||||||
} else {
|
|
||||||
filteredContacts = allContacts.take(10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 앱 및 연락처 UI 갱신
|
||||||
// 4. [UI 업데이트] (기존 로직 유지)
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (unifiedList.isNotEmpty()) {
|
if (_binding == null) return@withContext
|
||||||
binding.recAppsList.visibility = View.VISIBLE
|
|
||||||
recAdapter?.submitList(unifiedList)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 앱 리스트 갱신
|
|
||||||
packageList.clear()
|
packageList.clear()
|
||||||
packageList.addAll(filteredApps)
|
packageList.addAll(filteredApps)
|
||||||
appsAdapter?.updateData(packageList)
|
appsAdapter?.updateData(packageList)
|
||||||
binding.appsCount.text = "${packageList.size} Apps"
|
binding.appsCount.text = "${packageList.size} Apps"
|
||||||
|
|
||||||
// 연락처 리스트 갱신
|
|
||||||
if (filteredContacts.isNotEmpty()) {
|
if (filteredContacts.isNotEmpty()) {
|
||||||
binding.contactList.visibility = View.VISIBLE
|
binding.contactList.visibility = View.VISIBLE
|
||||||
contactAdapter?.updateData(filteredContacts)
|
contactAdapter?.updateData(filteredContacts)
|
||||||
@@ -610,13 +507,11 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
contactAdapter?.updateData(emptyList())
|
contactAdapter?.updateData(emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// 새 검색어 입력 등으로 인한 정상 취소
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +525,7 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
fetchJob?.cancel()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ package bums.lunatic.launcher.home
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
|
import android.text.TextUtils
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -19,6 +24,8 @@ import android.widget.AdapterView
|
|||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -34,22 +41,27 @@ import bums.lunatic.launcher.player.DocumentViewerActivity
|
|||||||
import bums.lunatic.launcher.player.ImageViewerActivity
|
import bums.lunatic.launcher.player.ImageViewerActivity
|
||||||
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
|
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.mlkit.nl.languageid.LanguageIdentification
|
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||||
import com.google.mlkit.nl.translate.Translation
|
import com.google.mlkit.nl.translate.Translation
|
||||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.util.ArrayDeque
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
enum class FileFilterType(val label: String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
|
enum class FileFilterType(val label: String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
|
||||||
enum class FileSortType(val label: String) { LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), NAME("파일명"), DOWNLOAD_DATE("다운로드"), SIZE("용량") }
|
enum class FileSortType(val label: String) { LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), NAME("파일명"), DOWNLOAD_DATE("다운로드"), SIZE("용량") }
|
||||||
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
||||||
|
|
||||||
// 💡 자막 번역 데이터를 담을 데이터 클래스 추가
|
|
||||||
data class SubtitleBlock(
|
data class SubtitleBlock(
|
||||||
val startSec: Double,
|
val startSec: Double,
|
||||||
val endSec: Double,
|
val endSec: Double,
|
||||||
@@ -57,6 +69,12 @@ data class SubtitleBlock(
|
|||||||
var translatedText: String? = null
|
var translatedText: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class VideoFrameItem(
|
||||||
|
val timestampMs: Long,
|
||||||
|
val timeLabel: String,
|
||||||
|
val bitmap: Bitmap
|
||||||
|
)
|
||||||
|
|
||||||
class NaturalOrderComparator : Comparator<File> {
|
class NaturalOrderComparator : Comparator<File> {
|
||||||
override fun compare(f1: File, f2: File): Int {
|
override fun compare(f1: File, f2: File): Int {
|
||||||
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
|
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
|
||||||
@@ -97,6 +115,7 @@ enum class RenameMode(val label: String) {
|
|||||||
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
||||||
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
|
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
|
||||||
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp", "srt", "smi", "ass", "vtt")
|
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp", "srt", "smi", "ass", "vtt")
|
||||||
|
private const val VLC_PACKAGE_NAME = "org.videolan.vlc"
|
||||||
|
|
||||||
class CompletedFilesActivity : AppCompatActivity() {
|
class CompletedFilesActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -115,7 +134,6 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
private val selectedFiles = mutableSetOf<File>()
|
private val selectedFiles = mutableSetOf<File>()
|
||||||
private lateinit var backPressedCallback: OnBackPressedCallback
|
private lateinit var backPressedCallback: OnBackPressedCallback
|
||||||
|
|
||||||
// 외부 재생기 복귀 시 리스트 갱신을 위한 Launcher
|
|
||||||
private val videoPlayerLauncher = registerForActivityResult(
|
private val videoPlayerLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) {
|
) {
|
||||||
@@ -126,6 +144,13 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
private lateinit var geckoWeb: GeckoWeb
|
private lateinit var geckoWeb: GeckoWeb
|
||||||
private var targetVideoForSubtitle: File? = null
|
private var targetVideoForSubtitle: File? = null
|
||||||
|
|
||||||
|
private val subtitleSearchQueue = ArrayDeque<File>()
|
||||||
|
private var totalQueueCount = 0
|
||||||
|
private var completedQueueCount = 0
|
||||||
|
private var isBatchSubtitleMode = false
|
||||||
|
|
||||||
|
private lateinit var videoPreviewHelper: VideoPreviewHelper
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.fragment_completed_files)
|
setContentView(R.layout.fragment_completed_files)
|
||||||
@@ -137,6 +162,11 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
geckoWeb = findViewById(R.id.geckoWebFragment)
|
geckoWeb = findViewById(R.id.geckoWebFragment)
|
||||||
geckoWeb.visibility = View.GONE
|
geckoWeb.visibility = View.GONE
|
||||||
|
|
||||||
|
videoPreviewHelper = VideoPreviewHelper(this) { videoFile, timestampMs ->
|
||||||
|
val subPath = findSubtitleFile(videoFile.absolutePath)
|
||||||
|
launchVlcPlayer(videoFile, subPath, timestampMs)
|
||||||
|
}
|
||||||
|
|
||||||
backPressedCallback = object : OnBackPressedCallback(false) {
|
backPressedCallback = object : OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
|
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
|
||||||
@@ -152,11 +182,30 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
private fun toggleSelectionMode(enable: Boolean) {
|
private fun toggleSelectionMode(enable: Boolean) {
|
||||||
isSelectionMode = enable
|
isSelectionMode = enable
|
||||||
if (!enable) selectedFiles.clear()
|
if (!enable) selectedFiles.clear()
|
||||||
adapter.updateSelection(selectedFiles)
|
updateSelectionUI()
|
||||||
findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
|
findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
|
||||||
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateSelectionUI() {
|
||||||
|
adapter.updateSelection(selectedFiles)
|
||||||
|
|
||||||
|
// 1. 미리보기 버튼: 전체 선택이 딱 1개이고, 그 파일이 영상일 때만 노출
|
||||||
|
val singleFile = selectedFiles.singleOrNull()
|
||||||
|
val isSingleVideo = singleFile != null && singleFile.isFile && extVideos.contains(singleFile.extension.lowercase())
|
||||||
|
findViewById<View>(R.id.btnVideoPreview)?.visibility = if (isSingleVideo) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
// 2. 자막 검색 버튼: 영상 파일이 1개 이상 선택되었을 때 노출
|
||||||
|
val videoCount = selectedFiles.count { it.isFile && extVideos.contains(it.extension.lowercase()) }
|
||||||
|
val btnSub = findViewById<TextView>(R.id.btnSearchSubtitle)
|
||||||
|
if (videoCount > 0) {
|
||||||
|
btnSub?.visibility = View.VISIBLE
|
||||||
|
btnSub?.text = if (videoCount == 1) "자막 검색" else "자막($videoCount)"
|
||||||
|
} else {
|
||||||
|
btnSub?.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupRecyclerView() {
|
private fun setupRecyclerView() {
|
||||||
recyclerView = findViewById(R.id.recyclerViewCompletedFiles)
|
recyclerView = findViewById(R.id.recyclerViewCompletedFiles)
|
||||||
updateRecyclerViewLayoutManager()
|
updateRecyclerViewLayoutManager()
|
||||||
@@ -171,7 +220,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
|
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
|
||||||
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
|
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
|
||||||
else adapter.updateSelection(selectedFiles)
|
else updateSelectionUI()
|
||||||
} else {
|
} else {
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
currentDir = file
|
currentDir = file
|
||||||
@@ -181,7 +230,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (extVideos.contains(file.extension.lowercase())) {
|
if (extVideos.contains(file.extension.lowercase())) {
|
||||||
val subtitlePath = findSubtitleFile(file.absolutePath)
|
val subtitlePath = findSubtitleFile(file.absolutePath)
|
||||||
launchExternalPlayer(file, subtitlePath)
|
launchVlcPlayer(file, subtitlePath, 0L)
|
||||||
} else if (extImages.contains(file.extension.lowercase())) {
|
} else if (extImages.contains(file.extension.lowercase())) {
|
||||||
trackFileAccess(file.name)
|
trackFileAccess(file.name)
|
||||||
loadFiles()
|
loadFiles()
|
||||||
@@ -210,7 +259,9 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
if (file.isDirectory && !isSelectionMode && !isProtectedFile(file)) {
|
if (file.isDirectory && !isSelectionMode && !isProtectedFile(file)) {
|
||||||
showFolderDeleteDialog(file)
|
showFolderDeleteDialog(file)
|
||||||
} else if (!isSelectionMode) {
|
} else if (!isSelectionMode) {
|
||||||
showFileContextMenu(file)
|
toggleSelectionMode(true)
|
||||||
|
selectedFiles.add(file)
|
||||||
|
updateSelectionUI()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getAccessCount = { fileName -> getAccessCount(fileName) }
|
getAccessCount = { fileName -> getAccessCount(fileName) }
|
||||||
@@ -231,44 +282,71 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFileContextMenu(file: File) {
|
// --- 자막 검색 & 일괄 처리 큐 파이프라인 ---
|
||||||
val isVideo = extVideos.contains(file.extension.lowercase())
|
|
||||||
val options = mutableListOf("선택 모드 시작")
|
|
||||||
|
|
||||||
if (isVideo) options.add("자막 검색 및 다운로드")
|
private fun startSubtitleSearchQueue(videoFiles: List<File>) {
|
||||||
|
if (videoFiles.isEmpty()) return
|
||||||
|
|
||||||
android.app.AlertDialog.Builder(this)
|
subtitleSearchQueue.clear()
|
||||||
.setTitle(file.name)
|
val sortedVideos = videoFiles.sortedWith(NaturalOrderComparator())
|
||||||
.setItems(options.toTypedArray()) { _, which ->
|
subtitleSearchQueue.addAll(sortedVideos)
|
||||||
when (options[which]) {
|
|
||||||
"선택 모드 시작" -> {
|
totalQueueCount = sortedVideos.size
|
||||||
toggleSelectionMode(true)
|
completedQueueCount = 0
|
||||||
selectedFiles.add(file)
|
isBatchSubtitleMode = totalQueueCount > 1
|
||||||
adapter.updateSelection(selectedFiles)
|
|
||||||
}
|
processNextSubtitleInQueue()
|
||||||
"자막 검색 및 다운로드" -> {
|
|
||||||
startSubtitleSearch(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startSubtitleSearch(videoFile: File) {
|
private fun processNextSubtitleInQueue() {
|
||||||
targetVideoForSubtitle = videoFile
|
val nextVideo = subtitleSearchQueue.pollFirst()
|
||||||
val query = videoFile.nameWithoutExtension
|
if (nextVideo == null) {
|
||||||
|
targetVideoForSubtitle = null
|
||||||
|
if (isBatchSubtitleMode) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
"일괄 자막 작업 완료 (${completedQueueCount}/${totalQueueCount}개 처리됨)",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
isBatchSubtitleMode = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetVideoForSubtitle = nextVideo
|
||||||
|
val currentIndex = totalQueueCount - subtitleSearchQueue.size
|
||||||
|
val query = nextVideo.nameWithoutExtension
|
||||||
val searchUrl = "https://www.subtitlecat.com/index.php?search=${URLEncoder.encode(query, "UTF-8")}"
|
val searchUrl = "https://www.subtitlecat.com/index.php?search=${URLEncoder.encode(query, "UTF-8")}"
|
||||||
|
|
||||||
geckoWeb.loadUrl(searchUrl)
|
geckoWeb.loadUrl(searchUrl)
|
||||||
Toast.makeText(this, "자막을 검색 중입니다...", Toast.LENGTH_SHORT).show()
|
val progressPrefix = if (isBatchSubtitleMode) "[$currentIndex/$totalQueueCount] " else ""
|
||||||
|
Toast.makeText(this, "${progressPrefix}'${nextVideo.name}' 자막 검색 중...", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelSubtitleQueue() {
|
||||||
|
subtitleSearchQueue.clear()
|
||||||
|
targetVideoForSubtitle = null
|
||||||
|
isBatchSubtitleMode = false
|
||||||
|
Toast.makeText(this, "자막 검색 작업이 중단되었습니다.", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showSubtitleSelectionDialog(subList: List<Map<String, String>>) {
|
fun showSubtitleSelectionDialog(subList: List<Map<String, String>>) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
if (isDestroyed || isFinishing) return@runOnUiThread
|
if (isDestroyed || isFinishing) return@runOnUiThread
|
||||||
|
|
||||||
|
val currentVideo = targetVideoForSubtitle
|
||||||
|
val currentIndex = totalQueueCount - subtitleSearchQueue.size
|
||||||
|
val dialogTitle = if (isBatchSubtitleMode && currentVideo != null) {
|
||||||
|
"[$currentIndex/$totalQueueCount] ${currentVideo.name}"
|
||||||
|
} else {
|
||||||
|
currentVideo?.name ?: "자막 선택"
|
||||||
|
}
|
||||||
|
|
||||||
if (subList.isEmpty()) {
|
if (subList.isEmpty()) {
|
||||||
Toast.makeText(this, "검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "'${currentVideo?.name}' 검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
if (isBatchSubtitleMode) {
|
||||||
|
processNextSubtitleInQueue()
|
||||||
|
}
|
||||||
return@runOnUiThread
|
return@runOnUiThread
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +354,8 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
" [${item["lang"]}] ${item["title"]} (${item["size"]})"
|
" [${item["lang"]}] ${item["title"]} (${item["size"]})"
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
android.app.AlertDialog.Builder(this)
|
val builder = android.app.AlertDialog.Builder(this)
|
||||||
.setTitle("자막 선택")
|
.setTitle(dialogTitle)
|
||||||
.setItems(items) { _, which ->
|
.setItems(items) { _, which ->
|
||||||
val selected = subList[which]
|
val selected = subList[which]
|
||||||
val detailUrl = selected["downloadUrl"]
|
val detailUrl = selected["downloadUrl"]
|
||||||
@@ -285,10 +363,26 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
if (detailUrl != null) {
|
if (detailUrl != null) {
|
||||||
Toast.makeText(this, "자막을 다운로드합니다...", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "자막을 다운로드합니다...", Toast.LENGTH_SHORT).show()
|
||||||
geckoWeb.sendJsonMsg("GO_TO_SUBTITLE_DETAIL", "url" to detailUrl)
|
geckoWeb.sendJsonMsg("GO_TO_SUBTITLE_DETAIL", "url" to detailUrl)
|
||||||
|
} else if (isBatchSubtitleMode) {
|
||||||
|
processNextSubtitleInQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton("취소", null)
|
|
||||||
.show()
|
if (isBatchSubtitleMode) {
|
||||||
|
builder.setNeutralButton("건너뛰기") { _, _ ->
|
||||||
|
processNextSubtitleInQueue()
|
||||||
|
}
|
||||||
|
builder.setNegativeButton("전체 중단") { _, _ ->
|
||||||
|
cancelSubtitleQueue()
|
||||||
|
}
|
||||||
|
builder.setCancelable(false)
|
||||||
|
} else {
|
||||||
|
builder.setNegativeButton("취소") { _, _ ->
|
||||||
|
targetVideoForSubtitle = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,21 +404,34 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(this@CompletedFilesActivity, "자막 파일 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this@CompletedFilesActivity, "자막 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
|
||||||
loadFiles()
|
loadFiles()
|
||||||
// 💡 즉시 재생하지 않고, 번역 프로세스 먼저 실행
|
|
||||||
processAndTranslateSubtitle(newSubFile, videoFile)
|
processAndTranslateSubtitle(newSubFile, videoFile)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패 (HTTP ${response.code})", Toast.LENGTH_SHORT).show()
|
||||||
|
if (isBatchSubtitleMode) processNextSubtitleInQueue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
if (isBatchSubtitleMode) processNextSubtitleInQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 💡 [번역 파이프라인 시작 (PlayerActivity에서 이식됨)] ---
|
private fun onSingleSubtitlePipelineFinished(videoFile: File, finalSubPath: String) {
|
||||||
|
completedQueueCount++
|
||||||
|
if (isBatchSubtitleMode) {
|
||||||
|
processNextSubtitleInQueue()
|
||||||
|
} else {
|
||||||
|
targetVideoForSubtitle = null
|
||||||
|
launchVlcPlayer(videoFile, finalSubPath, 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseSrtTime(timeStr: String): Double {
|
private fun parseSrtTime(timeStr: String): Double {
|
||||||
val cleanStr = timeStr.trim().replace(",", ".")
|
val cleanStr = timeStr.trim().replace(",", ".")
|
||||||
@@ -351,7 +458,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
if (!file.exists()) return result
|
if (!file.exists()) return result
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val content = readTextWithEncoding(file) // FileUtils 유틸 사용
|
val content = readTextWithEncoding(file)
|
||||||
val blocks = content.split("\n\n", "\r\n\r\n")
|
val blocks = content.split("\n\n", "\r\n\r\n")
|
||||||
|
|
||||||
for (block in blocks) {
|
for (block in blocks) {
|
||||||
@@ -378,7 +485,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
sb.append("${index + 1}\n")
|
sb.append("${index + 1}\n")
|
||||||
sb.append("${formatSrtTime(block.startSec)} --> ${formatSrtTime(block.endSec)}\n")
|
sb.append("${formatSrtTime(block.startSec)} --> ${formatSrtTime(block.endSec)}\n")
|
||||||
val textToSave = if (!block.translatedText.isNullOrEmpty()) {
|
val textToSave = if (!block.translatedText.isNullOrEmpty()) {
|
||||||
"${block.translatedText}\n${block.text}" // 번역본 + 원본 병기
|
"${block.translatedText}\n${block.text}"
|
||||||
} else {
|
} else {
|
||||||
block.text
|
block.text
|
||||||
}
|
}
|
||||||
@@ -390,11 +497,10 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
private fun processAndTranslateSubtitle(subFile: File, videoFile: File) {
|
private fun processAndTranslateSubtitle(subFile: File, videoFile: File) {
|
||||||
val subtitles = parseSrt(subFile)
|
val subtitles = parseSrt(subFile)
|
||||||
if (subtitles.isEmpty()) {
|
if (subtitles.isEmpty()) {
|
||||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 언어 감지를 위해 자막 앞부분 샘플링
|
|
||||||
val sampleText = subtitles.take(10).joinToString(" ") { it.text }
|
val sampleText = subtitles.take(10).joinToString(" ") { it.text }
|
||||||
val languageIdentifier = LanguageIdentification.getClient()
|
val languageIdentifier = LanguageIdentification.getClient()
|
||||||
|
|
||||||
@@ -402,20 +508,20 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
.addOnSuccessListener { languageCode ->
|
.addOnSuccessListener { languageCode ->
|
||||||
if (languageCode == "und" || languageCode == "ko") {
|
if (languageCode == "und" || languageCode == "ko") {
|
||||||
Log.d("CompletedFilesActivity", "번역 불필요 (언어: $languageCode)")
|
Log.d("CompletedFilesActivity", "번역 불필요 (언어: $languageCode)")
|
||||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||||
} else {
|
} else {
|
||||||
Log.d("CompletedFilesActivity", "감지된 언어: $languageCode - 번역 시작")
|
Log.d("CompletedFilesActivity", "감지된 언어: $languageCode - 번역 시작")
|
||||||
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
||||||
if (sourceLang != null) {
|
if (sourceLang != null) {
|
||||||
startTranslationProcess(sourceLang, subtitles, subFile, videoFile)
|
startTranslationProcess(sourceLang, subtitles, subFile, videoFile)
|
||||||
} else {
|
} else {
|
||||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
Log.e("CompletedFilesActivity", "언어 감지 실패", it)
|
Log.e("CompletedFilesActivity", "언어 감지 실패", it)
|
||||||
launchExternalPlayer(videoFile, subFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +532,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
val translator = Translation.getClient(options)
|
val translator = Translation.getClient(options)
|
||||||
Toast.makeText(this, "자막을 한국어로 번역 중입니다...", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "'${videoFile.name}' 자막 번역 중...", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
translator.downloadModelIfNeeded()
|
translator.downloadModelIfNeeded()
|
||||||
.addOnSuccessListener {
|
.addOnSuccessListener {
|
||||||
@@ -447,57 +553,58 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
newFile.writeText(translatedSrtContent, Charsets.UTF_8)
|
newFile.writeText(translatedSrtContent, Charsets.UTF_8)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
Toast.makeText(this@CompletedFilesActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this@CompletedFilesActivity, "'${videoFile.name}' 번역 완료!", Toast.LENGTH_SHORT).show()
|
||||||
loadFiles()
|
loadFiles()
|
||||||
launchExternalPlayer(videoFile, newFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, newFile.absolutePath)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("CompletedFilesActivity", "번역 자막 저장 실패", e)
|
Log.e("CompletedFilesActivity", "번역 자막 저장 실패", e)
|
||||||
withContext(Dispatchers.Main) { launchExternalPlayer(videoFile, originalSubFile.absolutePath) }
|
withContext(Dispatchers.Main) {
|
||||||
|
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.addOnFailureListener {
|
.addOnFailureListener {
|
||||||
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막으로 재생합니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막만 저장합니다.", Toast.LENGTH_SHORT).show()
|
||||||
launchExternalPlayer(videoFile, originalSubFile.absolutePath)
|
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 💡 [번역 파이프라인 끝] ---
|
private fun launchVlcPlayer(videoFile: File, subtitlePath: String, startPositionMs: Long = 0L) {
|
||||||
|
|
||||||
private fun launchExternalPlayer(videoFile: File, subtitlePath: String) {
|
|
||||||
trackFileAccess(videoFile.name)
|
trackFileAccess(videoFile.name)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val videoUri = FileProvider.getUriForFile(this, "bums.lunatic.launcher.fileprovider", videoFile)
|
val videoUri = FileProvider.getUriForFile(this, "bums.lunatic.launcher.fileprovider", videoFile)
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
setDataAndType(videoUri, "video/*")
|
setPackage(VLC_PACKAGE_NAME)
|
||||||
|
setDataAndTypeAndNormalize(videoUri, "video/*")
|
||||||
|
putExtra("title", videoFile.nameWithoutExtension)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||||
|
|
||||||
|
if (startPositionMs > 0L) {
|
||||||
|
putExtra("position", startPositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
if (subtitlePath.isNotEmpty()) {
|
if (subtitlePath.isNotEmpty()) {
|
||||||
val subFile = File(subtitlePath)
|
val subFile = File(subtitlePath)
|
||||||
if (subFile.exists()) {
|
if (subFile.exists()) {
|
||||||
val subUri = FileProvider.getUriForFile(this@CompletedFilesActivity, "bums.lunatic.launcher.fileprovider", subFile)
|
val subUri = FileProvider.getUriForFile(this@CompletedFilesActivity, "bums.lunatic.launcher.fileprovider", subFile)
|
||||||
val parcelableArray = arrayOf<android.os.Parcelable>(subUri)
|
grantUriPermission(VLC_PACKAGE_NAME, subUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
putExtra("subs", parcelableArray)
|
|
||||||
putExtra("subs.enable", parcelableArray)
|
|
||||||
putExtra("subtitles_location", subUri)
|
putExtra("subtitles_location", subUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
videoPlayerLauncher.launch(Intent.createChooser(intent, "동영상 재생 앱 선택"))
|
videoPlayerLauncher.launch(intent)
|
||||||
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
Toast.makeText(this, "동영상을 재생할 외부 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "VLC 플레이어($VLC_PACKAGE_NAME)가 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (이하 showFolderDeleteDialog, showUnorganizedFilesDialog 등 기존 유틸리티 함수 유지 동일) ...
|
|
||||||
|
|
||||||
private fun showFolderDeleteDialog(folder: File) {
|
private fun showFolderDeleteDialog(folder: File) {
|
||||||
val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
|
val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
|
||||||
|
|
||||||
@@ -771,11 +878,30 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
findViewById<View>(R.id.btnSelectAll)?.setOnClickListener {
|
findViewById<View>(R.id.btnSelectAll)?.setOnClickListener {
|
||||||
selectedFiles.clear()
|
selectedFiles.clear()
|
||||||
selectedFiles.addAll(adapter.getAll().filter { it.name.contains("..") == false })
|
selectedFiles.addAll(adapter.getAll().filter { !it.name.contains("..") })
|
||||||
adapter.updateSelection(selectedFiles)
|
updateSelectionUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
||||||
|
|
||||||
|
findViewById<View>(R.id.btnVideoPreview)?.setOnClickListener {
|
||||||
|
val targetVideo = selectedFiles.singleOrNull()
|
||||||
|
if (targetVideo != null && targetVideo.isFile && extVideos.contains(targetVideo.extension.lowercase())) {
|
||||||
|
toggleSelectionMode(false)
|
||||||
|
videoPreviewHelper.showPreviewDialog(targetVideo, targetFrameCount = 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<View>(R.id.btnSearchSubtitle)?.setOnClickListener {
|
||||||
|
val targetVideos = selectedFiles.filter { it.isFile && extVideos.contains(it.extension.lowercase()) }
|
||||||
|
if (targetVideos.isNotEmpty()) {
|
||||||
|
toggleSelectionMode(false)
|
||||||
|
startSubtitleSearchQueue(targetVideos)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, "선택된 영상 파일이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
||||||
findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
||||||
findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
|
findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
|
||||||
@@ -1078,7 +1204,7 @@ class CompletedFilesActivity : AppCompatActivity() {
|
|||||||
currentDir = currentDir.parentFile ?: rootDir
|
currentDir = currentDir.parentFile ?: rootDir
|
||||||
loadFiles()
|
loadFiles()
|
||||||
} else {
|
} else {
|
||||||
finish() // 최상위 폴더에서 뒤로가기 시 액티비티 종료
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,7 +1389,24 @@ class CompletedFilesAdapter(
|
|||||||
val accessCount = getAccessCount(file.name)
|
val accessCount = getAccessCount(file.name)
|
||||||
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
|
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
|
||||||
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
|
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
|
||||||
if (ivThumb != null) Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
|
|
||||||
|
if (ivThumb != null) {
|
||||||
|
val ext = file.extension.lowercase()
|
||||||
|
if (extImages.contains(ext) || extVideos.contains(ext)) {
|
||||||
|
Glide.with(itemView.context)
|
||||||
|
.load(file)
|
||||||
|
.placeholder(android.R.drawable.ic_menu_report_image)
|
||||||
|
.into(ivThumb)
|
||||||
|
} else {
|
||||||
|
Glide.with(itemView.context).clear(ivThumb)
|
||||||
|
val iconRes = if (extDocs.contains(ext)) {
|
||||||
|
android.R.drawable.ic_menu_edit
|
||||||
|
} else {
|
||||||
|
android.R.drawable.ic_menu_report_image
|
||||||
|
}
|
||||||
|
ivThumb.setImageResource(iconRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
itemView.setOnClickListener { onItemClick(file) }
|
itemView.setOnClickListener { onItemClick(file) }
|
||||||
@@ -1274,3 +1417,180 @@ class CompletedFilesAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class VideoPreviewHelper(
|
||||||
|
private val context: Context,
|
||||||
|
private val onFrameClick: (videoFile: File, timestampMs: Long) -> Unit
|
||||||
|
) {
|
||||||
|
private var extractJob: Job? = null
|
||||||
|
|
||||||
|
fun showPreviewDialog(videoFile: File, targetFrameCount: Int = 15) {
|
||||||
|
val dialog = BottomSheetDialog(context)
|
||||||
|
|
||||||
|
val container = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(24, 32, 24, 24)
|
||||||
|
setBackgroundColor(0xFF1A1A1A.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
val tvHeader = TextView(context).apply {
|
||||||
|
text = "🎬 ${videoFile.name}"
|
||||||
|
textSize = 15f
|
||||||
|
setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
setPadding(8, 0, 8, 16)
|
||||||
|
maxLines = 1
|
||||||
|
ellipsize = TextUtils.TruncateAt.END
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||||
|
isIndeterminate = false
|
||||||
|
max = targetFrameCount
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val recyclerView = RecyclerView(context).apply {
|
||||||
|
layoutManager = GridLayoutManager(context, 3)
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
(context.resources.displayMetrics.heightPixels * 0.65).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = mutableListOf<VideoFrameItem>()
|
||||||
|
val adapter = FrameGridAdapter(items) { clickedItem ->
|
||||||
|
dialog.dismiss()
|
||||||
|
onFrameClick(videoFile, clickedItem.timestampMs)
|
||||||
|
}
|
||||||
|
recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
container.addView(tvHeader)
|
||||||
|
container.addView(progressBar)
|
||||||
|
container.addView(recyclerView)
|
||||||
|
dialog.setContentView(container)
|
||||||
|
|
||||||
|
dialog.setOnShowListener { dialogInterface ->
|
||||||
|
val bsDialog = dialogInterface as BottomSheetDialog
|
||||||
|
val sheet = bsDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
|
||||||
|
sheet?.let {
|
||||||
|
val behavior = BottomSheetBehavior.from(it)
|
||||||
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
behavior.skipCollapsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
extractJob?.cancel()
|
||||||
|
items.forEach { if (!it.bitmap.isRecycled) it.bitmap.recycle() }
|
||||||
|
items.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
|
extractJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
try {
|
||||||
|
retriever.setDataSource(videoFile.absolutePath)
|
||||||
|
val durationMs = retriever.extractMetadata(
|
||||||
|
MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||||
|
)?.toLongOrNull() ?: 0L
|
||||||
|
|
||||||
|
if (durationMs <= 0L) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "영상 길이 정보를 읽을 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
tvHeader.text = "🎬 ${videoFile.name} (${formatDuration(durationMs)})"
|
||||||
|
}
|
||||||
|
|
||||||
|
val stepMs = durationMs / (targetFrameCount + 1)
|
||||||
|
|
||||||
|
for (i in 1..targetFrameCount) {
|
||||||
|
if (!isActive) break
|
||||||
|
val timeMs = stepMs * i
|
||||||
|
val timeUs = timeMs * 1000L
|
||||||
|
|
||||||
|
val rawBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
retriever.getScaledFrameAtTime(
|
||||||
|
timeUs,
|
||||||
|
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
320,
|
||||||
|
180
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawBitmap != null) {
|
||||||
|
val frameItem = VideoFrameItem(
|
||||||
|
timestampMs = timeMs,
|
||||||
|
timeLabel = formatDuration(timeMs),
|
||||||
|
bitmap = rawBitmap
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
items.add(frameItem)
|
||||||
|
adapter.notifyItemInserted(items.size - 1)
|
||||||
|
progressBar.progress = i
|
||||||
|
if (i == targetFrameCount) progressBar.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
try { retriever.release() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(ms: Long): String {
|
||||||
|
val totalSec = ms / 1000
|
||||||
|
val h = totalSec / 3600
|
||||||
|
val m = (totalSec % 3600) / 60
|
||||||
|
val s = totalSec % 60
|
||||||
|
return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, s)
|
||||||
|
else String.format(Locale.US, "%02d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class FrameGridAdapter(
|
||||||
|
private val list: List<VideoFrameItem>,
|
||||||
|
private val onClick: (VideoFrameItem) -> Unit
|
||||||
|
) : RecyclerView.Adapter<FrameGridAdapter.Holder>() {
|
||||||
|
|
||||||
|
inner class Holder(val root: LinearLayout, val iv: ImageView, val tv: TextView) :
|
||||||
|
RecyclerView.ViewHolder(root)
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||||
|
val box = LinearLayout(parent.context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(6, 6, 6, 10)
|
||||||
|
}
|
||||||
|
val iv = ImageView(parent.context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 190)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
setBackgroundColor(0xFF000000.toInt())
|
||||||
|
}
|
||||||
|
val tv = TextView(parent.context).apply {
|
||||||
|
textSize = 11f
|
||||||
|
setTextColor(0xFFCCCCCC.toInt())
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
setPadding(0, 4, 0, 0)
|
||||||
|
}
|
||||||
|
box.addView(iv)
|
||||||
|
box.addView(tv)
|
||||||
|
return Holder(box, iv, tv)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||||
|
val item = list[position]
|
||||||
|
holder.iv.setImageBitmap(item.bitmap)
|
||||||
|
holder.tv.text = item.timeLabel
|
||||||
|
holder.root.setOnClickListener { onClick(item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = list.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//package bums.lunatic.launcher.home
|
||||||
|
//
|
||||||
|
//import android.content.Context
|
||||||
|
//import android.graphics.Bitmap
|
||||||
|
//import android.media.MediaMetadataRetriever
|
||||||
|
//import android.os.Build
|
||||||
|
//import android.view.LayoutInflater
|
||||||
|
//import android.view.View
|
||||||
|
//import android.view.ViewGroup
|
||||||
|
//import android.widget.ImageView
|
||||||
|
//import android.widget.ProgressBar
|
||||||
|
//import android.widget.TextView
|
||||||
|
//import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
//import androidx.recyclerview.widget.RecyclerView
|
||||||
|
//import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
//import kotlinx.coroutines.CoroutineScope
|
||||||
|
//import kotlinx.coroutines.Dispatchers
|
||||||
|
//import kotlinx.coroutines.Job
|
||||||
|
//import kotlinx.coroutines.isActive
|
||||||
|
//import kotlinx.coroutines.launch
|
||||||
|
//import kotlinx.coroutines.withContext
|
||||||
|
//import java.io.File
|
||||||
|
//import java.util.Locale
|
||||||
|
//
|
||||||
|
//data class VideoFrameItem(
|
||||||
|
// val timestampMs: Long,
|
||||||
|
// val timeLabel: String,
|
||||||
|
// val bitmap: Bitmap
|
||||||
|
//)
|
||||||
|
//
|
||||||
|
//class VideoPreviewHelper(
|
||||||
|
// private val context: Context,
|
||||||
|
// private val onFrameClick: (videoFile: File, timestampMs: Long) -> Unit
|
||||||
|
//) {
|
||||||
|
// private var extractJob: Job? = null
|
||||||
|
//
|
||||||
|
// fun showPreviewDialog(videoFile: File, targetFrameCount: Int = 12) {
|
||||||
|
// val dialog = BottomSheetDialog(context)
|
||||||
|
// val rootView = LayoutInflater.from(context).inflate(
|
||||||
|
// android.R.layout.list_content, null
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // 별도 XML 없이 코드로 깔끔하게 구성한 컨테이너 (필요 시 전용 XML로 분리 가능)
|
||||||
|
// val container = android.widget.LinearLayout(context).apply {
|
||||||
|
// orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
// setPadding(24, 32, 24, 24)
|
||||||
|
// setBackgroundColor(0xFF1A1A1A.toInt())
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// val tvHeader = TextView(context).apply {
|
||||||
|
// text = "🎬 ${videoFile.name}"
|
||||||
|
// textSize = 15f
|
||||||
|
// setTextColor(0xFFFFFFFF.toInt())
|
||||||
|
// setPadding(8, 0, 8, 20)
|
||||||
|
// maxLines = 1
|
||||||
|
// ellipsize = android.text.TextUtils.TruncateAt.END
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// val progressBar = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||||
|
// isIndeterminate = false
|
||||||
|
// max = targetFrameCount
|
||||||
|
// progress = 0
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// val recyclerView = RecyclerView(context).apply {
|
||||||
|
// layoutManager = GridLayoutManager(context, 3) // 3열 그리드 나열
|
||||||
|
// layoutParams = ViewGroup.LayoutParams(
|
||||||
|
// ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
// (context.resources.displayMetrics.heightPixels * 0.65).toInt()
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// val items = mutableListOf<VideoFrameItem>()
|
||||||
|
// val adapter = FrameGridAdapter(items) { clickedItem ->
|
||||||
|
// dialog.dismiss()
|
||||||
|
// onFrameClick(videoFile, clickedItem.timestampMs)
|
||||||
|
// }
|
||||||
|
// recyclerView.adapter = adapter
|
||||||
|
//
|
||||||
|
// container.addView(tvHeader)
|
||||||
|
// container.addView(progressBar)
|
||||||
|
// container.addView(recyclerView)
|
||||||
|
// dialog.setContentView(container)
|
||||||
|
//
|
||||||
|
// dialog.setOnDismissListener {
|
||||||
|
// extractJob?.cancel()
|
||||||
|
// // 메모리 회수
|
||||||
|
// items.forEach { if (!it.bitmap.isRecycled) it.bitmap.recycle() }
|
||||||
|
// items.clear()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// dialog.show()
|
||||||
|
//
|
||||||
|
// // 백그라운드에서 키프레임 추출 및 순차 렌더링
|
||||||
|
// extractJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
// val retriever = MediaMetadataRetriever()
|
||||||
|
// try {
|
||||||
|
// retriever.setDataSource(videoFile.absolutePath)
|
||||||
|
// val durationMs = retriever.extractMetadata(
|
||||||
|
// MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||||
|
// )?.toLongOrNull() ?: 0L
|
||||||
|
//
|
||||||
|
// if (durationMs <= 0L) return@launch
|
||||||
|
//
|
||||||
|
// // 시작(0초)과 끝 직전 여백을 두고 균등 간격 산출
|
||||||
|
// val stepMs = durationMs / (targetFrameCount + 1)
|
||||||
|
//
|
||||||
|
// for (i in 1..targetFrameCount) {
|
||||||
|
// if (!isActive) break
|
||||||
|
// val timeMs = stepMs * i
|
||||||
|
// val timeUs = timeMs * 1000L
|
||||||
|
//
|
||||||
|
// val rawBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
// // API 27+: 추출 단계에서 가로 320px로 즉시 축소하여 메모리 절약
|
||||||
|
// retriever.getScaledFrameAtTime(
|
||||||
|
// timeUs,
|
||||||
|
// MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
||||||
|
// 320,
|
||||||
|
// 180
|
||||||
|
// )
|
||||||
|
// } else {
|
||||||
|
// retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (rawBitmap != null) {
|
||||||
|
// val frameItem = VideoFrameItem(
|
||||||
|
// timestampMs = timeMs,
|
||||||
|
// timeLabel = formatDuration(timeMs),
|
||||||
|
// bitmap = rawBitmap
|
||||||
|
// )
|
||||||
|
// withContext(Dispatchers.Main) {
|
||||||
|
// items.add(frameItem)
|
||||||
|
// adapter.notifyItemInserted(items.size - 1)
|
||||||
|
// progressBar.progress = i
|
||||||
|
// if (i == targetFrameCount) progressBar.visibility = View.GONE
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// } catch (e: Exception) {
|
||||||
|
// e.printStackTrace()
|
||||||
|
// } finally {
|
||||||
|
// try { retriever.release() } catch (_: Exception) {}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private fun formatDuration(ms: Long): String {
|
||||||
|
// val totalSec = ms / 1000
|
||||||
|
// val h = totalSec / 3600
|
||||||
|
// val m = (totalSec % 3600) / 60
|
||||||
|
// val s = totalSec % 60
|
||||||
|
// return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, s)
|
||||||
|
// else String.format(Locale.US, "%02d:%02d", m, s)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private inner class FrameGridAdapter(
|
||||||
|
// private val list: List<VideoFrameItem>,
|
||||||
|
// private val onClick: (VideoFrameItem) -> Unit
|
||||||
|
// ) : RecyclerView.Adapter<FrameGridAdapter.Holder>() {
|
||||||
|
//
|
||||||
|
// inner class Holder(val root: android.widget.LinearLayout, val iv: ImageView, val tv: TextView) :
|
||||||
|
// RecyclerView.ViewHolder(root)
|
||||||
|
//
|
||||||
|
// override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||||
|
// val box = android.widget.LinearLayout(parent.context).apply {
|
||||||
|
// orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
// setPadding(6, 6, 6, 10)
|
||||||
|
// }
|
||||||
|
// val iv = ImageView(parent.context).apply {
|
||||||
|
// layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 190)
|
||||||
|
// scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
// setBackgroundColor(0xFF000000.toInt())
|
||||||
|
// }
|
||||||
|
// val tv = TextView(parent.context).apply {
|
||||||
|
// textSize = 11f
|
||||||
|
// setTextColor(0xFFCCCCCC.toInt())
|
||||||
|
// gravity = android.view.Gravity.CENTER
|
||||||
|
// setPadding(0, 4, 0, 0)
|
||||||
|
// }
|
||||||
|
// box.addView(iv)
|
||||||
|
// box.addView(tv)
|
||||||
|
// return Holder(box, iv, tv)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||||
|
// val item = list[position]
|
||||||
|
// holder.iv.setImageBitmap(item.bitmap)
|
||||||
|
// holder.tv.text = item.timeLabel
|
||||||
|
// holder.root.setOnClickListener { onClick(item) }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun getItemCount() = list.size
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -105,10 +105,9 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// 🌟 수정된 부분: 인터넷 상태를 먼저 체크합니다!
|
// 🌟 수정된 부분: 인터넷 상태를 먼저 체크합니다!
|
||||||
if (isNetworkConnected()) {
|
if (isNetworkConnected()) {
|
||||||
// 온라인이면 서버로 접속
|
// 뒤에 ?v=현재시간 을 붙여서 서버/프록시 캐시 우회 (reader.html의 ?id= 파싱에는 영향 없음)
|
||||||
webView.loadUrl(SERVER_URL)
|
webView.loadUrl("$SERVER_URL?v=${System.currentTimeMillis()}")
|
||||||
} else {
|
} else {
|
||||||
// 오프라인이면 즉시 로컬 에셋(위조 모드)으로 실행
|
|
||||||
loadLocalHtmlAsServer()
|
loadLocalHtmlAsServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +127,7 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupWebView() {
|
private fun setupWebView() {
|
||||||
|
webView.clearCache(true)
|
||||||
webView.settings.apply {
|
webView.settings.apply {
|
||||||
cacheMode = WebSettings.LOAD_NO_CACHE
|
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
@@ -190,6 +190,10 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
request: WebResourceRequest?
|
request: WebResourceRequest?
|
||||||
): WebResourceResponse? {
|
): WebResourceResponse? {
|
||||||
val url = request?.url.toString()
|
val url = request?.url.toString()
|
||||||
|
if (url.contains("sw.js")) {
|
||||||
|
// 서비스 워커 스크립트 차단 (네이티브 앱은 assets 폴백을 쓰므로 불필요)
|
||||||
|
return WebResourceResponse("text/javascript", "UTF-8", "".byteInputStream())
|
||||||
|
}
|
||||||
if (url.contains("jszip.min.js")) {
|
if (url.contains("jszip.min.js")) {
|
||||||
return try {
|
return try {
|
||||||
val inputStream = assets.open("jszip.min.js")
|
val inputStream = assets.open("jszip.min.js")
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import bums.lunatic.launcher.workers.WorkersDb
|
|||||||
import com.google.android.gms.location.LocationServices
|
import com.google.android.gms.location.LocationServices
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
|
||||||
class NLService : NotificationListenerService() {
|
class NLService : NotificationListenerService() {
|
||||||
@@ -145,12 +152,84 @@ class NLService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
makeMsgByTransferInfomation(defaultMsg)
|
makeMsgByTransferInfomation(defaultMsg)
|
||||||
}
|
}
|
||||||
|
// else if (stringBuffer.contains("홈택스") && stringBuffer.contains("인증") && stringBuffer.contains(
|
||||||
|
// "요청"
|
||||||
|
// ) && stringBuffer.contains("도착")
|
||||||
|
// ) {
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// println("카톡::\n${stringBuffer.toString()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
"kakaopay.app" -> {
|
"kakaopay.app" -> {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cleanContent = stringBuffer.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct() // title, text, bigText 중복 줄 제거
|
||||||
|
.joinToString("\n")
|
||||||
|
|
||||||
|
val ignoredPackages = setOf("android", "com.android.systemui")
|
||||||
|
if (cleanContent.isNotEmpty() && !ignoredPackages.contains(sbn.packageName)) {
|
||||||
|
saveNotificationToFile(sbn.packageName, cleanContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fileWriteLock = Any()
|
||||||
|
|
||||||
|
private fun saveNotificationToFile(packageName: String, content: String) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
val now = Date()
|
||||||
|
val calendar = Calendar.getInstance().apply { time = now }
|
||||||
|
val hour = calendar.get(Calendar.HOUR_OF_DAY)
|
||||||
|
|
||||||
|
val yearStr = SimpleDateFormat("yyyy", Locale.getDefault()).format(now)
|
||||||
|
val monthStr = SimpleDateFormat("MM", Locale.getDefault()).format(now)
|
||||||
|
val dateStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(now)
|
||||||
|
val fullTimeStr = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(now)
|
||||||
|
|
||||||
|
// 4시간 단위 구간 계산 (00-04, 04-08, 08-12, 12-16, 16-20, 20-24)
|
||||||
|
val startHour = (hour / 4) * 4
|
||||||
|
val endHour = startHour + 4
|
||||||
|
val slotSuffix = String.format(Locale.US, "%02d-%02d", startHour, endHour)
|
||||||
|
|
||||||
|
// 경로: completed_torrents/Documents/Noti/2026/11/push_2026-11-13_12-16.txt
|
||||||
|
val rootDir = File(getExternalFilesDir(null), "completed_torrents")
|
||||||
|
val targetDir = File(rootDir, "Documents/Noti/$yearStr/$monthStr").apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
val logFile = File(targetDir, "push_${dateStr}_${slotSuffix}.txt")
|
||||||
|
|
||||||
|
// 앱 이름 조회 (실패 시 패키지명으로 대체)
|
||||||
|
val appName = try {
|
||||||
|
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
|
packageManager.getApplicationLabel(appInfo).toString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
val entry = buildString {
|
||||||
|
append("========================================\n")
|
||||||
|
append("■ 패키지명 : ").append(packageName).append("\n")
|
||||||
|
append("■ 앱이름 : ").append(appName).append("\n")
|
||||||
|
append("■ 시간 : ").append(fullTimeStr).append("\n")
|
||||||
|
append("■ 내용 :\n")
|
||||||
|
append(content).append("\n")
|
||||||
|
append("========================================\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(fileWriteLock) {
|
||||||
|
logFile.appendText(entry, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:background="#EEEEEE"
|
android:background="#EEEEEE"
|
||||||
android:padding="8dp"
|
android:padding="4dp"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnSelectAll"
|
android:id="@+id/btnSelectAll"
|
||||||
@@ -126,58 +126,95 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="전체"
|
android:text="전체"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnCancelSelection"
|
android:id="@+id/btnCancelSelection"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="취소"
|
android:text="취소"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnVideoPreview"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1.2"
|
||||||
|
android:text="미리보기"
|
||||||
|
android:textColor="#00897B"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnSearchSubtitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1.2"
|
||||||
|
android:text="자막 검색"
|
||||||
|
android:textColor="#0066CC"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:visibility="gone"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnRenameSelected"
|
android:id="@+id/btnRenameSelected"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1.2"
|
||||||
android:text="이름 변경"
|
android:text="이름 변경"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnCopySelected"
|
android:id="@+id/btnCopySelected"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1.2"
|
||||||
android:text="외부폴더로 복사"
|
android:text="외부 복사"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnMoveSelected"
|
android:id="@+id/btnMoveSelected"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1.2"
|
||||||
android:text="폴더 이동"
|
android:text="폴더 이동"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnDeleteSelected"
|
android:id="@+id/btnDeleteSelected"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1.2"
|
||||||
android:text="선택 삭제"
|
android:text="선택 삭제"
|
||||||
android:textColor="#FF0000"
|
android:textColor="#FF0000"
|
||||||
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:paddingVertical="12dp"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 💡 자막 다운로드 파싱용 GeckoWeb 추가 (화면에 보이지 않게 처리) -->
|
|
||||||
<bums.lunatic.launcher.home.GeckoWeb
|
<bums.lunatic.launcher.home.GeckoWeb
|
||||||
android:id="@+id/geckoWebFragment"
|
android:id="@+id/geckoWebFragment"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
Reference in New Issue
Block a user