This commit is contained in:
2026-11-13 15:08:05 +09:00
parent 6d1dbd46dd
commit a6857505ef
27 changed files with 2119 additions and 667 deletions
+2 -2
View File
@@ -144,12 +144,12 @@
android:theme="@style/Theme.Player"
android:icon="@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:screenOrientation="portrait"
android:excludeFromRecents="false"
android:hardwareAccelerated="true"
android:taskAffinity="bums.lunatic.launcher.home.WebReaderActivity"
android:taskAffinity="bums.lunatic.launcher.home.CompletedFilesActivity"
android:launchMode="singleTask"> <intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -22,7 +22,8 @@
"*://newtoki1.org/novel/*",
"*://*.newtoki1.org/novel/*",
"*://toki35.com/novel/*",
"*://sbxh9.com/novel/*"
"*://sbxh9.com/novel/*",
"*://toki36.com/novel/*"
],
"js": ["inject.js"],
"run_at": "document_start",
@@ -33,7 +34,8 @@
"*://newtoki1.org/novel/*",
"*://*.newtoki1.org/novel/*",
"*://toki35.com/novel/*",
"*://sbxh9.com/novel/*"
"*://sbxh9.com/novel/*",
"*://toki36.com/novel/*"
],
"js": ["content.js"],
"run_at": "document_end"
@@ -1,16 +1,2 @@
chrome.webRequest.onBeforeRequest.addListener(
function(details) {
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"] // 요청을 가로채고 취소할 수 있는 권한
);
// Manifest V3에서는 네트워크 차단을 rules.json이 담당하므로 이 파일은 비워둡니다.
console.log("Darama AdBlocker 서비스 워커가 실행되었습니다.");
@@ -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"]
);
@@ -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"
}
]
}
@@ -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",
"name": "Darama AdBlocker",
"background": {
"scripts": ["background.js"]
"service_worker": "background.js"
},
"content_scripts": [
{
@@ -13,9 +13,18 @@
}
],
"permissions": [
"cookies",
"<all_urls>",
"webRequest",
"webRequestBlocking"
"declarativeNetRequest"
],
"host_permissions": [
"<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 () {
handleCommon(document);
// applyDomainRules();
});
// 2. DOM 변화를 실시간 감시하여 동적 아이프레임 및 광고 추가 완벽 차단
// 6. DOM 변화를 감시하여 동적으로 추가되는 아이프레임 광고 차단
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// [darama 도메인] 동적으로 삽입되는 iframe 즉시 삭제
if (location.href.includes("darama")) {
// dramadunyam 도메인에서 동적으로 삽입되는 iframe 즉시 삭제
if (location.href.includes("dramadunyam")) {
if (node.tagName === 'IFRAME') {
node.remove();
} else if (node.querySelectorAll) {
@@ -19,7 +72,6 @@ const observer = new MutationObserver((mutations) => {
}
}
// 동적으로 삽입되는 공통 광고 노드 지속 삭제
handleCommon(node);
});
});
@@ -29,50 +81,3 @@ observer.observe(document.documentElement, {
childList: 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());
}
}
@@ -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.R
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
import bums.lunatic.launcher.home.NeoRssActivity
import bums.lunatic.launcher.home.WebReaderActivity
import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.model.SimpleContact
@@ -39,7 +38,9 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import io.realm.kotlin.ext.query
import io.realm.kotlin.query.Sort
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
@@ -51,8 +52,16 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
private var appsAdapter: AppsAdapter? = null
private var contactAdapter: ContactAdapter? = null
private var recAdapter: RecommendedAppsAdapter? = null
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 {
const val TAG = "AppDrawerBottomSheet"
@@ -62,19 +71,15 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
}
// 배경을 투명하게 하거나 스타일을 적용하기 위해
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// res/values/themes.xml 등에 BottomSheet 스타일 정의 필요 (모서리 둥글게 등)
setStyle(STYLE_NORMAL, R.style.CustomBottomSheetDialogTheme)
}
// 키보드가 올라올 때 바텀시트가 가려지지 않도록 설정
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
// 바텀시트가 완전히 펼쳐진 상태로 시작하게 설정
dialog.setOnShowListener { dialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
val bottomSheet = bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
@@ -94,32 +99,27 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
return binding.root
}
fun showReader() {
startActivity(Intent(context, WebReaderActivity::class.java))
}
private var currentScope: String = CategoryGrouper.SCOPE_ALL
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isSpinnerInitialized = false
setupAdapters()
setupSearchButtons()
setupListeners()
setupKeyboardObserver()
// 초기 데이터 로드
// 초기 데이터 1회 로드 및 스피너 초기화
fetchApps()
setupCategorySpinner()
}
private var currentCategoryKey: String = "ALL"
private fun setupCategorySpinner() {
// 1. 스피너에 들어갈 데이터(표시 이름들) 준비
val displayList = CategoryManualMapper.CATEGORY_MAP.keys.toList()
// 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용)
val adapter = object : ArrayAdapter<String>(
requireContext(),
R.layout.spinner_item_dark,
@@ -127,47 +127,39 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
view.setBackgroundColor(Color.parseColor("#000000"))
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getDropDownView(position, convertView, parent)
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
view.setBackgroundColor(Color.parseColor("#000000"))
return view
}
}
adapter.setDropDownViewResource(R.layout.spinner_item_dark)
binding.categorySpinner.adapter = adapter
// 3. 선택 리스너 설정
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
// 스피너 어댑터 연결 직후 발생하는 최초 자동 호출을 건너뛰어 중복 로딩 차단
if (!isSpinnerInitialized) {
isSpinnerInitialized = true
return
}
val selectedDisplayName = displayList[position]
currentCategoryKey = CategoryManualMapper.CATEGORY_MAP[selectedDisplayName] ?: "ALL"
// 선택 변경 시 목록 갱신 (검색어 유지)
fetchApps(binding.searchInput.text.toString())
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun setupKeyboardObserver() {
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
// 키보드(IME)가 보이는지 확인
val isKeyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
if (isKeyboardVisible) {
binding.quickSearch.visibility = View.VISIBLE
} else {
binding.quickSearch.visibility = View.GONE
}
// 키보드 높이만큼 패딩 처리 등을 자동으로 하게 둠
binding.quickSearch.visibility = if (isKeyboardVisible) View.VISIBLE else View.GONE
insets
}
}
@@ -175,32 +167,27 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
override fun 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 ->
// 1. 동작 설정 (완전히 펼치기)
val behavior = com.google.android.material.bottomsheet.BottomSheetBehavior.from(bottomSheet)
behavior.state = com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
val behavior = BottomSheetBehavior.from(bottomSheet)
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.skipCollapsed = true
// 2. [핵심] 높이 강제 설정 (이게 없으면 리스트가 0dp일 때 안 보임)
val layoutParams = bottomSheet.layoutParams
layoutParams.height = android.view.ViewGroup.LayoutParams.MATCH_PARENT
layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
bottomSheet.layoutParams = layoutParams
}
}
private var recAdapter: RecommendedAppsAdapter? = null
private fun setupAdapters() {
// 기존 Activity의 packageManager 대신 requireContext().packageManager 사용
val pm = requireContext().packageManager
appsAdapter = AppsAdapter(pm, childFragmentManager, binding.appsCount)
contactAdapter = ContactAdapter(requireContext(), childFragmentManager)
// 가로 그리드 개수 4~5개 정도로 조정 (기존 2개였으면 그대로 유지)
binding.appsList.layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false)
binding.appsList.adapter = appsAdapter
binding.appsList.setItemViewCacheSize(20)
binding.appsList.setHasFixedSize(true) //
binding.appsList.setHasFixedSize(true)
binding.contactList.layoutManager = GridLayoutManager(context, 2, GridLayoutManager.HORIZONTAL, false)
binding.contactList.adapter = contactAdapter
@@ -211,12 +198,10 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
private fun setupListeners() {
// 검색어 입력 리스너
binding.searchInput.doOnTextChanged { inputText, _, _, _ ->
filterAppsList(inputText.toString())
}
// 엔터키 입력 시 검색 실행
binding.searchInput.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_UP) {
checkResult(binding.searchInput.text.toString())
@@ -226,7 +211,6 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
}
// 새로고침 버튼
binding.reset.setOnClickListener {
binding.searchInput.text?.clear()
fetchApps()
@@ -234,12 +218,9 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
val keyword: String
get() {
return binding.searchInput.text.toString()
}
get() = binding.searchInput.text.toString()
private fun setupSearchButtons() {
// 기존 AppDrawer의 버튼 연결
binding.searchNmap.setOnClickListener {
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.isSelected = !binding.hidden.isSelected
filterAppsList(keyword)
}
binding.searchReader.setOnClickListener {
showReader()
dismiss()
}
binding.searchTaxi.setOnClickListener {
//// val destName = Uri.encode("서울역")
//// 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")
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696", "UTF-8")}&goaly=${URLEncoder.encode("127.101063", "UTF-8")}", "com.kakao.taxi")
}
binding.searchMusic.setOnClickListener {
try {
val intent: Intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
intent.putExtra(SearchManager.QUERY, keyword) // 검색하고자 하는 곡명이나 아티스트명
intent.setPackage("com.apple.android.music") // 애플 뮤직 패키지 명시
if (intent.resolveActivity(requireContext().getPackageManager()) != null) {
val intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH).apply {
putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
putExtra(SearchManager.QUERY, keyword)
setPackage("com.apple.android.music")
}
if (intent.resolveActivity(requireContext().packageManager) != null) {
startActivity(intent)
}
} catch (e: Exception) {
// 앱이 없을 경우 플레이스토어로 이동
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music"))
marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music")).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
requireContext().startActivity(marketIntent)
}
}
binding.searchTmap.setOnClickListener {
try {
// 한글 검색어를 위해 URL 인코딩 필수
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
val uri = Uri.parse("tmap://search?name=$encodedKeyword")
val intent = Intent(Intent.ACTION_VIEW, uri)
requireContext().startActivity(intent)
requireContext().startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: Exception) {
// 앱이 없을 경우 스토어로 이동
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.skt.tmap.ku"))
requireContext().startActivity(marketIntent)
}
}
binding.searchGoogle.setOnClickListener {
val intent = Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, keyword) // 질문 전달
putExtra(SearchManager.QUERY, keyword)
}
if (intent.resolveActivity(requireContext().packageManager) != null) {
startActivity(intent)
@@ -309,10 +276,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
binding.searchNaver.setOnClickListener {
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
}
}
private fun openSearchApps(schemeString: String, packageName: String? = null) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(schemeString))
@@ -321,31 +286,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
WorkersDb.logAppUsage(packageName, UsageLogType.APP, UsageUpdateType.DATETIME)
}
startActivity(intent)
dismiss() // 실행 후 닫기
dismiss()
} catch (e: Exception) {
e.printStackTrace()
// 앱이 없을 경우 웹으로 열기 등의 예외처리 권장
}
}
private fun checkResult(keyword: String) {
// 1. 현재 필터링된 리스트에서 첫 번째 아이템들을 가져옴
val filteredApp = appsAdapter?.oldList?.firstOrNull()
val filteredContact = contactAdapter?.oldList?.firstOrNull()
val appCount = appsAdapter?.oldList?.size ?: 0
val contactCount = contactAdapter?.oldList?.size ?: 0
// Case 1: 앱만 하나 검색된 경우
if (appCount == 1 && contactCount != 1) {
launchApp(filteredApp)
}
// Case 2: 연락처만 하나 검색된 경우
else if (contactCount == 1 && appCount != 1) {
} else if (contactCount == 1 && appCount != 1) {
launchContact(filteredContact)
}
// Case 3: 앱과 연락처가 동시에 하나씩 검색된 경우 (핵심 로직)
else if (appCount == 1 && contactCount == 1) {
} else if (appCount == 1 && contactCount == 1) {
val appUsage = filteredApp?.clickCount ?: 0
val contactUsage = filteredContact?.touchCount ?: 0
if (contactUsage > appUsage) {
@@ -353,29 +311,24 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
} else {
launchApp(filteredApp)
}
}
else {
} else {
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 {
return text.any { it.code in 0xAC00..0xD7AF }
}
private fun askGemini(keyword: String) {
private fun askGemini(keyword: String) {
try {
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
// 반대 언어 키워드 생성
val converted = if (isKorean) {
SimpleTransliterater.koToEn(keyword) // 한글 -> 영타
SimpleTransliterater.koToEn(keyword)
} else {
SimpleTransliterater.enToKo(keyword) // 영어 -> 한타(자모나열)
SimpleTransliterater.enToKo(keyword)
}
Blog.LOGE("keyword >>>> ${keyword} converted >>> ${converted}")
// 제미나이는 'ㅇㅏㄴㄴㅕㅇ' 같은 자모 나열도 맥락상 '안녕'으로 매우 잘 이해합니다.
val combinedQuery = if (converted != keyword) {
"$keyword ($converted) 에 대해 알려줘"
} else {
@@ -389,29 +342,22 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
startActivity(intent)
} catch (e: Exception) {
// 실패 시 일반 구글 검색으로 폴백
openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
}
}
// 코드 가독성을 위한 헬퍼 함수: 앱 실행
private fun launchApp(app: AppInfo?) {
app?.pkgName?.let { pkg ->
// 사용 기록 로깅
WorkersDb.logAppUsage(pkg, UsageLogType.APP, UsageUpdateType.DATETIME)
// 앱 실행
val intent = requireContext().packageManager.getLaunchIntentForPackage(pkg)
intent?.let { startActivity(it) }
dismiss()
}
}
// 코드 가독성을 위한 헬퍼 함수: 연락처 다이얼러 실행
private fun launchContact(contact: SimpleContact?) {
contact?.let { c ->
// 사용 기록 로깅
c.id?.let { id -> WorkersDb.logAppUsage(id, UsageLogType.CONTACT, UsageUpdateType.DATETIME) }
// 전화 걸기 화면 실행
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${c.phoneNumber}"))
startActivity(intent)
dismiss()
@@ -422,36 +368,41 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
fetchApps(searchString)
}
/**
* 앱 목록을 불러오는 함수
* - 검색어(keyword)가 있으면 필터링을 수행합니다.
* - 검색어가 없으면 '문맥 기반 추천 앱'을 최상단에 배치합니다.
* - IO 스레드에서 실행하여 UI 끊김을 방지합니다.
* 앱 및 연락처 목록을 비동기로 불러오는 함수
* - 이전 작업 취소(fetchJob?.cancel)로 연속 입력 시 병목 방지
* - queryIntentActivities 단 1회 호출로 런처 실행 가능 패키지 Set 캐싱
* - 추천 목록 준비 즉시 UI 선반영
*/
private fun fetchApps(keyword: String? = null) {
lifecycleScope.launch(Dispatchers.IO) {
fetchJob?.cancel()
fetchJob = lifecycleScope.launch(Dispatchers.IO) {
try {
val context = context ?: return@launch
val realm = WorkersDb.getRealm()
val pm = requireContext().packageManager
val pm = context.packageManager
// 1. [추천 로직] 에러가 나도 앱 목록 로딩은 진행되도록 try-catch 분리
// 1. 런처에서 실행 가능한 전체 패키지 Set 확보 (단 1회 IPC)
val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
}
val launchablePackages = pm.queryIntentActivities(mainIntent, 0)
.mapNotNull { it.activityInfo?.packageName }
.toHashSet()
// 2. 추천 목록 먼저 계산 후 UI 선반영
val scoredItems = WorkersDb.getContextualRecommendations(limit = 18)
val unifiedList = mutableListOf<RecommendationItem>()
for (item in scoredItems) {
if (item.type == "APP") {
val app = realm.query<AppInfo>("pkgName == $0", item.key).first().find()
if (app != null && (app.blockRecommend == false || (binding.hidden.isSelected == app.blockRecommend))) {
try { if(pm.getLaunchIntentForPackage(app.pkgName ?: "") != null) {
if (launchablePackages.contains(app.pkgName)) {
unifiedList.add(RecommendationItem.AppItem(realm.copyFromRealm(app)))
}} catch (e: Exception) { }
}
}
} else if (item.type == "CONTACT") {
// 연락처 ID나 전화번호로 조회 (Log 저장 시 key가 무엇인지에 따라 다름)
val contact = realm.query<SimpleContact>("id == $0", item.key).first().find()
if (contact != null) {
unifiedList.add(RecommendationItem.ContactItem(realm.copyFromRealm(contact)))
@@ -459,149 +410,95 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
}
try {
// =========================================================
// 2. [앱 검색 로직 변경] DB 쿼리 -> 메모리 필터링
// =========================================================
withContext(Dispatchers.Main) {
if (_binding != null && unifiedList.isNotEmpty()) {
binding.recAppsList.visibility = View.VISIBLE
recAdapter?.submitList(unifiedList)
}
}
// 2-1. 일단 모든 앱을 가져옵니다. (기본 정렬: 클릭수, 최근사용순)
// 쿼리 조건 없이 모두 가져옴
// 3. 전체 앱 목록 쿼리 및 메모리 필터링
val baseAppQuery = realm.query<AppInfo>()
.sort("clickCount", Sort.DESCENDING)
.sort("lastUseDate", Sort.DESCENDING)
.find()
val scopedAppsList = baseAppQuery.map {
val appCopy = realm.copyFromRealm(it)
val scopedAppsList = baseAppQuery.mapNotNull { app ->
val pkg = app.pkgName ?: ""
if (!launchablePackages.contains(pkg)) return@mapNotNull null
// 💡 수동 매퍼를 사용하여 카테고리 강제 재할당
// it.systemCategoryInt는 AppInfo 모델에 시스템 카테고리 값이 저장되어 있다고 가정함
val fixedCategory = CategoryManualMapper.getFixedCategory(it.pkgName ?:"", it.appName, it.systemCategoryInt)
appCopy.category = fixedCategory
appCopy
}
.filter { appInfo ->
val isLaunchable = try {
pm.getLaunchIntentForPackage(appInfo.pkgName ?: "") != null
} catch (e: Exception) { false }
val isVisible = binding.hidden.isSelected || (app.visibilityMode == 0)
if (!isVisible) return@mapNotNull null
val isVisible = binding.hidden.isSelected || (appInfo.visibilityMode == 0)
val fixedCategory = CategoryManualMapper.getFixedCategory(pkg, app.appName, app.systemCategoryInt)
if (currentCategoryKey != "ALL" && fixedCategory != currentCategoryKey) return@mapNotNull null
// [핵심 변경] 단순 문자열 비교 필터링
val isCategoryMatch = if (currentCategoryKey == "ALL") {
true
} else {
appInfo.category == currentCategoryKey
realm.copyFromRealm(app).apply { category = fixedCategory }
}
isLaunchable && isVisible && isCategoryMatch
}
// 2-2. Realm 객체를 메모리 객체로 변환 (속도 향상 및 스레드 안전)
// 여기서 visible 필터링 등을 미리 해도 좋습니다.
// 2-3. 키워드에 따라 필터링 및 정렬 (유사도 적용)
val filteredApps = if (keyword.isNullOrEmpty()) {
// 키워드가 없으면 기본 정렬 그대로 사용
scopedAppsList
} else {
// 검색어 전처리 (한/영 변환 등)
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
val converted = if (isKorean) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
// 검색어 후보군 (예: "카카오", "zkzk")
val searchTerms = listOfNotNull(keyword, converted).distinct()
// [핵심] 각 앱에 대해 점수 계산
scopedAppsList.mapNotNull { app ->
// 1. 검색 대상 필드들을 리스트로 묶습니다. (null인 필드는 자동으로 제외됨)
val targetFields = listOfNotNull(
app.appName, // 앱 이름
app.pkgName, // 패키지명 (예: com.kakao.talk)
app.category // 카테고리 (예: Social, Game)
)
// 2. 여러 검색어(term) 중 가장 높은 점수를 계산
val targetFields = listOfNotNull(app.appName, app.pkgName, app.category)
val maxScore = searchTerms.maxOf { term ->
// [1단계] 서치 인덱스 확인 (가장 빠름)
// 만약 makeSearchIndex에 패키지명 등을 이미 포함시켰다면 여기서 바로 잡힙니다.
if (app.searchIndex.contains(term, true)) return@maxOf 1.0
targetFields.maxOf { field ->
if (SimpleTransliterater.match(field, term)) 1.0
else SimpleTransliterater.getSimilarity(field, term)
}
}
if (maxScore > 0.3) Pair(app, maxScore) else null
}
.sortedByDescending { it.second }
.map { it.first }
}
// [2단계] 각 필드(이름, 패키지, 카테고리)에 대해 점수 계산 후 가장 높은 것 선택
// targetFields 리스트를 순회하며 가장 높은 매칭 점수를 찾습니다.
val fieldMaxScore = targetFields.maxOf { field ->
// A. 단순 포함 or 초성 매칭 (100점)
if (SimpleTransliterater.match(field, term)) {
1.0
// 4. 연락처 목록 필터링 (검색어 없을 때는 상위 10건만 DB 조회)
val filteredContacts: List<SimpleContact> = if (keyword.isNullOrEmpty()) {
realm.query<SimpleContact>()
.sort("touchCount", Sort.DESCENDING)
.limit(10)
.find()
.map { realm.copyFromRealm(it) }
} else {
// B. 유사도 계산 (0.0 ~ 1.0점)
SimpleTransliterater.getSimilarity(field, term)
}
}
fieldMaxScore // 해당 검색어(term)에 대한 최종 점수 반환
}
// 3. 유사도 임계값 체크 (0.3점 이상만 통과)
if (maxScore > 0.3) {
Pair(app, maxScore)
} else {
null
}
}
.sortedByDescending { it.second } // 점수 높은 순 정렬
.map { it.first } // 결과 반환// 앱 정보만 추출
}
// =========================================================
// 3. [연락처 검색 로직 변경] 동일한 방식 적용
// =========================================================
var filteredContacts: List<SimpleContact> = emptyList()
val allContacts = realm.query<SimpleContact>()
.sort("touchCount", Sort.DESCENDING)
.find()
.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()
.map { realm.copyFromRealm(it) }
filteredContacts = allContacts.mapNotNull { contact ->
val searchTerms = listOfNotNull(
keyword,
if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
).distinct()
allContacts.mapNotNull { contact ->
val maxScore = searchTerms.maxOf { term ->
// 연락처 이름이나 번호 매칭 확인
if (SimpleTransliterater.match(contact.searchIndex ?: "", 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
// 유사도 확인
SimpleTransliterater.getSimilarity(contact.name ?: "", term)
}
if (maxScore > 0.4) Pair(contact, maxScore) else null
}
.sortedByDescending { it.second }
.take(10) // 상위 10개만
.take(10)
.map { it.first }
} else {
filteredContacts = allContacts.take(10)
}
// 4. [UI 업데이트] (기존 로직 유지)
// 5. 앱 및 연락처 UI 갱신
withContext(Dispatchers.Main) {
if (unifiedList.isNotEmpty()) {
binding.recAppsList.visibility = View.VISIBLE
recAdapter?.submitList(unifiedList)
}
if (_binding == null) return@withContext
// 앱 리스트 갱신
packageList.clear()
packageList.addAll(filteredApps)
appsAdapter?.updateData(packageList)
binding.appsCount.text = "${packageList.size} Apps"
// 연락처 리스트 갱신
if (filteredContacts.isNotEmpty()) {
binding.contactList.visibility = View.VISIBLE
contactAdapter?.updateData(filteredContacts)
@@ -610,13 +507,11 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
contactAdapter?.updateData(emptyList())
}
}
} catch (e: CancellationException) {
// 새 검색어 입력 등으로 인한 정상 취소
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -630,6 +525,7 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
override fun onDestroyView() {
fetchJob?.cancel()
super.onDestroyView()
_binding = null
}
@@ -3,13 +3,18 @@ package bums.lunatic.launcher.home
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.text.Editable
import android.text.TextUtils
import android.text.TextWatcher
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -19,6 +24,8 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
@@ -34,22 +41,27 @@ import bums.lunatic.launcher.player.DocumentViewerActivity
import bums.lunatic.launcher.player.ImageViewerActivity
import bums.lunatic.launcher.utils.FileUtils.readTextWithEncoding
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.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.TranslatorOptions
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.net.URLEncoder
import java.util.ArrayDeque
import java.util.Locale
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 FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
// 💡 자막 번역 데이터를 담을 데이터 클래스 추가
data class SubtitleBlock(
val startSec: Double,
val endSec: Double,
@@ -57,6 +69,12 @@ data class SubtitleBlock(
var translatedText: String? = null
)
data class VideoFrameItem(
val timestampMs: Long,
val timeLabel: String,
val bitmap: Bitmap
)
class NaturalOrderComparator : Comparator<File> {
override fun compare(f1: File, f2: File): Int {
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 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 const val VLC_PACKAGE_NAME = "org.videolan.vlc"
class CompletedFilesActivity : AppCompatActivity() {
@@ -115,7 +134,6 @@ class CompletedFilesActivity : AppCompatActivity() {
private val selectedFiles = mutableSetOf<File>()
private lateinit var backPressedCallback: OnBackPressedCallback
// 외부 재생기 복귀 시 리스트 갱신을 위한 Launcher
private val videoPlayerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
@@ -126,6 +144,13 @@ class CompletedFilesActivity : AppCompatActivity() {
private lateinit var geckoWeb: GeckoWeb
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?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.fragment_completed_files)
@@ -137,6 +162,11 @@ class CompletedFilesActivity : AppCompatActivity() {
geckoWeb = findViewById(R.id.geckoWebFragment)
geckoWeb.visibility = View.GONE
videoPreviewHelper = VideoPreviewHelper(this) { videoFile, timestampMs ->
val subPath = findSubtitleFile(videoFile.absolutePath)
launchVlcPlayer(videoFile, subPath, timestampMs)
}
backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
@@ -152,11 +182,30 @@ class CompletedFilesActivity : AppCompatActivity() {
private fun toggleSelectionMode(enable: Boolean) {
isSelectionMode = enable
if (!enable) selectedFiles.clear()
adapter.updateSelection(selectedFiles)
updateSelectionUI()
findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
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() {
recyclerView = findViewById(R.id.recyclerViewCompletedFiles)
updateRecyclerViewLayoutManager()
@@ -171,7 +220,7 @@ class CompletedFilesActivity : AppCompatActivity() {
if (isSelectionMode) {
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
else adapter.updateSelection(selectedFiles)
else updateSelectionUI()
} else {
if (file.isDirectory) {
currentDir = file
@@ -181,7 +230,7 @@ class CompletedFilesActivity : AppCompatActivity() {
if (extVideos.contains(file.extension.lowercase())) {
val subtitlePath = findSubtitleFile(file.absolutePath)
launchExternalPlayer(file, subtitlePath)
launchVlcPlayer(file, subtitlePath, 0L)
} else if (extImages.contains(file.extension.lowercase())) {
trackFileAccess(file.name)
loadFiles()
@@ -210,7 +259,9 @@ class CompletedFilesActivity : AppCompatActivity() {
if (file.isDirectory && !isSelectionMode && !isProtectedFile(file)) {
showFolderDeleteDialog(file)
} else if (!isSelectionMode) {
showFileContextMenu(file)
toggleSelectionMode(true)
selectedFiles.add(file)
updateSelectionUI()
}
},
getAccessCount = { fileName -> getAccessCount(fileName) }
@@ -231,44 +282,71 @@ class CompletedFilesActivity : AppCompatActivity() {
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)
.setTitle(file.name)
.setItems(options.toTypedArray()) { _, which ->
when (options[which]) {
"선택 모드 시작" -> {
toggleSelectionMode(true)
selectedFiles.add(file)
adapter.updateSelection(selectedFiles)
}
"자막 검색 및 다운로드" -> {
startSubtitleSearch(file)
}
}
}
.show()
subtitleSearchQueue.clear()
val sortedVideos = videoFiles.sortedWith(NaturalOrderComparator())
subtitleSearchQueue.addAll(sortedVideos)
totalQueueCount = sortedVideos.size
completedQueueCount = 0
isBatchSubtitleMode = totalQueueCount > 1
processNextSubtitleInQueue()
}
private fun startSubtitleSearch(videoFile: File) {
targetVideoForSubtitle = videoFile
val query = videoFile.nameWithoutExtension
private fun processNextSubtitleInQueue() {
val nextVideo = subtitleSearchQueue.pollFirst()
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")}"
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>>) {
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()) {
Toast.makeText(this, "검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "'${currentVideo?.name}' 검색된 자막이 없습니다.", Toast.LENGTH_SHORT).show()
if (isBatchSubtitleMode) {
processNextSubtitleInQueue()
}
return@runOnUiThread
}
@@ -276,8 +354,8 @@ class CompletedFilesActivity : AppCompatActivity() {
" [${item["lang"]}] ${item["title"]} (${item["size"]})"
}.toTypedArray()
android.app.AlertDialog.Builder(this)
.setTitle("자막 선택")
val builder = android.app.AlertDialog.Builder(this)
.setTitle(dialogTitle)
.setItems(items) { _, which ->
val selected = subList[which]
val detailUrl = selected["downloadUrl"]
@@ -285,10 +363,26 @@ class CompletedFilesActivity : AppCompatActivity() {
if (detailUrl != null) {
Toast.makeText(this, "자막을 다운로드합니다...", Toast.LENGTH_SHORT).show()
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) {
Toast.makeText(this@CompletedFilesActivity, "자막 파일 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
Toast.makeText(this@CompletedFilesActivity, "자막 저장 완료! 언어를 분석합니다.", Toast.LENGTH_SHORT).show()
loadFiles()
// 💡 즉시 재생하지 않고, 번역 프로세스 먼저 실행
processAndTranslateSubtitle(newSubFile, videoFile)
}
} else {
withContext(Dispatchers.Main) {
Toast.makeText(this@CompletedFilesActivity, "자막 다운로드 실패 (HTTP ${response.code})", Toast.LENGTH_SHORT).show()
if (isBatchSubtitleMode) processNextSubtitleInQueue()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
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 {
val cleanStr = timeStr.trim().replace(",", ".")
@@ -351,7 +458,7 @@ class CompletedFilesActivity : AppCompatActivity() {
if (!file.exists()) return result
try {
val content = readTextWithEncoding(file) // FileUtils 유틸 사용
val content = readTextWithEncoding(file)
val blocks = content.split("\n\n", "\r\n\r\n")
for (block in blocks) {
@@ -378,7 +485,7 @@ class CompletedFilesActivity : AppCompatActivity() {
sb.append("${index + 1}\n")
sb.append("${formatSrtTime(block.startSec)} --> ${formatSrtTime(block.endSec)}\n")
val textToSave = if (!block.translatedText.isNullOrEmpty()) {
"${block.translatedText}\n${block.text}" // 번역본 + 원본 병기
"${block.translatedText}\n${block.text}"
} else {
block.text
}
@@ -390,11 +497,10 @@ class CompletedFilesActivity : AppCompatActivity() {
private fun processAndTranslateSubtitle(subFile: File, videoFile: File) {
val subtitles = parseSrt(subFile)
if (subtitles.isEmpty()) {
launchExternalPlayer(videoFile, subFile.absolutePath)
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
return
}
// 언어 감지를 위해 자막 앞부분 샘플링
val sampleText = subtitles.take(10).joinToString(" ") { it.text }
val languageIdentifier = LanguageIdentification.getClient()
@@ -402,20 +508,20 @@ class CompletedFilesActivity : AppCompatActivity() {
.addOnSuccessListener { languageCode ->
if (languageCode == "und" || languageCode == "ko") {
Log.d("CompletedFilesActivity", "번역 불필요 (언어: $languageCode)")
launchExternalPlayer(videoFile, subFile.absolutePath)
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
} else {
Log.d("CompletedFilesActivity", "감지된 언어: $languageCode - 번역 시작")
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
if (sourceLang != null) {
startTranslationProcess(sourceLang, subtitles, subFile, videoFile)
} else {
launchExternalPlayer(videoFile, subFile.absolutePath)
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
}
}
}
.addOnFailureListener {
Log.e("CompletedFilesActivity", "언어 감지 실패", it)
launchExternalPlayer(videoFile, subFile.absolutePath)
onSingleSubtitlePipelineFinished(videoFile, subFile.absolutePath)
}
}
@@ -426,7 +532,7 @@ class CompletedFilesActivity : AppCompatActivity() {
.build()
val translator = Translation.getClient(options)
Toast.makeText(this, "자막을 한국어로 번역 중입니다...", Toast.LENGTH_SHORT).show()
Toast.makeText(this, "'${videoFile.name}' 자막 번역 중...", Toast.LENGTH_SHORT).show()
translator.downloadModelIfNeeded()
.addOnSuccessListener {
@@ -447,57 +553,58 @@ class CompletedFilesActivity : AppCompatActivity() {
newFile.writeText(translatedSrtContent, Charsets.UTF_8)
withContext(Dispatchers.Main) {
Toast.makeText(this@CompletedFilesActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
Toast.makeText(this@CompletedFilesActivity, "'${videoFile.name}' 번역 완료!", Toast.LENGTH_SHORT).show()
loadFiles()
launchExternalPlayer(videoFile, newFile.absolutePath)
onSingleSubtitlePipelineFinished(videoFile, newFile.absolutePath)
}
} catch (e: Exception) {
Log.e("CompletedFilesActivity", "번역 자막 저장 실패", e)
withContext(Dispatchers.Main) { launchExternalPlayer(videoFile, originalSubFile.absolutePath) }
withContext(Dispatchers.Main) {
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
}
}
}
}
.addOnFailureListener {
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막으로 재생합니다.", Toast.LENGTH_SHORT).show()
launchExternalPlayer(videoFile, originalSubFile.absolutePath)
Toast.makeText(this, "번역 모델 다운로드에 실패하여 원본 자막만 저장합니다.", Toast.LENGTH_SHORT).show()
onSingleSubtitlePipelineFinished(videoFile, originalSubFile.absolutePath)
}
}
// --- 💡 [번역 파이프라인 끝] ---
private fun launchExternalPlayer(videoFile: File, subtitlePath: String) {
private fun launchVlcPlayer(videoFile: File, subtitlePath: String, startPositionMs: Long = 0L) {
trackFileAccess(videoFile.name)
try {
val videoUri = FileProvider.getUriForFile(this, "bums.lunatic.launcher.fileprovider", videoFile)
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_ACTIVITY_EXCLUDE_FROM_RECENTS)
addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
if (startPositionMs > 0L) {
putExtra("position", startPositionMs)
}
if (subtitlePath.isNotEmpty()) {
val subFile = File(subtitlePath)
if (subFile.exists()) {
val subUri = FileProvider.getUriForFile(this@CompletedFilesActivity, "bums.lunatic.launcher.fileprovider", subFile)
val parcelableArray = arrayOf<android.os.Parcelable>(subUri)
putExtra("subs", parcelableArray)
putExtra("subs.enable", parcelableArray)
grantUriPermission(VLC_PACKAGE_NAME, subUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra("subtitles_location", subUri)
}
}
}
videoPlayerLauncher.launch(Intent.createChooser(intent, "동영상 재생 앱 선택"))
videoPlayerLauncher.launch(intent)
} 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) {
val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
@@ -771,11 +878,30 @@ class CompletedFilesActivity : AppCompatActivity() {
findViewById<View>(R.id.btnSelectAll)?.setOnClickListener {
selectedFiles.clear()
selectedFiles.addAll(adapter.getAll().filter { it.name.contains("..") == false })
adapter.updateSelection(selectedFiles)
selectedFiles.addAll(adapter.getAll().filter { !it.name.contains("..") })
updateSelectionUI()
}
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.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
@@ -1078,7 +1204,7 @@ class CompletedFilesActivity : AppCompatActivity() {
currentDir = currentDir.parentFile ?: rootDir
loadFiles()
} else {
finish() // 최상위 폴더에서 뒤로가기 시 액티비티 종료
finish()
}
}
@@ -1263,7 +1389,24 @@ class CompletedFilesAdapter(
val accessCount = getAccessCount(file.name)
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
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) }
@@ -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()) {
// 온라인이면 서버로 접속
webView.loadUrl(SERVER_URL)
// 뒤에 ?v=현재시간 을 붙여서 서버/프록시 캐시 우회 (reader.html의 ?id= 파싱에는 영향 없음)
webView.loadUrl("$SERVER_URL?v=${System.currentTimeMillis()}")
} else {
// 오프라인이면 즉시 로컬 에셋(위조 모드)으로 실행
loadLocalHtmlAsServer()
}
@@ -128,6 +127,7 @@ class WebReaderActivity : AppCompatActivity() {
}
private fun setupWebView() {
webView.clearCache(true)
webView.settings.apply {
cacheMode = WebSettings.LOAD_NO_CACHE
javaScriptEnabled = true
@@ -190,6 +190,10 @@ class WebReaderActivity : AppCompatActivity() {
request: WebResourceRequest?
): WebResourceResponse? {
val url = request?.url.toString()
if (url.contains("sw.js")) {
// 서비스 워커 스크립트 차단 (네이티브 앱은 assets 폴백을 쓰므로 불필요)
return WebResourceResponse("text/javascript", "UTF-8", "".byteInputStream())
}
if (url.contains("jszip.min.js")) {
return try {
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 java.io.IOException
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() {
@@ -145,12 +152,84 @@ class NLService : NotificationListenerService() {
}
makeMsgByTransferInfomation(defaultMsg)
}
// else if (stringBuffer.contains("홈택스") && stringBuffer.contains("인증") && stringBuffer.contains(
// "요청"
// ) && stringBuffer.contains("도착")
// ) {
//
// }
//
// println("카톡::\n${stringBuffer.toString()}")
}
"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:orientation="horizontal"
android:background="#EEEEEE"
android:padding="8dp"
android:padding="4dp"
android:visibility="gone">
<TextView
android:id="@+id/btnSelectAll"
@@ -126,58 +126,95 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="전체"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
android:gravity="center"
android:padding="12dp"/>
android:paddingVertical="12dp"/>
<TextView
android:id="@+id/btnCancelSelection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="취소"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
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
android:id="@+id/btnRenameSelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_weight="1.2"
android:text="이름 변경"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
android:gravity="center"
android:padding="12dp"/>
android:paddingVertical="12dp"/>
<TextView
android:id="@+id/btnCopySelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="외부폴더로 복사"
android:layout_weight="1.2"
android:text="외부 복사"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
android:gravity="center"
android:padding="12dp"/>
android:paddingVertical="12dp"/>
<TextView
android:id="@+id/btnMoveSelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_weight="1.2"
android:text="폴더 이동"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
android:gravity="center"
android:padding="12dp"/>
android:paddingVertical="12dp"/>
<TextView
android:id="@+id/btnDeleteSelected"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_weight="1.2"
android:text="선택 삭제"
android:textColor="#FF0000"
android:textSize="11sp"
android:textStyle="bold"
android:singleLine="true"
android:gravity="center"
android:padding="12dp"/>
android:paddingVertical="12dp"/>
</LinearLayout>
<!-- 💡 자막 다운로드 파싱용 GeckoWeb 추가 (화면에 보이지 않게 처리) -->
<bums.lunatic.launcher.home.GeckoWeb
android:id="@+id/geckoWebFragment"
android:layout_width="match_parent"