diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdb1ed76..69c85c35 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } android { - namespace = "bums.lunatic.launcher" + namespace = "bums.lunatic.launcher" compileSdk = 35 defaultConfig { @@ -115,6 +115,15 @@ android { doNotStrip("**/libaria2c.zip.so") doNotStrip("**/libffmpeg.zip.so") doNotStrip("**/libpython.zip.so") + jniLibs { + pickFirsts.add("lib/**/libc++_shared.so") + pickFirsts.add("lib/**/libavcodec.so") + pickFirsts.add("lib/**/libavfilter.so") + pickFirsts.add("lib/**/libavformat.so") + pickFirsts.add("lib/**/libavutil.so") + pickFirsts.add("lib/**/libswresample.so") + pickFirsts.add("lib/**/libswscale.so") + } } } @@ -126,6 +135,7 @@ dependencies { "dir" to "libs", "include" to listOf("*.aar", "*.jar"), ))) + implementation(project(":gdrive")) val kotlinVersion: String? by extra val realmVersion = "2.0.0" implementation ("androidx.appcompat:appcompat:1.7.1") @@ -196,6 +206,28 @@ dependencies { // implementation ("me.everything:providers-core:1.0.1") // implementation ("androidx.window:window:1.0.0") // implementation("io.github.vaneproject:hanguleditor:1.0.0") + + +// implementation ("androidx.concurrent:concurrent-listenablefuture-ktx:1.1.0") + implementation ("com.google.guava:guava:33.2.1-jre") + val camerax_version = "1.3.0" // 안정적인 최신 버전 사용 + + // CameraX 코어 라이브러리 + implementation("androidx.camera:camera-core:$camerax_version") + implementation("androidx.camera:camera-camera2:$camerax_version") + + // CameraX Lifecycle 라이브러리 (Fragment/Activity 생명주기 연결) + implementation("androidx.camera:camera-lifecycle:$camerax_version") + + // CameraX View 라이브러리 (PreviewView 등 UI 구성 요소) + implementation("androidx.camera:camera-view:$camerax_version") + + // (선택) CameraX 비디오 라이브러리 + implementation("androidx.camera:camera-video:$camerax_version") + + // (선택) CameraX 확장 라이브러리 (보케, HDR 등) + implementation("androidx.camera:camera-extensions:$camerax_version") + constraints { // ⚠️ 이 버전을 프로젝트 루트의 build.gradle.kts에 정의된 kotlinVersion 값과 정확히 일치시키세요. val targetKotlinVersion = "2.0.20" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 37f5d6b5..9edb2e9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,11 +58,9 @@ - - - - + + -1 && it.id != R.id.dl_video }.forEach { it.visibility = visibility } + decoViews.filter { it.id > -1 && it.id != R.id.btn_dl_video }.forEach { it.visibility = visibility } } open fun loadUrl(url: String, param: String? = null) { @@ -212,7 +212,7 @@ open class GeckoWeb @JvmOverloads constructor( fun checkIfDownloadable(url: String) { // UI 초기화 post { - decoViews.firstOrNull { it.id == R.id.dl_video }?.let { + decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let { it.setOnClickListener {} it.visibility = GONE } @@ -242,7 +242,7 @@ open class GeckoWeb @JvmOverloads constructor( val videoInfo = YoutubeDL.getInstance().getInfo(request) if (videoInfo != null && !videoInfo.title.isNullOrEmpty()) { post { - decoViews.firstOrNull { it.id == R.id.dl_video }?.let { view -> + decoViews.firstOrNull { it.id == R.id.btn_dl_video }?.let { view -> view.setOnClickListener { videoDownload(url) } view.visibility = VISIBLE } @@ -320,10 +320,11 @@ open class GeckoWeb @JvmOverloads constructor( // 외부 뷰 업데이트 decoViews.forEach { view -> + if (view == null) return@forEach // 뷰가 null이면 건너뛰어 크래시 방지 when(view.id) { - R.id.back -> view.setOnClickListener { session.goBack() } - R.id.current_address -> (view as? TextView)?.apply { tag = currentTitle; text = url } - R.id.reload -> view.setOnClickListener { session.reload() } + R.id.btn_back -> view.setOnClickListener { session.goBack() } + R.id.tv_address -> (view as? TextView)?.apply { tag = currentTitle; text = url } + R.id.btn_reload -> view.setOnClickListener { session.reload() } } } // scrollState = 0 diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt index 3621983c..8bf4b0b4 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt @@ -164,7 +164,7 @@ open class NeoRssActivity : CommonActivity() { } else { - val currentFragment = fragmentMap.values.find { it.isAdded && (!it.isHidden || it.isVisible) } + val currentFragment = supportFragmentManager.fragments.find { it.isAdded && (!it.isHidden || it.isVisible) } if (currentFragment is KeyEventHandler) { if (currentFragment.onKeyEvent(ev)) { return true @@ -338,28 +338,16 @@ open class NeoRssActivity : CommonActivity() { } return@setOnTouchListener false } - binding.floatingActionMenu.setOnMenuButtonClickListener { v-> - Blog.LOGE("v >> ${v}") - showContents(v.id) - } +// binding.floatingActionMenu.setOnMenuButtonClickListener { v-> +// Blog.LOGE("v >> ${v}") +// showContents(v.id) +// } if (intent?.action == Intent.ACTION_WEB_SEARCH) { openWithIntent(intent) } val nullCursor = PointerIcon.getSystemIcon(this, PointerIcon.TYPE_NULL) binding.root.setPointerIcon(nullCursor) - binding.share.setOnClickListener { - if (binding.currentAddress.text.length > 5) { - val sendIntent: Intent = Intent().apply { - setAction(Intent.ACTION_SEND) - (binding.currentAddress.tag as? String)?.let{putExtra(Intent.EXTRA_TITLE, it)} - putExtra(Intent.EXTRA_TEXT, binding.currentAddress.text) - setType("text/plain") - } - val shareIntent = Intent.createChooser(sendIntent, "링크 공유하기") - startActivity(shareIntent) - } - } showContents(R.id.btn_info) } @@ -377,32 +365,31 @@ open class NeoRssActivity : CommonActivity() { private var lastTouchY = 0f - private val fragmentMap = mutableMapOf() +// private val fragmentMap = mutableMapOf() fun showContents(id : Int) { - binding.fragmentLayer.visibility = View.VISIBLE - binding.fragmentContainer.visibility = View.VISIBLE - binding.controllPanel.visibility = View.VISIBLE - binding.floatingActionMenu.visibility = View.VISIBLE + if (id == View.NO_ID) { + Blog.LOGE("무효한 ID(-1) 호출로 인해 showContents를 취소합니다.") + return + } + + Blog.LOGE("targetFragment id ${id}") + // 1. 모든 관련 레이어 가시성 확보 + binding.fragmentContainer.isVisible = true val transaction = supportFragmentManager.beginTransaction() - - fragmentMap.values.forEach { fragment -> - if (fragment.isAdded) { - transaction.hide(fragment) + val tagKey = "TAG_$id" + Blog.LOGE("targetFragment id ${id} tagKey ${tagKey}") + // 2. 매니저에 등록된 모든 프래그먼트를 찾아서 숨김 (중복 방지) + val allFragments = supportFragmentManager.fragments + for (f in allFragments) { + if (f.isAdded && f.isVisible) { + transaction.hide(f) } } - // 2. 요청된 ID에 해당하는 프래그먼트가 이미 생성되었는지 확인 - var targetFragment = fragmentMap[id] - if (targetFragment == null) { - targetFragment = supportFragmentManager.findFragmentByTag("TAG_$id") - - // 시스템이 복구해 놓은 게 있다면, 맵에 다시 등록(싱크 맞추기) - if (targetFragment != null) { - fragmentMap[id] = targetFragment - } - } + // 3. 대상 프래그먼트 찾기 (findFragmentByTag 우선) + var targetFragment = supportFragmentManager.findFragmentByTag(tagKey) if (targetFragment == null) { // 처음 호출되는 메뉴라면 인스턴스 생성 및 추가 @@ -426,10 +413,11 @@ open class NeoRssActivity : CommonActivity() { } targetFragment?.let { - fragmentMap[id] = it - transaction.add(R.id.fragment_container, it, "TAG_$id") + Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}") + transaction.add(R.id.fragment_container, it, tagKey) } } else { + Blog.LOGE("targetFragment id ${id} key : ${tagKey}, instance ${targetFragment}") // 이미 생성된 프래그먼트가 있다면 다시 보여줌 transaction.show(targetFragment) } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt index 85d0e45d..4dbf175a 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt @@ -220,11 +220,11 @@ internal class RssHome : Fragment() , KeyEventHandler { return when (event.keyCode) { KeyEvent.KEYCODE_VOLUME_DOWN -> { if (isUp) { - if (binding.geckoWeb.scrollState > 0) { + if (binding.lunaticBrowser.geckoWeb.scrollState > 0) { // 사용자가 손으로 끝까지 내렸든, 볼륨키로 내렸든 상관없이 이곳에 도달함 doNextPage() } else { - binding.geckoWeb.pageDown() + binding.lunaticBrowser.geckoWeb.pageDown() } } true @@ -232,10 +232,10 @@ internal class RssHome : Fragment() , KeyEventHandler { } KeyEvent.KEYCODE_VOLUME_UP -> { if (isUp) { - if (binding.geckoWeb.scrollState < 0) { - binding.geckoWeb.session?.goBack() + if (binding.lunaticBrowser.geckoWeb.scrollState < 0) { + binding.lunaticBrowser.geckoWeb.session?.goBack() } else { - binding.geckoWeb.pageUp() + binding.lunaticBrowser.geckoWeb.pageUp() } } true @@ -261,7 +261,7 @@ internal class RssHome : Fragment() , KeyEventHandler { } } fun searchKeyword() { - binding.geckoWeb.visibility = View.GONE + binding.lunaticBrowser.visibility = View.GONE val bottomSheet = SearchBottomSheet() bottomSheet.listener = object : OnSearchListener{ override fun onSearch( @@ -280,11 +280,11 @@ internal class RssHome : Fragment() , KeyEventHandler { fun ask() { - binding.geckoWeb.visibility = View.GONE + binding.lunaticBrowser.visibility = View.GONE val bottomSheet = WebBottomSheet() bottomSheet.listener = object : WebBottomSheet.OnGoToWebListener{ override fun enterSearch() { - binding.geckoWeb.sendSearchDo() + binding.lunaticBrowser.geckoWeb.sendSearchDo() } override fun onGotoWeb( keyword: String, @@ -294,56 +294,56 @@ internal class RssHome : Fragment() , KeyEventHandler { when (category) { RssDataType.ANYTHING -> { - if (binding.geckoWeb.lastedUrl?.contains("search.brave") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("search.brave") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://search.brave.com/", if (keyword.length ?: 0 > 0) { "search?q=${keyword}" } else { null }) + binding.lunaticBrowser.geckoWeb.loadUrl("https://search.brave.com/", if (keyword.length ?: 0 > 0) { "search?q=${keyword}" } else { null }) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.GOOGLE -> { - if (binding.geckoWeb.lastedUrl?.contains("www.google") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("www.google") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://www.google.com/", if (keyword.length ?: 0 > 0) { "search?q=${keyword}" } else { null }) + binding.lunaticBrowser.geckoWeb.loadUrl("https://www.google.com/", if (keyword.length ?: 0 > 0) { "search?q=${keyword}" } else { null }) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.NAMU -> { - if (binding.geckoWeb.lastedUrl?.contains("namu.wiki") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("namu.wiki") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://namu.wiki", if (keyword.length ?: 0 > 0) {"/Search?q=${keyword}"} else {null}) + binding.lunaticBrowser.geckoWeb.loadUrl("https://namu.wiki", if (keyword.length ?: 0 > 0) {"/Search?q=${keyword}"} else {null}) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.NAVER -> { - if (binding.geckoWeb.lastedUrl?.contains("naver") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("naver") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://search.naver.com/", if (keyword.length ?: 0 > 0) {"search.naver?where=nexearch&query==${keyword}"} else {null}) + binding.lunaticBrowser.geckoWeb.loadUrl("https://search.naver.com/", if (keyword.length ?: 0 > 0) {"search.naver?where=nexearch&query==${keyword}"} else {null}) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.NEWSFEED -> { - if (binding.geckoWeb.lastedUrl?.contains("news.google") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("news.google") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://news.google.com/", if (keyword.length ?: 0 > 0) {"search?q=${keyword}&hl=ko&gl=KR&ceid=KR%3Ako"} else {null}) + binding.lunaticBrowser.geckoWeb.loadUrl("https://news.google.com/", if (keyword.length ?: 0 > 0) {"search?q=${keyword}&hl=ko&gl=KR&ceid=KR%3Ako"} else {null}) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.NEWS -> { - if (binding.geckoWeb.lastedUrl?.contains("bigkinds") == true) { - binding.geckoWeb.sendSearch(keyword) + if (binding.lunaticBrowser.geckoWeb.lastedUrl?.contains("bigkinds") == true) { + binding.lunaticBrowser.geckoWeb.sendSearch(keyword) } else { - binding.geckoWeb.loadUrl("https://www.bigkinds.or.kr/", if (keyword.length ?: 0 > 0) {"search?q=${keyword}&hl=ko&gl=KR&ceid=KR%3Ako"} else {null}) + binding.lunaticBrowser.geckoWeb.loadUrl("https://www.bigkinds.or.kr/", if (keyword.length ?: 0 > 0) {"search?q=${keyword}&hl=ko&gl=KR&ceid=KR%3Ako"} else {null}) } - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.geckoWeb.privateMode = false } RssDataType.PRIVATE -> { - binding.geckoWeb.privateMode = privateMode - binding.geckoWeb.loadUrl("aHR0cHM6Ly9pamF2dG9ycmVudC5jb20=", if (keyword.length ?: 0 > 0) {"/?searchTerm=${keyword}"} else {null}) + binding.lunaticBrowser.geckoWeb.privateMode = privateMode + binding.lunaticBrowser.geckoWeb.loadUrl("aHR0cHM6Ly9pamF2dG9ycmVudC5jb20=", if (keyword.length ?: 0 > 0) {"/?searchTerm=${keyword}"} else {null}) } else -> { @@ -365,7 +365,7 @@ internal class RssHome : Fragment() , KeyEventHandler { } appendReadCount(it, 1, false) binding.layoutRssSummary.title.setOnLongClickListener { - currentRss?.originPage?.let { binding.geckoWeb.loadUrl(it)} + currentRss?.originPage?.let { binding.lunaticBrowser.geckoWeb.loadUrl(it)} binding.layoutRssSummary.root.visibility = View.GONE true } @@ -437,12 +437,15 @@ internal class RssHome : Fragment() , KeyEventHandler { @SuppressLint("SimpleDateFormat") fun openGecko(rssData: RssData? = null) { binding.layoutRssSummary.root.visibility = View.GONE + binding.lunaticBrowser.visibility = View.GONE + binding.lunaticBrowser.setupForRssHome() if (rssData?.category()?.equals(RssDataType.PRIVATE) == true && rssData?.getMagnet().isNullOrEmpty()) { openSummary(rssData) } else if (rssData?.originPage?.isNotEmpty() == true) { rssData?.let { rss -> currentRss = rss - binding.geckoWeb.privateMode = false + binding.lunaticBrowser.visibility = View.VISIBLE + binding.lunaticBrowser.geckoWeb.privateMode = false appendReadCount(rss, 1, false) rss?.originPage?.let { rssId-> synchronized(lasted) { @@ -450,7 +453,7 @@ internal class RssHome : Fragment() , KeyEventHandler { lasted.removeAll { target -> target.originPage.equals(rssId) } } } - binding.geckoWeb.loadUrl(rssId) + binding.lunaticBrowser.geckoWeb.loadUrl(rssId) } } } else { @@ -487,17 +490,12 @@ internal class RssHome : Fragment() , KeyEventHandler { return@setOnTouchListener false } } - binding.vote.setOnClickListener { - if (binding.geckoWeb.isVisible) { - vote() - } - } if (openQuery.length > 0) { - binding.geckoWeb.loadUrl("https://www.google.com/search?q=${openQuery}") + binding.lunaticBrowser.geckoWeb.loadUrl("https://www.google.com/search?q=${openQuery}") openQuery = "" } - binding.geckoWeb?.mOnSave = object : GeckoWeb.OnSave{ + binding.lunaticBrowser.geckoWeb?.mOnSave = object : GeckoWeb.OnSave{ override fun saved() { currentRss?.originPage.let { Blog.LOGE("Arrow Center Click") @@ -516,41 +514,7 @@ internal class RssHome : Fragment() , KeyEventHandler { } } } - binding.hide.setOnClickListener { - if (binding.geckoWeb.isVisible) { - WorkersDb.getRealm().apply { - writeBlocking { - currentRss?.originPage?.let { - val result = query().query("originPage == $0", it).find() - if (result.size > 0) { - result.forEach { - if (it.vote) { - it.vote = false - } - it.hide = true - } - } - } - } - } - doNextPage() - } - } - binding.home.setOnClickListener { - if (binding.geckoWeb.isVisible || binding.layoutRssSummary.root.isVisible) { - binding.geckoWeb.visibility = View.GONE - binding.layoutRssSummary.root.visibility = View.GONE - } else { - queryInfos() - } - } - - - binding.bookmark.setOnClickListener { - binding.layoutRssSummary.root.visibility = View.GONE - queryVotes() - } binding.layoutRssSummary.link.setOnClickListener { (it.tag as? RssData)?.let { @@ -573,46 +537,113 @@ internal class RssHome : Fragment() , KeyEventHandler { queryInfos() - binding.geckoWeb.progress = binding.progressBar - binding.geckoWeb.jxInteface = { jxEvent -> - when (jxEvent) { - JxEvent.SCROLL_UP -> binding.geckoWeb.sendScrollDown(false) - JxEvent.SCROLL_DOWN -> binding.geckoWeb.sendScrollDown(true) - JxEvent.ON_CLICK -> { - binding.geckoWeb.visibility = View.GONE - } + val nullCursor = PointerIcon.getSystemIcon(requireContext(), PointerIcon.TYPE_NULL) + binding.root.setPointerIcon(nullCursor) - JxEvent.SWIPE_LEFT -> { + binding.lunaticBrowser.let { browser -> + // 1. 상단 바 RssHome 전용 기능 연결 + browser.binding.btnHome.setOnClickListener { +// if (binding.lunaticBrowser.isVisible || binding.layoutRssSummary.root.isVisible) { + binding.lunaticBrowser.visibility = View.GONE + binding.layoutRssSummary.root.visibility = View.GONE +// } else { + queryInfos() +// } + } + + browser.binding.btnBookmark.setOnClickListener { + binding.layoutRssSummary.root.visibility = View.GONE + binding.lunaticBrowser.visibility = View.GONE + queryVotes() + } + + browser.binding.btnSearch.setOnClickListener { searchKeyword() } + browser.binding.btnSearch.setOnLongClickListener{ + ask() + true + } + browser.binding.btnHide.setOnClickListener { + if (binding.lunaticBrowser.isVisible) { + WorkersDb.getRealm().apply { + writeBlocking { + currentRss?.originPage?.let { + val result = query().query("originPage == $0", it).find() + if (result.size > 0) { + result.forEach { + if (it.vote) { + it.vote = false + } + it.hide = true + } + } + } + } + } doNextPage() } + } - JxEvent.SWIPE_RIGHT -> { + browser.binding.btnVote.setOnClickListener { + if (binding.lunaticBrowser.isVisible) { vote() } } + + // 2. GeckoWeb 콜백 및 설정 유지 + browser.geckoWeb.jxInteface = { jxEvent -> + when (jxEvent) { + JxEvent.SWIPE_LEFT -> doNextPage() + JxEvent.SWIPE_RIGHT -> vote() + else -> {} + } + } } - val nullCursor = PointerIcon.getSystemIcon(requireContext(), PointerIcon.TYPE_NULL) - binding.root.setPointerIcon(nullCursor) - binding.search.setOnClickListener { searchKeyword() } - binding.search.setOnLongClickListener{ - ask() - true + binding.lunaticBrowser.geckoWeb.restoreSessionState() + + binding.let { b -> + // 전체 탭 + b.tabAll.setOnClickListener { + highlightTab(it as TextView) + queryInfos() // 기존 전체 쿼리 호출 + } + + // 북마크(Voted) 탭 + b.tabVoted.setOnClickListener { + highlightTab(it as TextView) + queryVotes() // 기존 보트 쿼리 호출 + } + + // 프라이빗 탭 + b.tabPrivate.setOnClickListener { + highlightTab(it as TextView) + queryPrevate() // 기존 프라이빗 쿼리 호출 + } + b.tabAll.performClick() + + b.tabSerarch.setOnClickListener { + searchKeyword() + } + b.tabSerarch.setOnLongClickListener { + ask() + true + } + } - binding.geckoWeb.decoViews.add(binding.hide) - binding.geckoWeb.decoViews.add(binding.vote) - binding.geckoWeb.decoViews.add(binding.progressBar) - (activity as? NeoRssActivity)?.let { activity -> - binding.geckoWeb.decoViews.add(activity.findViewById(R.id.current_address)) - binding.geckoWeb.decoViews.add(activity.findViewById(R.id.back)) - binding.geckoWeb.decoViews.add(activity.findViewById(R.id.reload)) - binding.geckoWeb.decoViews.add(activity.findViewById(R.id.dl_video)) - } - binding.geckoWeb.restoreSessionState() + + // 탭 강조를 위한 간단한 함수 + return binding.root } + private fun highlightTab(selected: TextView) { + val tabs = listOf(binding.tabAll, binding.tabVoted, binding.tabPrivate) + tabs.forEach { + it.setTextColor(resources.getColor(R.color.finestSilver)) + } + selected.setTextColor(resources.getColor(R.color.white)) + } fun vote() { - binding.geckoWeb?.saveMd(true) + binding.lunaticBrowser.geckoWeb?.saveMd(true) } // TokiFragment 또는 GeckoView를 사용하는 프래그먼트 내부 @@ -620,7 +651,7 @@ internal class RssHome : Fragment() , KeyEventHandler { super.onHiddenChanged(hidden) if (hidden) { // 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지 - binding.geckoWeb?.onPause() + binding.lunaticBrowser.geckoWeb?.onPause() // 일반 WebView라면: webView.onPause() 및 webView.pauseTimers() } else { // 💡 다시 나타날 때: 다시 시작 @@ -960,26 +991,6 @@ internal class RssHome : Fragment() , KeyEventHandler { } fun randomOrNull() : RssData? = lasted.randomOrNull() -// fun rett(imageView: ImageView,imageUrl: String){ -// // OkHttp로 직접 이미지 다운로드 후 -// val request = Request.Builder().url(imageUrl).build() -// val client = OkHttpClient() -// client.newCall(request).enqueue(object : Callback { -// override fun onFailure(call: Call, e: IOException) { -// // 실패 시 기본 이미지 처리 or 로깅 -// -// } -// -// override fun onResponse(call: Call, response: Response) { -// response.body?.byteStream()?.let { inputStream -> -// val bitmap = BitmapFactory.decodeStream(inputStream) -// mainHandler.post({ -// imageView.setImageBitmap(bitmap) -// }) -// } -// } -// }) -// } } var toast: Toast? = null fun Context.toast(string: String) { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt index 9d147908..ae17def5 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt @@ -38,6 +38,9 @@ import bums.lunatic.launcher.databinding.FragmentSystemStatusBinding import bums.lunatic.launcher.utils.Blog import kotlinx.coroutines.* import java.util.Calendar +import com.google.common.util.concurrent.ListenableFuture +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat class SystemStatusFragment : Fragment() { @@ -120,7 +123,8 @@ class SystemStatusFragment : Fragment() { } binding.progressBattery.setOnClickListener { binding.textBattery.performClick() } } - + private var isFlashOn = false + private lateinit var cameraManager: android.hardware.camera2.CameraManager private fun setupQuickControls() { binding.btnQuickWifi.setOnClickListener { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -177,9 +181,109 @@ class SystemStatusFragment : Fragment() { override fun onStartTrackingTouch(seekBar: SeekBar?) {} override fun onStopTrackingTouch(seekBar: SeekBar?) {} }) + cameraManager = requireContext().getSystemService(Context.CAMERA_SERVICE) as android.hardware.camera2.CameraManager + + binding.btnQuickFlash.setOnClickListener { + toggleFlashlight() + } + binding.btnQuickMirror.setOnClickListener { + if (allPermissionsGranted()) { + showMirrorDialog() // 권한이 있으면 거울 실행 + } else { + // 권한이 없으면 사용자에게 요청 + requestPermissions(arrayOf(CAMERA_PERMISSION), REQUEST_CODE_PERMISSIONS) + } + } updateQuickControlUI() } + private val CAMERA_PERMISSION = android.Manifest.permission.CAMERA + private val REQUEST_CODE_PERMISSIONS = 1001 + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + if (requestCode == REQUEST_CODE_PERMISSIONS) { + if (allPermissionsGranted()) { + showMirrorDialog() + } else { + Toast.makeText(requireContext(), "거울 기능을 사용하려면 카메라 권한이 필요합니다.", Toast.LENGTH_SHORT).show() + } + } + } + + private var flashTimerJob: Job? = null + + private fun toggleFlashlight() { + try { + val cameraId = cameraManager.cameraIdList[0] + isFlashOn = !isFlashOn + cameraManager.setTorchMode(cameraId, isFlashOn) + + // 기존 타이머가 있다면 취소 + flashTimerJob?.cancel() + + if (isFlashOn) { + binding.btnQuickFlash.text = "🔦 ON" + binding.btnQuickFlash.setTextColor(Color.YELLOW) + + // 30초 후 자동 종료 타이머 시작 + flashTimerJob = CoroutineScope(Dispatchers.Main).launch { + delay(30000L) // 30초 + if (isFlashOn) { + isFlashOn = false + cameraManager.setTorchMode(cameraId, false) + binding.btnQuickFlash.text = "🔦 조명" + binding.btnQuickFlash.setTextColor(Color.WHITE) + Toast.makeText(context, "배터리 보호를 위해 조명을 껐습니다.", Toast.LENGTH_SHORT).show() + } + } + } else { + binding.btnQuickFlash.text = "🔦 조명" + binding.btnQuickFlash.setTextColor(Color.WHITE) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + // 2. 권한 확인 함수 + private fun allPermissionsGranted() = androidx.core.content.ContextCompat.checkSelfPermission( + requireContext(), CAMERA_PERMISSION) == android.content.pm.PackageManager.PERMISSION_GRANTED + + private fun showMirrorDialog() { + val dialog = android.app.Dialog(requireContext(), android.R.style.Theme_Black_NoTitleBar_Fullscreen) + val previewView = androidx.camera.view.PreviewView(requireContext()) + dialog.setContentView(previewView) + + val cameraProviderFuture = androidx.camera.lifecycle.ProcessCameraProvider.getInstance(requireContext()) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = androidx.camera.core.Preview.Builder().build() + + // 전면 카메라 선택 + val cameraSelector = androidx.camera.core.CameraSelector.DEFAULT_FRONT_CAMERA + + try { + cameraProvider.unbindAll() + // 이 다이얼로그의 LifecycleOwner를 Fragment로 지정 + cameraProvider.bindToLifecycle(this, cameraSelector, preview) + preview.setSurfaceProvider(previewView.surfaceProvider) + } catch (e: Exception) { + Blog.LOGE("거울 실행 실패: ${e.message}") + } + }, androidx.core.content.ContextCompat.getMainExecutor(requireContext())) + CoroutineScope(Dispatchers.Main).launch { + delay(30000L) + if (dialog.isShowing) { + dialog.dismiss() + Toast.makeText(context, "거울을 종료합니다.", Toast.LENGTH_SHORT).show() + } + } + // 화면 터치 시 종료 + previewView.setOnClickListener { dialog.dismiss() } + dialog.show() + } private fun updateQuickControlUI() { val ringerText = when (audioManager.ringerMode) { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt index 2177a634..0cd57dee 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt @@ -292,7 +292,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } // Novels의 경우 VISIBLE 처리가 있었으나, GONE 처리 후 필요 시 메뉴를 보여주는 흐름으로 통합 if(contentsType == "book") { - binding.menuWeb.visibility = VISIBLE + binding.lunaticBrowser.visibility = VISIBLE } } } @@ -312,7 +312,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { fun back() { if (contentsType == "youtube") { - binding.menuWeb.session?.goBack() + binding.lunaticBrowser.geckoWeb.session?.goBack() } else { actionNextEvent(false) } @@ -431,7 +431,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { super.onHiddenChanged(hidden) if (hidden) { // 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지 - binding.menuWeb?.onPause() + binding.lunaticBrowser.geckoWeb?.onPause() // 일반 WebView라면: webView.onPause() 및 webView.pauseTimers() } else { // 💡 다시 나타날 때: 다시 시작 @@ -447,7 +447,47 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { ): View { Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}") binding = BooktokiBinding.inflate(inflater) - binding.menuWeb.let { + binding = BooktokiBinding.inflate(inflater) + + // 💡 1. Toki용 레이아웃 설정 + binding.lunaticBrowser.setupForToki() + + // 💡 2. GeckoWeb 접근 및 설정 + val geckoWeb = binding.lunaticBrowser.geckoWeb + + + + // 💡 3. Toki 전용 버튼 클릭 리스너 연결 + binding.lunaticBrowser.binding.btnList.setOnClickListener { + lastedUrl?.let { + Uri.parse(it).path?.let { + HistoryManager.getBookInfos(contentsType,processPageUrl(it), { + it?.let { + it.pages.sortBy { it.pathUrl } + Blog.LOGE("bind ing.btnList it >>> $it") + showList(it) + } + }) + } + } + } + + binding.lunaticBrowser.binding.btnSetting.setOnClickListener { + activity?.startActivity(Intent(requireContext(), Settings::class.java)) // 필요 시 경로수정 + } + + binding.lunaticBrowser.binding.btnHistory.setOnClickListener { + getHistory()?.let { showHistory(it) } + } + + binding.lunaticBrowser.binding.btnHome.setOnClickListener { + binding.pagedLayer.visibility = GONE + binding.lunaticBrowser.visibility = VISIBLE + binding.lunaticBrowser.showBrowserAreaOnly(true) + goToHome() + } + + geckoWeb.let { it.sessionTag = webcontentsName it.visibility = View.VISIBLE it.lastDomain = getLastedDoamin() @@ -462,33 +502,13 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { if (lastedUrl?.contains("youtube.com") == true) { } else { -// binding.menuWeb.postDelayed({ -// -// Blog.LOGE("onPageStop $success from WebExtension ${mPort!!.name}") -// val message: JSONObject = JSONObject() -// try { -// message.put("type", "getList") -// message.put("event", "sadsadds") -//// message.put("tab", session.settings.screenId) -// } catch (ex: JSONException) { -// throw RuntimeException(ex) -// } -// -// mPort!!.postMessage(message) -// -// -// // 타입별 분기 처리 (기존 when 절 대체) -// if (contentsType == "comics" || contentsType == "webtoon") { -// lastInfo -// } -// binding.progress.visibility = GONE -// }, 10L) + } } } } it.onPageStartCallback = { url -> - binding.progress.visibility = VISIBLE +// binding.lunaticBrowser.binding.progress.visibility = VISIBLE } it.onSessionStateChangeCallback = {sessionState -> if (contentsType == "comics" || contentsType == "webtoon") { @@ -508,15 +528,17 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } "NotRegistered" -> { binding.pagedLayer.visibility = GONE + binding.lunaticBrowser.visibility = VISIBLE } "WebtoonContents"-> { binding.pagedLayer.visibility = GONE + binding.lunaticBrowser.visibility = VISIBLE } "MSG" -> { lPortMessage.msg?.let { Toast.makeText(requireContext(),it, Toast.LENGTH_SHORT).show() } } "SHOWVIEWER" -> { - binding.progress.visibility = GONE + binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE } "PRIVATES"->{ lPortMessage.privates?.let { @@ -528,7 +550,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } } - binding.progress.visibility = GONE + binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE } catch (e: Exception) { e.printStackTrace() } @@ -539,7 +561,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } else { // url이 현재 로드된 주소입니다. Blog.LOGE("GeckoView", "현재 주소: $url") - Blog.LOGE("GeckoView", "현재 session: ${binding.menuWeb.session}") + Blog.LOGE("GeckoView", "현재 session: ${binding.lunaticBrowser.geckoWeb.session}") url?.let { url -> if (url.split("//").size > 1) { url.replace("//", "/").replace("https:/", "https://").let { @@ -570,44 +592,16 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { else -> {} } } - (activity as? NeoRssActivity)?.let { activity -> it.decoViews.clear() - it.decoViews.add(activity.findViewById(R.id.current_address)) - it.decoViews.add(activity.findViewById(R.id.back)) - it.decoViews.add(activity.findViewById(R.id.reload)) - it.decoViews.add(activity.findViewById(R.id.dl_video)) - } + it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.tv_address)) + it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_back)) + it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_reload)) + it.decoViews.add(binding.lunaticBrowser.findViewById(R.id.btn_dl_video)) it.restoreSessionState() } - binding.btnList.setOnClickListener { v -> - lastedUrl?.let { - Uri.parse(it).path?.let { - HistoryManager.getBookInfos(contentsType,processPageUrl(it), { - it?.let { - it.pages.sortBy { it.pathUrl } - Blog.LOGE("bind ing.btnList it >>> $it") - showList(it) - } - }) - } - } - } - binding.btnSetting.setOnClickListener { v -> - activity?.startActivity(Intent(requireContext(), Settings::class.java)) - } - binding.btnHistory.setOnClickListener { v -> - getHistory()?.let { - showHistory(it) - } - } - - binding.btnHome.setOnClickListener { v -> - binding.pagedLayer.visibility = GONE - goToHome() - } val nullCursor = PointerIcon.getSystemIcon(requireContext(), PointerIcon.TYPE_NULL) binding.root.setPointerIcon(nullCursor) @@ -967,9 +961,9 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { contentsPageInfo.contents?.let { onLoadedContents(it) } contentsPageInfo.chapterTitle?.let { onFindTitle(it) } if (contentsPageInfo.pathUrl?.startsWith("http") == true) { - binding.menuWeb.loadUrl(pathUrl) + binding.lunaticBrowser.geckoWeb.loadUrl(pathUrl) } else { - binding.menuWeb.loadUrl(getLastedDoamin() + contentsPageInfo.pathUrl!!) + binding.lunaticBrowser.geckoWeb.loadUrl(getLastedDoamin() + contentsPageInfo.pathUrl!!) } HistoryManager.save(HistoryItem().putHistory(contentsPageInfo, contentsPageInfo.pathUrl!!)) } @@ -984,9 +978,10 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { applyCurrentBook(it) } else if(lastInfo != null){ binding.pagedLayer.visibility = GONE - binding.menuWeb.loadUrl(getLastedDoamin() + lastInfo!!.pageUrl) + binding.lunaticBrowser.visibility = VISIBLE + binding.lunaticBrowser.geckoWeb.loadUrl(getLastedDoamin() + lastInfo!!.pageUrl) } else { - binding.menuWeb.loadUrl(getLastedDoamin()) + binding.lunaticBrowser.geckoWeb.loadUrl(getLastedDoamin()) } } } @@ -1088,7 +1083,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } } if (saveContinuation) { - binding.menuWeb.postDelayed({ + binding.lunaticBrowser.geckoWeb.postDelayed({ moveToNext( currentPage?.pathUrl ?: lastedUrl?.toUri()?.path ) @@ -1108,16 +1103,16 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { contents = (contents.replace("\\n", System.getProperty("line.separator"))) view.mPagedTextViewInterface = this@TokiFragment if (lastInfo != null && lastedUrl?.endsWith(lastInfo!!.pageUrl) == true) { - binding.progress.visibility = VISIBLE + binding.lunaticBrowser.binding.internalProgressBar.visibility = VISIBLE binding.pagedLayer.postDelayed({ - binding.progress.visibility = GONE + binding.lunaticBrowser.binding.internalProgressBar.visibility = GONE }, 1000) } applyReaderConfig() activity?.runOnUiThread { view.text = contents view.visibility = VISIBLE - binding.menuWeb.visibility = GONE + binding.lunaticBrowser.visibility = GONE } // view.forceUpdateUI() lastedUrl?.let { @@ -1139,7 +1134,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { HistoryManager.getBooPageInfoContentsSave(contentsType,it, contents) } if (saveContinuation) { - binding.menuWeb.postDelayed({moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)}, 10000) + binding.lunaticBrowser.geckoWeb.postDelayed({moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)}, 10000) } } @@ -1155,8 +1150,8 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { var currentChapter: Int = 0 fun onFindTitle(contents: String) { - binding.textviewTitle.text = contents - binding.textviewTitle.setOnClickListener { + binding.lunaticBrowser.binding.tvTitle.text = contents + binding.lunaticBrowser.binding.tvTitle.setOnClickListener { val builder = AlertDialog.Builder(requireContext()) builder.setTitle("Title") val input = EditText(requireContext()) @@ -1196,7 +1191,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } fun onStartLoad() { - binding.progress.visibility = VISIBLE + binding.lunaticBrowser.binding.internalProgressBar.visibility = VISIBLE } fun completePageLoad(lastInfo: LastInfo) { @@ -1305,6 +1300,6 @@ class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { } private fun goToHome() { - binding.menuWeb.loadUrl(getLastedDoamin()) + binding.lunaticBrowser.geckoWeb.loadUrl(getLastedDoamin()) } } \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/settings/SettingsActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/settings/SettingsActivity.kt index 414c11a9..f3ea9a23 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/settings/SettingsActivity.kt @@ -23,6 +23,8 @@ import android.content.SharedPreferences import android.content.res.Resources import android.os.Bundle import android.os.Environment +import android.widget.Toast +import androidx.lifecycle.lifecycleScope import bums.lunatic.launcher.BuildConfig import bums.lunatic.launcher.R import bums.lunatic.launcher.common.CommonActivity @@ -39,6 +41,10 @@ import bums.lunatic.launcher.settings.childs.HomeSettings import bums.lunatic.launcher.settings.childs.Misc import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.workers.WorkersDb +import kr.gdrive.bums.lunatic.utils.BackupPayload +import kr.gdrive.bums.lunatic.utils.GDriveBackupTask +import kr.gdrive.bums.lunatic.utils.GDriveLoginManager +import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.color.DynamicColors import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -46,7 +52,11 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.realm.kotlin.UpdatePolicy import io.realm.kotlin.ext.query +import kotlinx.coroutines.launch import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlin.system.exitProcess @@ -55,6 +65,9 @@ internal class SettingsActivity : CommonActivity() { private lateinit var binding: SettingsActivityBinding private val sourceCode = "https://github.com/iamrasel/lunar-launcher" + // 전역 변수로 매니저 선언 (콜백 처리) + private lateinit var loginManager: GDriveLoginManager + companion object { @JvmStatic var settingsPrefs: SharedPreferences? = null } @@ -70,6 +83,49 @@ internal class SettingsActivity : CommonActivity() { settingsPrefs = this.getSharedPreferences(PREFS_SETTINGS, 0) + loginManager = GDriveLoginManager(this) { isSuccess, account, errorMsg -> + if (isSuccess && account != null) { + updateUiForLoggedIn(account.email ?: "사용자") + } else { + updateUiForLoggedOut() + Blog.LOGE("로그인 에러: $errorMsg") + } + } + + val currentAccount = loginManager.getSignedInAccount() + if (currentAccount != null) { + updateUiForLoggedIn(currentAccount.email ?: "사용자") + } else { + updateUiForLoggedOut() + } + binding.btnGoogleLogin.setOnClickListener { + if (loginManager.getSignedInAccount() == null) { + loginManager.signIn() + } else { + loginManager.signOut { updateUiForLoggedOut() } + } + } + + // 2) 수동 백업 버튼 + binding.btnManualBackup.setOnClickListener { + val account = loginManager.getSignedInAccount() + if (account != null) { + performManualBackup(account) + } else { + Toast.makeText(this, "먼저 구글에 로그인해주세요.", Toast.LENGTH_SHORT).show() + } + } + + + binding.switchAutoBackup.setOnCheckedChangeListener { _, isChecked -> + if (isChecked && loginManager.getSignedInAccount() == null) { + Toast.makeText(this, "자동 백업을 켜려면 구글 로그인이 필요합니다.", Toast.LENGTH_SHORT).show() + binding.switchAutoBackup.isChecked = false + return@setOnCheckedChangeListener + } + // TODO: isChecked 값을 SharedPreference에 저장 + } + /* launch child settings dialogs on button clicks */ // binding.timeDate.setOnClickListener { // TopInfos().show(supportFragmentManager, BOTTOM_SHEET_TAG) @@ -119,6 +175,46 @@ internal class SettingsActivity : CommonActivity() { } } + private fun updateUiForLoggedIn(email: String) { + binding.btnGoogleLogin.text = "연결 해제 ($email)" + binding.btnManualBackup.isEnabled = true + binding.switchAutoBackup.isEnabled = true + } + + private fun updateUiForLoggedOut() { + binding.btnGoogleLogin.text = "구글 드라이브 연결" + binding.btnManualBackup.isEnabled = false + binding.switchAutoBackup.isChecked = false + binding.switchAutoBackup.isEnabled = false + } + + private fun performManualBackup(account: GoogleSignInAccount) { + android.widget.Toast.makeText(this, "백업을 시작합니다...", Toast.LENGTH_SHORT).show() + binding.btnManualBackup.isEnabled = false + + lifecycleScope.launch { + // DB 데이터 추출 (앱, 연락처 등) + val appsJson = "..." // WorkersDb에서 가져오기 + val payload = BackupPayload( + manifestJson = """{"version": ${WorkersDb.schemaVersion}}""", + folderName = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()), + files = mapOf("apps_info.json" to appsJson) + ) + + // 백업 실행 + val backupTask = GDriveBackupTask(this@SettingsActivity, account) + val result = backupTask.executeBackup(payload) + + result.onSuccess { msg -> + Toast.makeText(this@SettingsActivity, msg, Toast.LENGTH_LONG).show() + }.onFailure { + Toast.makeText(this@SettingsActivity, "백업 실패", Toast.LENGTH_LONG).show() + } + + binding.btnManualBackup.isEnabled = true + } + } + var cancelCount = Runnable{ clickCount = 0 } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt b/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt index 76246fb1..7468d0db 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt @@ -25,6 +25,7 @@ import android.view.animation.Interpolator import android.view.animation.OvershootInterpolator import android.widget.ImageView import android.widget.ImageView.ScaleType +import android.widget.TextView import bums.lunatic.launcher.R import bums.lunatic.launcher.utils.CommonUtils @@ -85,7 +86,7 @@ class FloatingActionMenu @JvmOverloads constructor( private var mLabelsStyle = 0 private var mCustomTypefaceFromFont: Typeface? = null var isIconAnimated: Boolean = true - private var mImageToggle: ImageView? = null + private var mToggleView: View? = null private var mMenuButtonShowAnimation: Animation? = null private var mMenuButtonHideAnimation: Animation? = null private var mImageToggleShowAnimation: Animation? = null @@ -198,7 +199,7 @@ class FloatingActionMenu @JvmOverloads constructor( attr.getInt(R.styleable.FloatingActionMenu_menu_animationDelayPerItem, 50) mIcon = attr.getDrawable(R.styleable.FloatingActionMenu_menu_icon) if (mIcon == null) { - mIcon = getResources().getDrawable(R.drawable.fab_add) +// mIcon = getResources().getDrawable(R.drawable.fab_add) } mLabelsSingleLine = attr.getBoolean(R.styleable.FloatingActionMenu_menu_labels_singleLine, false) @@ -314,15 +315,21 @@ class FloatingActionMenu @JvmOverloads constructor( mMenuButton!!.mFabSize = mMenuFabSize mMenuButton!!.updateBackground() mMenuButton!!.labelText = mMenuLabelText - - mImageToggle = ImageView(getContext()) - mImageToggle?.scaleType = ScaleType.FIT_CENTER - mImageToggle?.adjustViewBounds = true - mImageToggle!!.setImageDrawable(mIcon) - addView(mMenuButton, super.generateDefaultLayoutParams()) - addView(mImageToggle) + if (mIcon != null) { + var imge = ImageView(getContext()) + imge?.scaleType = ScaleType.FIT_CENTER + imge?.adjustViewBounds = true + imge?.setImageDrawable(mIcon) + mToggleView = imge + } else { + var v = TextView(getContext()) + v?.text = mMenuLabelText + v?.setTextSize(TypedValue.COMPLEX_UNIT_PX, mLabelsTextSize) + mToggleView = v + } + addView(mToggleView) createDefaultIconAnimation() } @@ -342,14 +349,14 @@ class FloatingActionMenu @JvmOverloads constructor( } val collapseAnimator = ObjectAnimator.ofFloat( - mImageToggle, + mToggleView, "rotation", collapseAngle, CLOSED_PLUS_ROTATION ) val expandAnimator = ObjectAnimator.ofFloat( - mImageToggle, + mToggleView, "rotation", CLOSED_PLUS_ROTATION, expandAngle @@ -371,12 +378,12 @@ class FloatingActionMenu @JvmOverloads constructor( mMaxButtonWidth = 0 var maxLabelWidth = 0 - measureChildWithMargins(mImageToggle, widthMeasureSpec, 0, heightMeasureSpec, 0) + measureChildWithMargins(mToggleView, widthMeasureSpec, 0, heightMeasureSpec, 0) for (i in 0.. r - l - mMaxButtonWidth / 2 - paddingRight + else -> mMaxButtonWidth / 2 + paddingLeft + } + + val openUp = mOpenDirection == OPEN_UP + val menuButtonTop = if (openUp) b - t - mMenuButton!!.measuredHeight - paddingBottom else paddingTop + val menuButtonLeft = buttonsHorizontalCenter - mMenuButton!!.measuredWidth / 2 + + if (mMenuButton!!.left == 0 && mMenuButton!!.top == 0) { + mMenuButton!!.layout(menuButtonLeft, menuButtonTop, menuButtonLeft + mMenuButton!!.measuredWidth, menuButtonTop + mMenuButton!!.measuredHeight) + } + + // 💡 화면의 너비(r - l)와 높이(b - t)를 함께 넘겨줍니다. + layoutChildrenRelative(r - l, b - t) } - private fun doLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - val buttonsHorizontalCenter = if (mLabelsPosition == LABELS_POSITION_LEFT) - r - l - mMaxButtonWidth / 2 - getPaddingRight() - else - mMaxButtonWidth / 2 + getPaddingLeft() - val openUp = mOpenDirection == OPEN_UP + private fun moveElements(v: View?, SetX: Float, SetY: Float, oldX: Float, oldY: Float) { + val parentView = (parent as ViewGroup).parent as View + // 💡 드래그 중에도 부모의 너비와 높이를 계산해 넘겨줍니다. + layoutChildrenRelative(parentView.width, parentView.height) + } - val menuButtonTop = if (openUp) - b - t - mMenuButton!!.getMeasuredHeight() - getPaddingBottom() - else - getPaddingTop() - val menuButtonLeft = buttonsHorizontalCenter - mMenuButton!!.getMeasuredWidth() / 2 + // 파라미터에 parentHeight 추가 + private fun layoutChildrenRelative(parentWidth: Int, parentHeight: Int) { + val menuL = mMenuButton!!.x.toInt() + val menuT = mMenuButton!!.y.toInt() + val menuW = mMenuButton!!.measuredWidth + val menuH = mMenuButton!!.measuredHeight + val buttonsCenterX = menuL + menuW / 2 + val buttonsCenterY = menuT + menuH / 2 - mMenuButton!!.layout( - menuButtonLeft, menuButtonTop, menuButtonLeft + mMenuButton!!.getMeasuredWidth(), - menuButtonTop + mMenuButton!!.getMeasuredHeight() - ) + // 💡 [핵심 복원] 메인 버튼이 화면 하단부에 있으면 위로(openUp = true), 상단부에 있으면 아래로 엽니다. + val openUp = buttonsCenterY > (parentHeight / 2) - val imageLeft = buttonsHorizontalCenter - mImageToggle!!.getMeasuredWidth() / 2 - val imageTop = - menuButtonTop + mMenuButton!!.getMeasuredHeight() / 2 - mImageToggle!!.getMeasuredHeight() / 2 + // 1. 토글 아이콘 배치 + val imageLeft = buttonsCenterX - mToggleView!!.measuredWidth / 2 + val imageTop = menuT + menuH / 2 - mToggleView!!.measuredHeight / 2 + mToggleView!!.layout(imageLeft, imageTop, imageLeft + mToggleView!!.measuredWidth, imageTop + mToggleView!!.measuredHeight) + mToggleView!!.translationX = 0f + mToggleView!!.translationY = 0f - mImageToggle!!.layout( - imageLeft, imageTop, imageLeft + mImageToggle!!.getMeasuredWidth(), - imageTop + mImageToggle!!.getMeasuredHeight() - ) + val isMenuOnLeft = buttonsCenterX < parentWidth / 2 - var nextY = if (openUp) - menuButtonTop + mMenuButton!!.getMeasuredHeight() + mButtonSpacing - else - menuButtonTop + // 💡 시작 높이: 위로 열릴 땐 메인 버튼의 Top, 아래로 열릴 땐 메인 버튼의 Bottom + var nextY = if (openUp) menuT else menuT + menuH for (i in mButtonsCount - 1 downTo 0) { val child = getChildAt(i) - - if (child === mImageToggle) continue - + if (child === mToggleView) continue val fab = child as FloatingActionButton - - if (fab.getVisibility() == GONE) continue - - val childX = buttonsHorizontalCenter - fab.getMeasuredWidth() / 2 - val childY = if (openUp) nextY - fab.getMeasuredHeight() - mButtonSpacing else nextY + if (fab.visibility == GONE) continue if (fab != mMenuButton) { - fab.layout( - childX, childY, childX + fab.getMeasuredWidth(), - childY + fab.getMeasuredHeight() - ) + val childX = buttonsCenterX - fab.measuredWidth / 2 - if (!mIsMenuOpening) { - fab.hide(false) + // 💡 상/하 전개 방향에 맞게 Y 좌표 계산 + val childY = if (openUp) nextY - mButtonSpacing - fab.measuredHeight else nextY + mButtonSpacing + + fab.layout(childX, childY, childX + fab.measuredWidth, childY + fab.measuredHeight) + fab.translationX = 0f + fab.translationY = 0f + + if (!mIsMenuOpening) fab.hide(false) + + // 2. 라벨 배치 + val label = fab.getTag(R.id.fab_label) as View? + if (label != null) { + val labelL: Int + val labelT: Int + + if (mLabelsPosition == LABELS_POSITION_CENTER) { + labelL = childX + (fab.measuredWidth - label.measuredWidth) / 2 + labelT = childY + (fab.measuredHeight - label.measuredHeight) / 2 + } else { + val actualSide = if (isMenuOnLeft) LABELS_POSITION_RIGHT else LABELS_POSITION_LEFT + val offset = (if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.measuredWidth / 2) + mLabelsMargin + + labelL = if (actualSide == LABELS_POSITION_LEFT) buttonsCenterX - offset - label.measuredWidth else buttonsCenterX + offset + labelT = childY - mLabelsVerticalOffset + (fab.measuredHeight - label.measuredHeight) / 2 + } + + label.layout(labelL, labelT, labelL + label.measuredWidth, labelT + label.measuredHeight) + label.translationX = 0f + label.translationY = 0f + + if (!mIsMenuOpening) label.visibility = INVISIBLE } + + // 3. 다음 뷰를 위한 Y 좌표 누적 갱신 + nextY = if (openUp) childY else childY + fab.measuredHeight } - - val label = fab.getTag(R.id.fab_label) as View? - if (label != null) { - val labelLeft: Int - val labelRight: Int - val labelTop: Int - - // 💡 [수정] Center 옵션일 경우 X,Y축 모두 버튼의 중앙으로 계산 - if (mLabelsPosition == LABELS_POSITION_CENTER) { - labelLeft = childX + (fab.getMeasuredWidth() - label.getMeasuredWidth()) / 2 - labelRight = labelLeft + label.getMeasuredWidth() - labelTop = childY + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2 - } else { - val labelsOffset = - (if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.getMeasuredWidth() / 2) + mLabelsMargin - val labelXNearButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - buttonsHorizontalCenter - labelsOffset - else - buttonsHorizontalCenter + labelsOffset - - val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - label.getMeasuredWidth() - else - labelXNearButton + label.getMeasuredWidth() - - labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXAwayFromButton - else - labelXNearButton - - labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - else - labelXAwayFromButton - - labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2 - } - - label.layout(labelLeft, labelTop, labelRight, labelTop + label.getMeasuredHeight()) - - if (!mIsMenuOpening) { - label.setVisibility(INVISIBLE) - } - } - - nextY = if (openUp) - childY - mButtonSpacing - else - childY + child.getMeasuredHeight() + mButtonSpacing } } @@ -555,14 +548,14 @@ class FloatingActionMenu @JvmOverloads constructor( override fun onFinishInflate() { super.onFinishInflate() bringChildToFront(mMenuButton) - bringChildToFront(mImageToggle) + bringChildToFront(mToggleView) mButtonsCount = getChildCount() createLabels() } private fun createLabels() { for (i in 0.. { - dX = v.getX() - event.getRawX() - dY = v.getY() - event.getRawY() - startX = event.getRawX() - startY = event.getRawY() - lastAction = MotionEvent.ACTION_DOWN + dX = v.x - event.rawX + dY = v.y - event.rawY + startX = event.rawX + startY = event.rawY } MotionEvent.ACTION_MOVE -> { - if (!isDragMenuDisabled and (checkViewLimits( - v, - (event.getRawX() + dX).toInt(), - (event.getRawY() + dY).toInt() - )) - ) { - v.setY(event.getRawY() + dY) - v.setX(event.getRawX() + dX) + if (!isDragMenuDisabled) { + // 1. 드래그하려는 목표 좌표 계산 + var newX = event.rawX + dX + var newY = event.rawY + dY - moveElements( - v, - event.getRawX() + dX, - event.getRawY() + dY, - oldX, - oldY - ) + // 2. 화면 전체 너비와 높이 가져오기 (현재 FloatingActionMenu가 match_parent이므로 자체 크기 사용) + val menuWidth = this@FloatingActionMenu.width + val menuHeight = this@FloatingActionMenu.height + + // 3. 화면을 벗어나지 않도록 좌표 가두기 (패딩 포함) + val minX = paddingLeft.toFloat() + val maxX = (menuWidth - v.width - paddingRight).toFloat() + + val minY = paddingTop.toFloat() + val maxY = (menuHeight - v.height - paddingBottom).toFloat() + + // coerceIn 함수를 사용하여 최소~최대 값 사이로 강제 보정 + newX = newX.coerceIn(minX, maxX) + newY = newY.coerceIn(minY, maxY) + + // 4. 보정된 좌표로 뷰 이동 + v.x = newX + v.y = newY + + // 5. 하위 메뉴들도 바뀐 메인 버튼 위치에 맞춰 다시 그리기 + moveElements(v, newX, newY, oldX, oldY) } - lastAction = MotionEvent.ACTION_MOVE } - MotionEvent.ACTION_UP -> if (abs((startX - event.getRawX()).toDouble()) < 10 && abs( - (startY - event.getRawY()).toDouble() - ) < 10 - ) { - toggle(mIsAnimated) + MotionEvent.ACTION_UP -> { + // 드래그가 아니라 단순 클릭(오차 10픽셀 이내)이었다면 메뉴 토글 + if (abs(startX - event.rawX) < 10 && abs(startY - event.rawY) < 10) { + toggle(mIsAnimated) + } } - else -> return false } return true @@ -650,113 +651,6 @@ class FloatingActionMenu @JvmOverloads constructor( } } - private fun moveElements(v: View?, SetX: Float, SetY: Float, oldX: Float, oldY: Float) { - val parent = (this.getParent() as ViewGroup).getParent() as View - - val l = mMenuButton!!.getX().toInt() - val t = mMenuButton!!.getY().toInt() - val r = l + mMenuButton!!.getMeasuredWidth() - val b = t + mMenuButton!!.getMeasuredHeight() - - val mMenuH = mMenuButton!!.getMeasuredHeight() - - val halfHeight = parent.getHeight() / 2 - - val buttonsHorizontalCenter = (r - l) / 2 - val openUp = halfHeight < (t) - - var nextY = if (openUp) t - getPaddingTop() else t + mMenuH - var fab: Any? - - for (i in mButtonsCount - 1 downTo 0) { - val child = getChildAt(i) - if (child.getVisibility() == GONE) continue - - val childX: Int - val childY: Int - - if (child === mImageToggle) { - val imageLeft = l + buttonsHorizontalCenter - mImageToggle!!.getMeasuredWidth() / 2 - val imageTop = - t + mMenuButton!!.getMeasuredHeight() / 2 - mImageToggle!!.getMeasuredHeight() / 2 - child.layout( - imageLeft, - imageTop, - l + mImageToggle!!.getMeasuredWidth() * 2, - t + mImageToggle!!.getMeasuredHeight() * 2 - ) - } else { - childX = l + buttonsHorizontalCenter - child.getMeasuredWidth() / 2 - childY = - if (openUp) nextY - child.getMeasuredHeight() - mButtonSpacing else nextY + mButtonSpacing - - fab = child as FloatingActionButton - if (fab === mMenuButton) continue - - child.layout( - childX, childY, childX + child.getMeasuredWidth(), - childY + child.getMeasuredHeight() - ) - - if (!mIsMenuOpening) { - if (child is FloatingActionButton) fab.hide(false) - } - - var label: View? = null - if (child !== mImageToggle) label = fab.getTag(R.id.fab_label) as View? - - if (label != null) { - val labelLeft: Int - val labelRight: Int - val labelTop: Int - - // 💡 [수정] Center 옵션일 경우 드래그 중에도 중앙으로 정렬 - if (mLabelsPosition == LABELS_POSITION_CENTER) { - labelLeft = childX + (fab.getMeasuredWidth() - label.getMeasuredWidth()) / 2 - labelRight = labelLeft + label.getMeasuredWidth() - labelTop = childY + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2 - } else { - val labelsOffset = - (if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.getMeasuredWidth() / 2) + mLabelsMargin - val labelXNearButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - l + buttonsHorizontalCenter - labelsOffset - else - l + buttonsHorizontalCenter + labelsOffset - - val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - label.getMeasuredWidth() - else - labelXNearButton + label.getMeasuredWidth() - - labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXAwayFromButton - else - labelXNearButton - - labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - else - labelXAwayFromButton - - labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2 - } - - label.layout( - labelLeft, - labelTop, - labelRight, - labelTop + label.getMeasuredHeight() - ) - - if (!mIsMenuOpening) { - label.setVisibility(INVISIBLE) - } - } - nextY = - if (openUp) childY - mButtonSpacing else childY + fab.getMeasuredHeight() + mButtonSpacing - } - } - } private fun addLabel(fab: FloatingActionButton) { val text = fab.labelText @@ -821,7 +715,8 @@ class FloatingActionMenu @JvmOverloads constructor( label.setTypeface(mCustomTypefaceFromFont) } label.setText(text) - label.setOnClickListener(fab.getOnClickListener()) + label.setOnClickListener {fab.performClick()} + addView(label) fab.setTag(R.id.fab_label, label) @@ -859,9 +754,9 @@ class FloatingActionMenu @JvmOverloads constructor( if (!this.isMenuButtonHidden) { mMenuButton!!.hide(animate) if (animate) { - mImageToggle!!.startAnimation(mImageToggleHideAnimation) + mToggleView!!.startAnimation(mImageToggleHideAnimation) } - mImageToggle!!.setVisibility(INVISIBLE) + mToggleView!!.setVisibility(INVISIBLE) mIsMenuButtonAnimationRunning = false } } @@ -870,9 +765,9 @@ class FloatingActionMenu @JvmOverloads constructor( if (this.isMenuButtonHidden) { mMenuButton!!.show(animate) if (animate) { - mImageToggle!!.startAnimation(mImageToggleShowAnimation) + mToggleView!!.startAnimation(mImageToggleShowAnimation) } - mImageToggle!!.setVisibility(VISIBLE) + mToggleView!!.setVisibility(VISIBLE) } } @@ -1055,8 +950,8 @@ class FloatingActionMenu @JvmOverloads constructor( mToggleListener = listener } - val menuIconView: ImageView - get() = mImageToggle!! +// val menuIconView: ImageView +// get() = mToggleView!! fun setMenuButtonShowAnimation(showAnimation: Animation) { mMenuButtonShowAnimation = showAnimation @@ -1239,7 +1134,7 @@ class FloatingActionMenu @JvmOverloads constructor( val viewsToRemove: MutableList = ArrayList() for (i in 0.. 5) { + val intent = Intent(Intent.ACTION_SEND).apply { + (binding.tvAddress.tag as? String)?.let { putExtra(Intent.EXTRA_TITLE, it) } + putExtra(Intent.EXTRA_TEXT, addr) + type = "text/plain" + } + context.startActivity(Intent.createChooser(intent, "공유")) + } + } + + // GeckoWeb 내부에서 주소 등 업데이트 시 연동되도록 등록 + geckoWeb.decoViews.addAll(listOf( + binding.tvAddress, binding.btnDlVideo, binding.btnReload, binding.btnBack, binding.tvTitle + )) + } + + fun setupForRssHome() { + binding.btnBookmark.isVisible = true + binding.btnSearch.isVisible = true + binding.btnHide.isVisible = true + binding.btnVote.isVisible = true + + binding.btnList.isVisible = false + binding.btnHistory.isVisible = false + binding.btnSetting.isVisible = false + } + + fun setupForToki() { + binding.btnBookmark.isVisible = false + binding.btnSearch.isVisible = false + binding.btnHide.isVisible = false + binding.btnVote.isVisible = false + + binding.btnList.isVisible = true + binding.btnHistory.isVisible = true + binding.btnSetting.isVisible = true + } + + // 💡 소설 뷰어 모드로 전환 시, 웹뷰와 하단바를 숨기고 상단 타이틀바만 남기기 위한 헬퍼 + fun showBrowserAreaOnly(isVisible: Boolean) { + binding.internalGeckoWeb.isVisible = isVisible + binding.layoutBottomBar.isVisible = isVisible + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt index 8614de83..59d7506f 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt @@ -67,13 +67,15 @@ object WorkersDb { //RecentCall::class, RecentSms::class, val clazz : Set> = setOf( - RssData::class, NotificationItem::class, AppInfo::class,SimpleContact::class, CurrentPlayItem::class, - TelegramBotUpdate::class, TelegramData::class, TelegramMessage::class, TelegramChat::class, BotCommandEentitie::class, TelegramFrom::class, - WeatherForcast::class, Location::class, Current::class, Forecast::class, Condition::class, Forecastday::class, Day::class, Astro::class, Hour::class, + RssData::class, AppInfo::class, SimpleContact::class, + NotificationItem::class, + CurrentPlayItem::class, +// TelegramBotUpdate::class, TelegramData::class, TelegramMessage::class, TelegramChat::class, BotCommandEentitie::class, TelegramFrom::class, +// WeatherForcast::class, Location::class, Current::class, Forecast::class, Condition::class, Forecastday::class, Day::class, Astro::class, Hour::class, LocationLog::class, LastInfo::class, HistoryItem::class, ReaderConfig::class, ContentsCollection::class, ContentsPageInfo::class, - WidgetData::class,AppUsageLog::class, - + AppUsageLog::class, + WidgetData::class, ) //,UserActionModel::class diff --git a/app/src/main/res/font/material_symbols.ttf b/app/src/main/res/font/material_symbols.ttf new file mode 100644 index 00000000..a4b7a0a1 Binary files /dev/null and b/app/src/main/res/font/material_symbols.ttf differ diff --git a/app/src/main/res/layout/app_menu.xml b/app/src/main/res/layout/app_menu.xml index fee72d2d..3f85a4a0 100644 --- a/app/src/main/res/layout/app_menu.xml +++ b/app/src/main/res/layout/app_menu.xml @@ -70,7 +70,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/eight" - app:layout_constraintTop_toBottomOf="@id/totalTouch" + app:layout_constraintTop_toBottomOf="@id/recommend" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"/> - - - - - - - - - - - - - - + android:layout_height="match_parent" /> - - diff --git a/app/src/main/res/layout/fragment_system_status.xml b/app/src/main/res/layout/fragment_system_status.xml index 36904ec6..2806202e 100644 --- a/app/src/main/res/layout/fragment_system_status.xml +++ b/app/src/main/res/layout/fragment_system_status.xml @@ -3,13 +3,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" - android:background="#E6121212"> + android:background="?android:attr/windowBackground"> + android:padding="12dp"> + android:weightSum="5"> + + - + - + - + + - - - + + + android:scrollbars="none"/> - - + android:layout_height="match_parent" + android:visibility="gone" /> - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_lunatic_browser.xml b/app/src/main/res/layout/layout_lunatic_browser.xml new file mode 100644 index 00000000..2da79f71 --- /dev/null +++ b/app/src/main/res/layout/layout_lunatic_browser.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/layout_textviewer.xml b/app/src/main/res/layout/layout_textviewer.xml index b26241b8..7dff3cce 100644 --- a/app/src/main/res/layout/layout_textviewer.xml +++ b/app/src/main/res/layout/layout_textviewer.xml @@ -36,11 +36,13 @@ - - - - - - + app:layout_constraintEnd_toEndOf="parent" /> - - - - + app:fab_label="📰" + style="@style/CommonFabStyle" + android:id="@+id/feeds"/> + app:fab_label="📚" + style="@style/CommonFabStyle" + android:id="@+id/books"/> + app:fab_label="🎨" + style="@style/CommonFabStyle" + android:id="@+id/webtoons"/> + app:fab_label="🗯️" + style="@style/CommonFabStyle" + android:id="@+id/comics"/> + app:fab_label="📺" + style="@style/CommonFabStyle" + android:id="@+id/youtube"/> + app:fab_label="🤖" + style="@style/CommonFabStyle" + android:id="@+id/perplexity"/> + app:fab_label="😂" + style="@style/CommonFabStyle" + android:id="@+id/zzalbang"/> + app:fab_label="🐦" + style="@style/CommonFabStyle" + android:id="@+id/btn_x"/> + app:fab_label="🔞" + style="@style/CommonFabStyle" + android:id="@+id/btn_i"/> + /> + /> + style="@style/CommonFabStyle" + app:fab_label="❌" + android:id="@+id/close"/> diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml index c784e171..ff83661d 100644 --- a/app/src/main/res/layout/settings_activity.xml +++ b/app/src/main/res/layout/settings_activity.xml @@ -29,107 +29,127 @@ android:background="@drawable/rounded_bg_top" android:backgroundTint="@color/black" android:paddingHorizontal="@dimen/thirtySix" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/version" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/appBar"> - + android:paddingTop="@dimen/twelve" + android:paddingBottom="@dimen/thirtySix"> - - - - - - - - + - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:enabled="false" + android:text="자동 백업 허용 (주 1회)" + android:textColor="@color/white" + android:textStyle="bold" + app:switchPadding="16dp" /> - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 58f69065..b187535a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -5,6 +5,11 @@ @drawable/ic_launcher 1000 @style/Theme.LunarLauncher + true + false + + true + @android:color/transparent + + + + + \ No newline at end of file diff --git a/gdrive/.gitignore b/gdrive/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/gdrive/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/gdrive/build.gradle.kts b/gdrive/build.gradle.kts new file mode 100644 index 00000000..2a7631ef --- /dev/null +++ b/gdrive/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id ("com.android.library") + id ("kotlin-android") +} + +android { + namespace = "kr.bums.lunatic.utils.gdrive" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.activity:activity-ktx:1.8.2") // ActivityResultLauncher용 + + // 구글 로그인 + implementation("com.google.android.gms:play-services-auth:20.7.0") + + // 구글 드라이브 API + implementation("com.google.api-client:google-api-client-android:1.33.0") { + exclude(group = "org.apache.httpcomponents") + } + implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") { + exclude(group = "org.apache.httpcomponents") + } +// implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("com.google.android.material:material:1.13.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") +} \ No newline at end of file diff --git a/gdrive/proguard-rules.pro b/gdrive/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/gdrive/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/gdrive/src/androidTest/java/bums/lunatic/utils/gdrive/ExampleInstrumentedTest.kt b/gdrive/src/androidTest/java/bums/lunatic/utils/gdrive/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..42d31a2e --- /dev/null +++ b/gdrive/src/androidTest/java/bums/lunatic/utils/gdrive/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package bums.lunatic.utils.gdrive + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("bums.lunatic.utils.gdrive", appContext.packageName) + } +} \ No newline at end of file diff --git a/gdrive/src/main/AndroidManifest.xml b/gdrive/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1b4f05b9 --- /dev/null +++ b/gdrive/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveBackupTask.kt b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveBackupTask.kt new file mode 100644 index 00000000..9de1d88c --- /dev/null +++ b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveBackupTask.kt @@ -0,0 +1,76 @@ +package kr.gdrive.bums.lunatic.utils + +import android.content.Context +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GDriveBackupTask( + private val context: Context, + private val account: GoogleSignInAccount +) { + // 💡 백업 실행 (IO 스레드에서 돌아야 함) + suspend fun executeBackup(payload: BackupPayload): Result = withContext(Dispatchers.IO) { + try { + val credential = GoogleAccountCredential.usingOAuth2(context, listOf(DriveScopes.DRIVE_APPDATA)) + credential.selectedAccount = account.account + + val driveService = Drive.Builder( + NetHttpTransport(), GsonFactory.getDefaultInstance(), credential + ).setApplicationName("LunaticLauncherBackup").build() + + // 1. 매니페스트 업데이트 + uploadFile(driveService, "appDataFolder", "manifest.json", payload.manifestJson) + + // 2. 폴더 가져오기 or 생성 + val folderId = getOrCreateFolder(driveService, payload.folderName) + + // 3. 파일들 업로드 + payload.files.forEach { (fileName, jsonContent) -> + uploadFile(driveService, folderId, fileName, jsonContent) + } + + Result.success("백업이 완료되었습니다. (${payload.folderName})") + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } + } + + private fun getOrCreateFolder(driveService: Drive, folderName: String): String { + val query = "mimeType='application/vnd.google-apps.folder' and name='$folderName' and 'appDataFolder' in parents and trashed=false" + val fileList = driveService.files().list().setSpaces("appDataFolder").setQ(query).execute() + + if (fileList.files.isNotEmpty()) return fileList.files[0].id + + val folderMetadata = File().apply { + name = folderName + mimeType = "application/vnd.google-apps.folder" + parents = listOf("appDataFolder") + } + return driveService.files().create(folderMetadata).setFields("id").execute().id + } + + private fun uploadFile(driveService: Drive, parentId: String, fileName: String, contentStr: String) { + val content = ByteArrayContent.fromString("application/json", contentStr) + val query = "name='$fileName' and '$parentId' in parents and trashed=false" + val fileList = driveService.files().list().setSpaces("appDataFolder").setQ(query).execute() + + if (fileList.files.isNotEmpty()) { + driveService.files().update(fileList.files[0].id, null, content).execute() + } else { + val fileMetadata = File().apply { + name = fileName + parents = listOf(parentId) + } + driveService.files().create(fileMetadata, content).execute() + } + } +} \ No newline at end of file diff --git a/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveLoginManager.kt b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveLoginManager.kt new file mode 100644 index 00000000..36ad920a --- /dev/null +++ b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveLoginManager.kt @@ -0,0 +1,79 @@ +package kr.gdrive.bums.lunatic.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import com.google.api.services.drive.DriveScopes + +class GDriveLoginManager( + private val activity: ComponentActivity, + private val onLoginResult: (Boolean, GoogleSignInAccount?, String?) -> Unit +) { + // 필수 권한: 숨겨진 App Data 폴더 접근 권한 + private val driveScope = Scope(DriveScopes.DRIVE_APPDATA) + + private val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(driveScope) + .build() + + private val signInClient = GoogleSignIn.getClient(activity, signInOptions) + + // 액티비티 생성 시점에 등록되어야 하는 런처 + private val signInLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(Exception::class.java) + if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) { + onLoginResult(true, account, null) + } else { + onLoginResult(false, null, "드라이브 접근 권한이 부족합니다.") + } + } catch (e: Exception) { + onLoginResult(false, null, "로그인 실패: ${e.message}") + } + } else { + onLoginResult(false, null, "로그인이 취소되었습니다.") + } + } + + /** + * 이미 로그인된 유효한 계정이 있는지 확인 (백그라운드에서 유용함) + */ + fun getSignedInAccount(context: Context = activity): GoogleSignInAccount? { + val account = GoogleSignIn.getLastSignedInAccount(context) + return if (account != null && GoogleSignIn.hasPermissions(account, driveScope)) { + account + } else { + null + } + } + + /** + * 로그인 화면 띄우기 + */ + fun signIn() { + val account = getSignedInAccount() + if (account != null) { + onLoginResult(true, account, null) + } else { + signInLauncher.launch(signInClient.signInIntent) + } + } + + /** + * 로그아웃 (연결 해제) + */ + fun signOut(onComplete: () -> Unit) { + signInClient.signOut().addOnCompleteListener { onComplete() } + } +} \ No newline at end of file diff --git a/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveModels.kt b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveModels.kt new file mode 100644 index 00000000..df078763 --- /dev/null +++ b/gdrive/src/main/java/kr/gdrive/bums/lunatic/utils/GDriveModels.kt @@ -0,0 +1,16 @@ +package kr.gdrive.bums.lunatic.utils + +sealed class GDriveState { + object Idle : GDriveState() + object StartingLogin : GDriveState() + object Uploading : GDriveState() + class Success(val message: String) : GDriveState() + class Error(val message: String, val exception: Exception? = null) : GDriveState() +} + +// 메인 앱에서 백업할 데이터를 담아 보낼 데이터 클래스 +data class BackupPayload( + val manifestJson: String, // 최상단에 저장될 버전 정보 + val folderName: String, // 예: "2026-03-05" (날짜 폴더명) + val files: Map // 파일명(키)과 JSON 텍스트(값)의 쌍 +) \ No newline at end of file diff --git a/gdrive/src/test/java/bums/lunatic/utils/gdrive/ExampleUnitTest.kt b/gdrive/src/test/java/bums/lunatic/utils/gdrive/ExampleUnitTest.kt new file mode 100644 index 00000000..f309dab8 --- /dev/null +++ b/gdrive/src/test/java/bums/lunatic/utils/gdrive/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package bums.lunatic.utils.gdrive + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index cbc74b3a..ca960754 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,8 +3,8 @@ pluginManagement { gradlePluginPortal() google() mavenCentral() -// jcenter() maven (url = "https://maven.mozilla.org/maven2/") + } } @@ -17,8 +17,10 @@ dependencyResolutionManagement { mavenCentral() maven (url = "https://maven.mozilla.org/maven2/") maven(url = "https://jitpack.io") + } } rootProject.name = "LunarLauncher" include ("app","library","utils") +include(":gdrive")