From 614e244be726b7ce939aef587ebca916a0880278 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 4 Mar 2026 16:39:49 +0900 Subject: [PATCH] ... --- .../extensions/my_extension/messaging.js | 39 +- .../bums/lunatic/launcher/LauncherActivity.kt | 20 +- .../launcher/apps/AppDrawerBottomSheet.kt | 19 +- .../launcher/helpers/ForeGroundService.kt | 117 +++- .../bums/lunatic/launcher/home/GeckoWeb.kt | 117 +++- .../lunatic/launcher/home/NeoRssActivity.kt | 159 ++--- .../bums/lunatic/launcher/home/RssHome.kt | 49 +- .../launcher/home/SystemStatusFragment.kt | 582 ++++++++++++++++++ .../home/adapters/TorrentListAdapter.kt | 2 +- .../launcher/home/tokiz/HistoryManager.kt | 1 + .../launcher/home/tokiz/TokiFragment.kt | 97 ++- .../launcher/view/FloatingActionMenu.kt | 191 +++--- .../launcher/workers/TaskAggregator.kt | 14 +- .../launcher/workers/TorrentManager.kt | 71 ++- .../res/layout/bottom_sheet_app_drawer.xml | 15 +- .../res/layout/fragment_system_status.xml | 327 ++++++++++ app/src/main/res/layout/item_torrent_task.xml | 4 +- app/src/main/res/layout/rss_activity.xml | 20 +- app/src/main/res/layout/spinner_item_dark.xml | 7 +- app/src/main/res/values/attrs.xml | 2 + app/src/main/res/values/styles.xml | 2 +- 21 files changed, 1597 insertions(+), 258 deletions(-) create mode 100644 app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt create mode 100644 app/src/main/res/layout/fragment_system_status.xml diff --git a/app/src/main/assets/extensions/my_extension/messaging.js b/app/src/main/assets/extensions/my_extension/messaging.js index 9ba54370..25fc3d07 100644 --- a/app/src/main/assets/extensions/my_extension/messaging.js +++ b/app/src/main/assets/extensions/my_extension/messaging.js @@ -359,7 +359,10 @@ function toast(msg) { port.postMessage(JSON.stringify({type:"MSG",msg:msg})); } -var mainContentsEl = null +var mainContentsEl = null; +let lastState = null; // 이전 상태 저장 (-1, 0, 1) +let throttleTimer = null; + document.addEventListener('DOMContentLoaded', function () { const currentUrl = location.href; @@ -382,6 +385,40 @@ document.addEventListener('DOMContentLoaded', function () { // toast("connect port on " + location.href); time1 = setTimeout(autoScrollAndSave(false), 3500); } + + + + + window.onscroll = function() { + if (throttleTimer) return; // 이미 타이머가 동작 중이면 무시 + + throttleTimer = setTimeout(() => { + const scrollY = window.scrollY; + const innerHeight = window.innerHeight; + const offsetHeight = document.body.offsetHeight; + + let currentState; + + if (scrollY <= 2) { + currentState = -1; // 최상단 + } else if ((innerHeight + scrollY) >= offsetHeight - 10) { + currentState = 1; // 최하단 + } else { + currentState = 0; // 중간 + } + + // 💡 상태가 바뀌었을 때만 앱으로 전송 + if (currentState !== lastState) { + lastState = currentState; + sendMessage({ + type: "SCROLL_STATE", + value: currentState + }); + } + + throttleTimer = null; + }, 150); // 0.15초 간격으로 체크 (사용성에 따라 조절 가능) + }; }) const keywords = ["youtube", "mojeek"]; diff --git a/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt index 8ab5134b..81d0a2a4 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt @@ -218,8 +218,10 @@ open class LauncherActivity : CommonActivity() { fun onSwipeRight() { startActivity(Intent(this, NeoRssActivity::class.java).apply { - - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + // 이미 실행 중인 액티비티가 있다면 스택의 맨 위로 올리고 재사용합니다. + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + // 기존에 있던 SINGLE_TOP과 함께 사용하면 중복 생성을 더 확실히 방지합니다. + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) }) } @@ -267,10 +269,14 @@ open class LauncherActivity : CommonActivity() { override fun onNewIntent(intent: Intent) { try { + supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG)?.let { + try { if (it is AppDrawerBottomSheet) it.dismissAllowingStateLoss() } catch (e : Exception) {} + } + val fragment = supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG) Blog.LOGE("fragment >>> $fragment") if (fragment is AppDrawerBottomSheet) { - fragment.dismiss() + try { fragment.dismiss() } catch (e : Exception) {} } if (intent?.action?.equals(Intent.ACTION_MAIN) == true && intent.categories.contains( Intent.CATEGORY_HOME)) { @@ -357,9 +363,17 @@ open class LauncherActivity : CommonActivity() { fun showAppDrawer() { + // 이미 화면에 떠 있는지 확인 + val existingFragment = supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG) + if (existingFragment != null && existingFragment.isAdded) { + return // 이미 존재하면 새로 띄우지 않음 + } + val bottomSheet = AppDrawerBottomSheet.newInstance() bottomSheet.show(supportFragmentManager, AppDrawerBottomSheet.TAG) } + + private lateinit var homeGestureDetector: androidx.core.view.GestureDetectorCompat @SuppressLint("NewApi", "MissingPermission", "ClickableViewAccessibility") diff --git a/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt b/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt index 1ea1f2ab..79982aac 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/apps/AppDrawerBottomSheet.kt @@ -3,6 +3,7 @@ package bums.lunatic.launcher.apps import android.app.Dialog import android.app.SearchManager import android.content.Intent +import android.graphics.Color import android.net.Uri import android.os.Bundle import android.view.KeyEvent @@ -129,11 +130,23 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() { val displayList = categoryMap.keys.toList() // 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용) - val adapter = ArrayAdapter( + val adapter = object : ArrayAdapter( requireContext(), R.layout.spinner_item_dark, displayList - ) + ){ + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) + 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")) // 펼쳐진 리스트의 배경 + return view + } + } adapter.setDropDownViewResource(R.layout.spinner_item_dark) binding.categorySpinner.adapter = adapter @@ -147,6 +160,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() { fetchApps(binding.searchInput.text.toString()) } + + override fun onNothingSelected(parent: AdapterView<*>?) {} } } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt index 5f96f79d..61841ab7 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt @@ -34,6 +34,7 @@ import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.workers.TaskAggregator import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLRequest +import io.realm.kotlin.internal.interop.sync.CancellableTimer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -45,7 +46,11 @@ import okhttp3.ResponseBody import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit - +import android.bluetooth.BluetoothClass +import android.media.AudioManager +import android.os.SystemClock +import android.view.KeyEvent +import kotlinx.coroutines.delay class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { @@ -63,6 +68,19 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter } } +class VibrationWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + vibrator.vibrate(android.os.VibrationEffect.createOneShot(1000, android.os.VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(1000) + } + // 필요 시 여기서 상단 알림(Notification)을 한 번 더 띄워줄 수 있습니다. + return Result.success() + } +} + class ForeGroundService : Service() { companion object { val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE" @@ -70,6 +88,7 @@ class ForeGroundService : Service() { val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD" val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE" + val ACTION_SIT_DOWN = "ACTION_SIT_DOWN" val targetUrls = arrayListOf() } @@ -89,9 +108,11 @@ class ForeGroundService : Service() { val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED) registerReceiver(bluetoothreceiver, filter) refreshFeeds() + startForeGround() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Blog.LOGE("intent?.action >> ${intent?.action}") when(intent?.action) { ACTION_SENDMSG -> { intent?.getStringExtra(EXTRA_MSGKEY)?.let { @@ -105,9 +126,27 @@ class ForeGroundService : Service() { } } } - } + ACTION_SIT_DOWN -> { + val sitDownRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(45, TimeUnit.MINUTES) // 45분 지연 + .build() - startForeGround() + WorkManager.getInstance(this).enqueueUniqueWork( + "SitDownVibration", + ExistingWorkPolicy.REPLACE, // 새로 누를 때마다 타이머 초기화 (원치 않으면 KEEP) + sitDownRequest + ) + // 사용자 확인을 위해 알림 텍스트 변경 가능 + startForeGround(0, 0, "45분 뒤에 진동 알림이 예약되었습니다.") + } + else -> { + + } + }.apply { + if(intent?.action?.equals(ACTION_SIT_DOWN) == false) { + startForeGround() + } + } return START_STICKY } @@ -161,7 +200,8 @@ class ForeGroundService : Service() { } } - fun startForeGround(max : Int = 0 , progress : Int = 0, str : String = "실행중입니다.") { + fun startForeGround(max : Int = 0 , progress : Int = 0, str : String? = "실행중입니다.") { + Blog.LOGE(str?:"") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( CHANNEL_ID, @@ -189,11 +229,20 @@ class ForeGroundService : Service() { .setSmallIcon(R.drawable.ic_b) .setContentIntent(pendingIntent) .addAction(android.R.drawable.ic_btn_speak_now,"퇴근", makeSendMsgAction(0,"돼지 퇴근했다요~!")) + .addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction()) .setOngoing(true) // 사용자가 알림을 스와이프로 지울 수 없게 만듦 .setProgress(max, progress, false) .build()) } + fun makeSitDownAction(): PendingIntent { + val intent = Intent(this, ForeGroundService::class.java).apply { + action = ACTION_SIT_DOWN + } + return PendingIntent.getService(this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + fun makeSendMsgAction(code : Int, msg : String) : PendingIntent { val actionIntent = Intent(this, ForeGroundService::class.java).apply { action = ACTION_SENDMSG @@ -356,6 +405,66 @@ class ForeGroundService : Service() { if (context == null) return if (ActivityCompat.checkSelfPermission(context!!, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return getPairedDevices() + + if (action == BluetoothDevice.ACTION_ACL_CONNECTED) { + val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + + // 연결된 기기가 오디오(이어폰, 스피커, 차량 블루투스)인지 확인 + val majorClass = device?.bluetoothClass?.majorDeviceClass + if (majorClass == BluetoothClass.Device.Major.AUDIO_VIDEO) { + Blog.LOGE("Audio Bluetooth Connected: ${device?.name ?: "Unknown"}") + if ("BUMz pod".equals(device?.name)) { + launchAppleMusic(context) + } + } + } + } } + + private fun launchAppleMusic(context: Context) { + try { + val appleMusicPackage = "com.apple.android.music" + val launchIntent = context.packageManager.getLaunchIntentForPackage(appleMusicPackage) + + if (launchIntent != null) { + // 서비스(백그라운드)에서 화면을 띄우려면 NEW_TASK 플래그가 필수입니다. + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + context.startActivity(launchIntent) + Blog.LOGE("애플뮤직 자동 실행 완료") + CoroutineScope(Dispatchers.Main).launch { + delay(1500) // 1.5초 대기 (기기 속도에 따라 1000~2000 사이로 조절 가능) + + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val eventTime = SystemClock.uptimeMillis() + + // 재생 버튼 누름 (ACTION_DOWN) + val downEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0) + audioManager.dispatchMediaKeyEvent(downEvent) + + // 재생 버튼 뗌 (ACTION_UP) + val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0) + audioManager.dispatchMediaKeyEvent(upEvent) + + Blog.LOGE("애플뮤직 미디어 재생(Play) 명령 전송 완료 🎵") + delay(300) // 시스템이 재생 명령을 처리할 수 있도록 아주 잠깐 대기 + + // 💡 3. 홈 화면(런처)으로 즉시 강제 복귀 + val homeIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(homeIntent) + Blog.LOGE("런처(홈) 화면으로 복귀 완료") + } + + } else { + Blog.LOGE("애플뮤직 앱이 기기에 설치되어 있지 않습니다.") + } + } catch (e: Exception) { + e.printStackTrace() + Blog.LOGE("애플뮤직 실행 중 오류 발생: ${e.message}") + } + } + } \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt index b71cdb32..72698c52 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -4,14 +4,11 @@ import CustomVideoNodeRenderer import android.annotation.SuppressLint import android.app.Dialog import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.net.Uri import android.os.Build -import android.os.Handler -import android.os.Looper import android.text.SpannableStringBuilder import android.text.style.RelativeSizeSpan import android.util.AttributeSet @@ -21,7 +18,6 @@ import android.view.KeyEvent import android.view.MotionEvent import android.view.PointerIcon import android.view.View -import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout import android.widget.ProgressBar @@ -30,7 +26,6 @@ import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContentProviderCompat.requireContext import androidx.core.net.toUri import androidx.core.view.isVisible import bums.lunatic.launcher.BookmarkUploader @@ -56,30 +51,28 @@ import com.yausername.youtubedl_android.YoutubeDLRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import org.json.JSONException import org.json.JSONObject import org.jsoup.Jsoup import org.mozilla.gecko.util.ThreadUtils import org.mozilla.geckoview.GeckoResult import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate import org.mozilla.geckoview.GeckoSessionSettings import org.mozilla.geckoview.GeckoView import org.mozilla.geckoview.MediaSession +import org.mozilla.geckoview.PanZoomController +import org.mozilla.geckoview.ScreenLength import org.mozilla.geckoview.WebExtension import org.mozilla.geckoview.WebExtension.MessageDelegate import org.mozilla.geckoview.WebExtension.PortDelegate import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate -import org.mozilla.geckoview.WebRequestError import org.mozilla.geckoview.WebResponse import java.io.File import java.io.FileOutputStream -import java.io.InputStream import java.text.SimpleDateFormat import java.util.Date -import kotlin.jvm.java // BWebview와 GeckoWeb을 통합한 클래스 open class GeckoWeb @JvmOverloads constructor( @@ -91,35 +84,29 @@ open class GeckoWeb @JvmOverloads constructor( // 1. 세션 상태를 저장할 SharedPreferences 키 private val PREF_SESSION_STATE = "gecko_session_state" - // 2. 현재 세션 상태 저장 메서드 + var sessionTag: String = "default" // 프레그먼트에서 설정할 고유 키 + fun saveCurrentSessionState() { - lastSessionState?.let { state -> - // SessionState.toString()은 내부적으로 JSON 문자열을 반환합니다. val stateJson = state.toString() - + // 💡 키 이름에 sessionTag를 포함시켜 분리 저장 context.getSharedPreferences("GeckoPrefs", Context.MODE_PRIVATE) .edit() - .putString("gecko_session_state", stateJson) + .putString("gecko_session_state_$sessionTag", stateJson) .apply() - - Log.d("GeckoWeb", "Session State Saved: $stateJson") + Log.d("GeckoWeb", "Saved State for $sessionTag") } } -// // 3. 저장된 세션 상태 복구 메서드 - fun restoreSessionState(session: GeckoSession) { + fun restoreSessionState() { + // 💡 저장할 때와 동일한 태그로 불러오기 val stateJson = context.getSharedPreferences("GeckoPrefs", Context.MODE_PRIVATE) - .getString("gecko_session_state", null) + .getString("gecko_session_state_$sessionTag", null) if (!stateJson.isNullOrEmpty()) { - // 문자열에서 SessionState 객체 생성 val state = GeckoSession.SessionState.fromString(stateJson) - Blog.LOGE("Restored state >>> ${state}") if (state != null) { - - session.restoreState(state) - Log.d("GeckoWeb", "Session State Restored") + session?.restoreState(state) } } } @@ -339,6 +326,7 @@ open class GeckoWeb @JvmOverloads constructor( R.id.reload -> view.setOnClickListener { session.reload() } } } +// scrollState = 0 } override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { @@ -433,7 +421,7 @@ open class GeckoWeb @JvmOverloads constructor( } buildWeb() } - + var lastScrollY = 0.0f private fun buildWeb() { getRuntime()?.let { runtime -> val sessionSettings = GeckoSessionSettings.Builder() @@ -446,7 +434,7 @@ open class GeckoWeb @JvmOverloads constructor( .build() val session = GeckoSession(sessionSettings) - restoreSessionState(session) + session.open(runtime) this.setSession(session) @@ -457,6 +445,15 @@ open class GeckoWeb @JvmOverloads constructor( session.mediaDelegate = mediaDelegate session.promptDelegate = promptDelegate session.mediaSessionDelegate = mediaSessionDelegate + session.compositorScrollDelegate = object : CompositorScrollDelegate { + override fun onScrollChanged( + session: GeckoSession, + update: GeckoSession.ScrollPositionUpdate + ) { + super.onScrollChanged(session, update) + lastScrollY = update.scrollY + } + } runtime.settings.loginAutofillEnabled = true runtime.webExtensionController.setAddonManagerDelegate(addonManagerDelegate) @@ -476,8 +473,38 @@ open class GeckoWeb @JvmOverloads constructor( } // --- 4. Helper Methods --- + fun checkScrollEndUsingMatrix(): Boolean { + val matrix = android.graphics.Matrix() + // 현재 페이지 좌표 -> 화면 좌표 변환 행렬 가져오기 + session?.getPageToScreenMatrix(matrix) + + // 웹페이지의 콘텐츠 높이를 짐작하기 위한 로직 + // (GeckoView에서 scrollHeight를 직접 가져오기 어려우므로 + // CompositorScrollDelegate에서 업데이트된 정보를 활용하거나 + // 아래와 같이 Matrix의 역산 값을 활용합니다.) + + val values = FloatArray(9) + matrix.getValues(values) + + // values[Matrix.MTRANS_Y]는 현재 페이지의 Top이 화면상 어디에 있는지(y축 오프셋)를 나타냅니다. + // values[Matrix.MSCALE_Y]는 현재 줌 배율입니다. + val currentTopInScreen = values[android.graphics.Matrix.MTRANS_Y] + val currentZoom = values[android.graphics.Matrix.MSCALE_Y] + + // 💡 논리: + // 손가락 스크롤이나 볼륨키 스크롤 모두 이 Matrix 값을 실시간으로 변화시킵니다. + // 특정 시점에 '스크롤 다운'을 명령했는데 이 Matrix의 변환 값(MTRANS_Y)이 + // 더 이상 변하지 않는다면 그것이 물리적인 끝입니다. + + return false // 실제 구현 시에는 이전 MTRANS_Y 값과 비교 로직 추가 + } + private fun handlePortMessage(msg: PortMessage) { when (msg.type) { + "SCROLL_STATE" -> { + Blog.LOGE("${msg.type} : ${msg.value}") + scrollState = msg.value?.toInt() ?: 0 + } "allImagesFound" -> lastedUrl?.let { startBookmarkSaveProcessForMultipleImages(it, msg.urls) } "SINGLE_IMAGE_DATA" -> if (msg.imgSrc != null && msg.base64Data != null) { Base64ImageCache.put(msg.imgSrc!!, msg.base64Data!!) @@ -517,7 +544,7 @@ open class GeckoWeb @JvmOverloads constructor( } return super.dispatchKeyEvent(ev) } - + var scrollState = 0 // Message Sending Helpers fun sendScrollDown(isUp: Boolean) = sendJsonMsg("scrollDown", "isUpDown" to if(isUp) 1 else -1) fun sendSearch(keyword: String) = sendJsonMsg("search", "keyword" to keyword) @@ -529,6 +556,29 @@ open class GeckoWeb @JvmOverloads constructor( params.forEach { json.put(it.first, it.second) } mPort?.postMessage(json) } + var HH = 0.3 + fun pageDown() { + val session = this.session ?: return + val pzc = session.panZoomController + + // 💡 현재 눈에 보이는 GeckoView의 높이를 가져옵니다. + val viewHeight = this.height.toDouble().times(HH) + + // 세로 방향(y)으로 뷰 높이만큼 아래로 스크롤 (픽셀 단위) + // 세 번째 인자는 애니메이션 여부입니다. + pzc.scrollBy(ScreenLength.fromPixels(0.0), ScreenLength.fromPixels(viewHeight), + PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + fun pageUp() { + val session = this.session ?: return + val pzc = session.panZoomController + val viewHeight = this.height.toDouble().times(HH) + + // 위로 스크롤 + pzc.scrollBy(ScreenLength.fromPixels(0.0), ScreenLength.fromPixels(-viewHeight), + PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } // File/Download Helpers private fun downloadFile(url: String) { @@ -631,4 +681,15 @@ open class GeckoWeb @JvmOverloads constructor( } private fun View.post(action: () -> Unit) = this.post(Runnable(action)) + fun onPause() { + saveCurrentSessionState() + session?.stop() + session?.setActive(false) + Blog.LOGE("called onstop $lastedUrl") + } + + fun onResume() { + restoreSessionState() + session?.setActive(true) + } } \ No newline at end of file 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 42de62b4..3621983c 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt @@ -31,6 +31,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import bums.lunatic.launcher.LunaticLauncher import bums.lunatic.launcher.R import bums.lunatic.launcher.common.CommonActivity @@ -61,6 +62,10 @@ import org.mozilla.geckoview.GeckoRuntimeSettings import org.mozilla.geckoview.GeckoRuntimeSettings.ALLOW_ALL import java.io.File +interface KeyEventHandler { + // 이벤트를 소비(consume)했으면 true, 아니면 false 반환 + fun onKeyEvent(event: KeyEvent): Boolean +} open class NeoRssActivity : CommonActivity() { @@ -95,6 +100,10 @@ open class NeoRssActivity : CommonActivity() { } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + } @SuppressLint("MissingSuperCall") override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -154,33 +163,11 @@ open class NeoRssActivity : CommonActivity() { return true } else { - val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) - Blog.LOGE("currentFragment >>> ${currentFragment} ${currentFragment is TokiFragment && currentFragment.isbooktoki()}") - if (currentFragment is TokiFragment && currentFragment.isbooktoki()) { - if(MotionEvent.ACTION_UP.equals(ev?.action ?: MotionEvent.ACTION_CANCEL) == true) { - return when (ev.keyCode) { - KeyEvent.KEYCODE_VOLUME_DOWN -> { - if(currentFragment is TokiFragment){ currentFragment.actionNextEvent() } - true - } - KeyEvent.KEYCODE_VOLUME_UP -> { - if(currentFragment is TokiFragment){ currentFragment.actionPrevEvent() } - true - } - else -> false - } - } else { - return when (ev.keyCode) { - KeyEvent.KEYCODE_VOLUME_DOWN -> { - true - } - KeyEvent.KEYCODE_VOLUME_UP -> { - true - } - - else -> false - } + val currentFragment = fragmentMap.values.find { it.isAdded && (!it.isHidden || it.isVisible) } + if (currentFragment is KeyEventHandler) { + if (currentFragment.onKeyEvent(ev)) { + return true } } else { return super.dispatchKeyEvent(ev) } @@ -373,7 +360,7 @@ open class NeoRssActivity : CommonActivity() { startActivity(shareIntent) } } - showContents(R.id.feeds) + showContents(R.id.btn_info) } override fun onPause() { @@ -390,78 +377,64 @@ open class NeoRssActivity : CommonActivity() { private var lastTouchY = 0f + 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 - when(id) { - R.id.feeds -> { - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, RssHome()) - .commit() - } - R.id.books ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstanceNovels()) - .commit() - } - R.id.webtoons ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstanceWebtoons()) - .commit() - } - R.id.comics ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstanceComics()) - .commit() - } - R.id.youtube ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstanceYouTube()) - .commit() - } - R.id.perplexity ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstancePerplexity()) - .commit() - } - R.id.zzalbang ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, BookmarkPagerFragment()) - .commit() - } + val transaction = supportFragmentManager.beginTransaction() - R.id.btn_x ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TokiFragment.newInstanceX()) - .commit() + fragmentMap.values.forEach { fragment -> + if (fragment.isAdded) { + transaction.hide(fragment) } - - R.id.btn_torrent ->{ - supportFragmentManager.beginTransaction() - .replace(R.id.fragment_container, TorrentListFragment()) - .commit() - } - - R.id.close ->{ - supportFragmentManager.findFragmentById(R.id.fragment_container)?.let { - supportFragmentManager.beginTransaction() - .remove(it) - .commit() - binding.fragmentLayer.visibility = View.GONE - binding.fragmentContainer.visibility = View.GONE - binding.controllPanel.visibility = View.GONE - binding.floatingActionMenu.visibility = View.GONE - } -// sRuntime?.shutdown() -// sRuntime = null - finish() - } - else -> {} } + // 2. 요청된 ID에 해당하는 프래그먼트가 이미 생성되었는지 확인 + var targetFragment = fragmentMap[id] + + if (targetFragment == null) { + targetFragment = supportFragmentManager.findFragmentByTag("TAG_$id") + + // 시스템이 복구해 놓은 게 있다면, 맵에 다시 등록(싱크 맞추기) + if (targetFragment != null) { + fragmentMap[id] = targetFragment + } + } + + if (targetFragment == null) { + // 처음 호출되는 메뉴라면 인스턴스 생성 및 추가 + targetFragment = when(id) { + R.id.feeds -> RssHome() + R.id.books -> TokiFragment.newInstanceNovels() + R.id.webtoons -> TokiFragment.newInstanceWebtoons() + R.id.comics -> TokiFragment.newInstanceComics() + R.id.youtube -> TokiFragment.newInstanceYouTube() + R.id.perplexity -> TokiFragment.newInstancePerplexity() + R.id.zzalbang -> BookmarkPagerFragment() + R.id.btn_x -> TokiFragment.newInstanceX() + R.id.btn_i -> TokiFragment.newInstanceI() + R.id.btn_torrent -> TorrentListFragment() + R.id.btn_info -> SystemStatusFragment() + R.id.close -> { + finish() + return + } + else -> null + } + + targetFragment?.let { + fragmentMap[id] = it + transaction.add(R.id.fragment_container, it, "TAG_$id") + } + } else { + // 이미 생성된 프래그먼트가 있다면 다시 보여줌 + transaction.show(targetFragment) + } + + transaction.commit() binding.floatingActionMenu.close(false) } @@ -524,14 +497,14 @@ open class NeoRssActivity : CommonActivity() { currentFragment.doNextPage() } } -// is YouTube -> { -// currentFragment.back() -// } + is TokiFragment -> { + currentFragment.back() + } // is Novels -> { // currentFragment.actionNextEvent(false) // } else -> { - showContents(R.id.close) +// showContents(R.id.close) } } } 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 15184375..85d0e45d 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt @@ -27,6 +27,7 @@ import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.PointerIcon @@ -82,7 +83,7 @@ import java.text.SimpleDateFormat import java.util.Date -internal class RssHome : Fragment() { +internal class RssHome : Fragment() , KeyEventHandler { lateinit var binding: LauncherHomeBinding private lateinit var fragManager: FragmentManager @@ -213,6 +214,36 @@ internal class RssHome : Fragment() { home = this } + override fun onKeyEvent(event: KeyEvent): Boolean { + + val isUp = event.action == MotionEvent.ACTION_UP + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (isUp) { + if (binding.geckoWeb.scrollState > 0) { + // 사용자가 손으로 끝까지 내렸든, 볼륨키로 내렸든 상관없이 이곳에 도달함 + doNextPage() + } else { + binding.geckoWeb.pageDown() + } + } + true + + } + KeyEvent.KEYCODE_VOLUME_UP -> { + if (isUp) { + if (binding.geckoWeb.scrollState < 0) { + binding.geckoWeb.session?.goBack() + } else { + binding.geckoWeb.pageUp() + } + } + true + } + else -> false + } + } + var targetList = arrayListOf() val dateViewClick = View.OnClickListener { v -> Blog.LOGE("click view >> ${v}") @@ -576,7 +607,7 @@ internal class RssHome : Fragment() { 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 } @@ -584,6 +615,20 @@ internal class RssHome : Fragment() { binding.geckoWeb?.saveMd(true) } + // TokiFragment 또는 GeckoView를 사용하는 프래그먼트 내부 + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + if (hidden) { + // 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지 + binding.geckoWeb?.onPause() + // 일반 WebView라면: webView.onPause() 및 webView.pauseTimers() + } else { + // 💡 다시 나타날 때: 다시 시작 +// binding.geckoWeb?.onResume() + // 일반 WebView라면: webView.onResume() 및 webView.resumeTimers() + } + } + @SuppressLint("NewApi") fun doNextPage() { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt new file mode 100644 index 00000000..9d147908 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/SystemStatusFragment.kt @@ -0,0 +1,582 @@ +package bums.lunatic.launcher.home + +import android.app.ActivityManager +import android.app.AppOpsManager +import android.app.NotificationManager +import android.app.usage.NetworkStatsManager +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Color +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.TrafficStats +import android.net.Uri +import android.net.wifi.WifiManager +import android.os.BatteryManager +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.StatFs +import android.os.SystemClock +import android.provider.Settings +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.Fragment +import bums.lunatic.launcher.databinding.FragmentSystemStatusBinding +import bums.lunatic.launcher.utils.Blog +import kotlinx.coroutines.* +import java.util.Calendar + +class SystemStatusFragment : Fragment() { + + private var _binding: FragmentSystemStatusBinding? = null + private val binding get() = _binding!! + private var monitorJob: Job? = null + + private var lastRxBytes: Long = 0 + private var lastTxBytes: Long = 0 + private var lastUpdateTime: Long = 0 + + private lateinit var audioManager: AudioManager + + // 앱 데이터 사용량 계산용 데이터 클래스 + data class AppUsage(val uid: Int, var name: String = "", var wifiBytes: Long = 0L, var mobileBytes: Long = 0L) { + val totalBytes get() = wifiBytes + mobileBytes + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSystemStatusBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + audioManager = requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager + + setupClickListeners() + setupQuickControls() + + resetNetworkCounters() + startMonitoring() + updateMonthlyDataUsage() + } + + private fun setupClickListeners() { + binding.textMonthlyUsage.setOnClickListener { + if (!hasUsageStatsPermission()) { + startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) + } else { + updateMonthlyDataUsage() + } + } + + binding.textStorage.setOnClickListener { + try { + startActivity(Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS)) + } catch (e: Exception) { + startActivity(Intent(Settings.ACTION_SETTINGS)) + } + } + binding.progressStorage.setOnClickListener { binding.textStorage.performClick() } + + binding.textRam.setOnClickListener { + try { + val samsungIntent = Intent().apply { + setClassName("com.samsung.android.lool", "com.samsung.android.sm.ui.ram.RamActivity") + } + startActivity(samsungIntent) + } catch (e: ActivityNotFoundException) { + try { + startActivity(Intent(Settings.ACTION_APPLICATION_SETTINGS)) + Toast.makeText(requireContext(), "앱 설정 화면으로 이동합니다.", Toast.LENGTH_SHORT).show() + } catch (ex: Exception) { + startActivity(Intent(Settings.ACTION_SETTINGS)) + } + } + } + binding.progressRam.setOnClickListener { binding.textRam.performClick() } + + binding.textBattery.setOnClickListener { + try { + startActivity(Intent(Intent.ACTION_POWER_USAGE_SUMMARY)) + } catch (e: Exception) { + startActivity(Intent(Settings.ACTION_SETTINGS)) + } + } + binding.progressBattery.setOnClickListener { binding.textBattery.performClick() } + } + + private fun setupQuickControls() { + binding.btnQuickWifi.setOnClickListener { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startActivity(Intent(Settings.Panel.ACTION_WIFI)) + } else { + startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) + } + } + + binding.btnQuickBt.setOnClickListener { + startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + } + + binding.btnQuickRinger.setOnClickListener { + val nm = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !nm.isNotificationPolicyAccessGranted) { + Toast.makeText(requireContext(), "무음 모드 제어를 위해 권한을 허용해주세요.", Toast.LENGTH_LONG).show() + startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)) + return@setOnClickListener + } + + when (audioManager.ringerMode) { + AudioManager.RINGER_MODE_NORMAL -> audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + AudioManager.RINGER_MODE_VIBRATE -> audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT + AudioManager.RINGER_MODE_SILENT -> audioManager.ringerMode = AudioManager.RINGER_MODE_NORMAL + } + updateQuickControlUI() + } + + binding.seekVolume.max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + binding.seekVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, progress, 0) + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + binding.seekBrightness.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (!fromUser) return + if (Settings.System.canWrite(requireContext())) { + val cr = requireContext().contentResolver + Settings.System.putInt(cr, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL) + Settings.System.putInt(cr, Settings.System.SCREEN_BRIGHTNESS, progress) + } else { + Toast.makeText(requireContext(), "밝기 조절을 위해 '시스템 설정 수정' 권한이 필요합니다.", Toast.LENGTH_LONG).show() + val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { + data = Uri.parse("package:${requireContext().packageName}") + } + startActivity(intent) + } + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + updateQuickControlUI() + } + + private fun updateQuickControlUI() { + val ringerText = when (audioManager.ringerMode) { + AudioManager.RINGER_MODE_NORMAL -> "🔔 소리" + AudioManager.RINGER_MODE_VIBRATE -> "📳 진동" + AudioManager.RINGER_MODE_SILENT -> "🔕 무음" + else -> "소리" + } + binding.btnQuickRinger.text = ringerText + binding.seekVolume.progress = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + try { + val brightness = Settings.System.getInt(requireContext().contentResolver, Settings.System.SCREEN_BRIGHTNESS) + binding.seekBrightness.progress = brightness + } catch (e: Settings.SettingNotFoundException) { } + } + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + if (hidden) { + stopMonitoring() + } else { + resetNetworkCounters() + startMonitoring() + updateMonthlyDataUsage() + updateQuickControlUI() + } + } + + override fun onPause() { + super.onPause() + stopMonitoring() + } + + override fun onResume() { + super.onResume() + if (!isHidden) { + resetNetworkCounters() + startMonitoring() + updateMonthlyDataUsage() + updateQuickControlUI() + } + } + + private fun resetNetworkCounters() { + lastRxBytes = TrafficStats.getTotalRxBytes() + lastTxBytes = TrafficStats.getTotalTxBytes() + lastUpdateTime = System.currentTimeMillis() + } + + private fun startMonitoring() { + if (monitorJob?.isActive == true) return + + monitorJob = CoroutineScope(Dispatchers.Main).launch { + while (isActive) { + updateSystemInfo() + updateNetworkSpeed() + if (!binding.seekVolume.isPressed && !binding.seekBrightness.isPressed) { + updateQuickControlUI() + } + delay(2000) + } + } + } + + private fun stopMonitoring() { + monitorJob?.cancel() + monitorJob = null + } + + private fun updateSystemInfo() { + val context = requireContext() + + // 1. RAM + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + + val totalRam = memoryInfo.totalMem / (1024 * 1024) + val availRam = memoryInfo.availMem / (1024 * 1024) + val usedRam = totalRam - availRam + val ramPercent = if (totalRam > 0) (usedRam.toFloat() / totalRam.toFloat() * 100).toInt() else 0 + + binding.textRam.text = "RAM: ${usedRam}MB / ${totalRam}MB ($ramPercent%)" + binding.progressRam.progress = ramPercent + + // 2. Storage + val statFs = StatFs(Environment.getDataDirectory().path) + val totalStorage = statFs.totalBytes / (1024 * 1024 * 1024) + val availStorage = statFs.availableBytes / (1024 * 1024 * 1024) + val usedStorage = totalStorage - availStorage + val storagePercent = if (totalStorage > 0) (usedStorage.toFloat() / totalStorage.toFloat() * 100).toInt() else 0 + + binding.textStorage.text = "Storage: ${usedStorage}GB / ${totalStorage}GB ($storagePercent%)" + binding.progressStorage.progress = storagePercent + + // 3. Battery + val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter -> + context.registerReceiver(null, ifilter) + } + + batteryStatus?.let { intent -> + val level: Int = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale: Int = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) + val batteryPct = if (scale > 0) level * 100 / scale.toFloat() else 0f + + val tempInt = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) + val temperature = tempInt / 10f + + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL + val chargePlug = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) + + val chargingText = when { + status == BatteryManager.BATTERY_STATUS_FULL -> "충전 완료 (100%)" + isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_AC -> "충전 중 (AC 어댑터)" + isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_USB -> "충전 중 (USB 연결)" + isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_WIRELESS -> "충전 중 (무선 충전)" + isCharging -> "충전 중" + else -> "방전 중" + } + binding.textChargingStatus.text = "Status: $chargingText" + + binding.textBattery.text = "Battery: ${batteryPct.toInt()}%" + binding.progressBattery.progress = batteryPct.toInt() + + if (temperature >= 40.0f) { + binding.textTemperature.setTextColor(Color.parseColor("#F44336")) + } else { + binding.textTemperature.setTextColor(Color.parseColor("#FF9800")) + } + binding.textTemperature.text = "Temp: ${temperature}°C" + } + + // 4. Connectivity + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = cm.activeNetwork + val caps = cm.getNetworkCapabilities(activeNetwork) + + var networkName = "오프라인" + if (caps != null) { + when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { + networkName = "Wi-Fi" + try { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + var ssid = wifiManager.connectionInfo.ssid + if (ssid.startsWith("\"") && ssid.endsWith("\"")) { + ssid = ssid.substring(1, ssid.length - 1) + } + if (ssid != "" && ssid.isNotBlank()) { + networkName = "Wi-Fi ($ssid)" + } + } catch (e: SecurityException) { } + } + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> networkName = "Mobile Data" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> networkName = "Ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> networkName = "VPN" + } + } + binding.textNetworkState.text = "Net: $networkName" + + // 5. Bluetooth + var btState = "OFF" + var connectedDevicesStr = "" + try { + val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val btAdapter = btManager.adapter + if (btAdapter?.isEnabled == true) { + btState = "ON" + val connectedNames = mutableSetOf() + + val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + audioDevices.forEach { device -> + if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + device.type == AudioDeviceInfo.TYPE_BLE_HEADSET || + device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER) { + + if (!device.productName.isNullOrBlank()) { + connectedNames.add(device.productName.toString()) + } + } + } + + try { + btManager.getConnectedDevices(BluetoothProfile.GATT).forEach { device -> + if (!device.name.isNullOrBlank()) { + connectedNames.add(device.name) + } + } + } catch (e: SecurityException) { } + + if (connectedNames.isNotEmpty()) { + connectedDevicesStr = " (${connectedNames.joinToString(", ")})" + } + } + } catch (e: SecurityException) { + btState = "권한 필요" + } + + binding.textBluetoothState.text = "BT: $btState$connectedDevicesStr" + + // 6. Uptime + val uptimeMillis = SystemClock.elapsedRealtime() + val hours = (uptimeMillis / (1000 * 60 * 60)).toInt() + val minutes = ((uptimeMillis / (1000 * 60)) % 60).toInt() + val seconds = ((uptimeMillis / 1000) % 60).toInt() + + binding.textUptime.text = String.format("System Uptime: %02d:%02d:%02d", hours, minutes, seconds) + } + + private fun updateNetworkSpeed() { + val currentRxBytes = TrafficStats.getTotalRxBytes() + val currentTxBytes = TrafficStats.getTotalTxBytes() + val currentTime = System.currentTimeMillis() + + if (currentRxBytes == TrafficStats.UNSUPPORTED.toLong() || lastUpdateTime == 0L) return + + val timeDiff = (currentTime - lastUpdateTime) / 1000f + if (timeDiff > 0) { + val rxSpeed = (currentRxBytes - lastRxBytes) / timeDiff + val txSpeed = (currentTxBytes - lastTxBytes) / timeDiff + + binding.textDownload.text = "↓ DL: ${formatSpeed(rxSpeed)}" + binding.textUpload.text = "↑ UL: ${formatSpeed(txSpeed)}" + } + + lastRxBytes = currentRxBytes + lastTxBytes = currentTxBytes + lastUpdateTime = currentTime + } + + // 💡 앱별 다중 색상 데이터 사용량 뷰 생성 로직 + private fun updateMonthlyDataUsage() { + if (!hasUsageStatsPermission()) { + binding.textMonthlyUsage.text = "Monthly Usage: 권한 필요 (여기를 클릭하여 허용)" + binding.layoutTopApps.visibility = View.GONE + return + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val networkStatsManager = requireContext().getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager + val calendar = Calendar.getInstance() + calendar.set(Calendar.DAY_OF_MONTH, 1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + val startTime = calendar.timeInMillis + val endTime = System.currentTimeMillis() + + val wifiBucket = networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_WIFI, null, startTime, endTime) + val mobileBucket = networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_MOBILE, null, startTime, endTime) + + val wifiTotal = wifiBucket.rxBytes + wifiBucket.txBytes + val mobileTotal = mobileBucket.rxBytes + mobileBucket.txBytes + + val appUsageMap = mutableMapOf() + fun aggregateStats(networkType: Int, isWifi: Boolean) { + try { + val networkStats = networkStatsManager.querySummary(networkType, null, startTime, endTime) + val bucket = android.app.usage.NetworkStats.Bucket() + while (networkStats.hasNextBucket()) { + networkStats.getNextBucket(bucket) + val uid = bucket.uid + val totalBytes = bucket.rxBytes + bucket.txBytes + + val appUsage = appUsageMap.getOrPut(uid) { AppUsage(uid) } + if (isWifi) appUsage.wifiBytes += totalBytes + else appUsage.mobileBytes += totalBytes + } + networkStats.close() + } catch (e: Exception) { e.printStackTrace() } + } + + aggregateStats(ConnectivityManager.TYPE_WIFI, true) + aggregateStats(ConnectivityManager.TYPE_MOBILE, false) + + val packageManager = requireContext().packageManager + val topApps = appUsageMap.values + .filter { it.totalBytes > 0 } + .sortedByDescending { it.totalBytes } + .take(10) + + topApps.forEach { app -> + app.name = "Unknown (UID: ${app.uid})" + try { + val packages = packageManager.getPackagesForUid(app.uid) + if (!packages.isNullOrEmpty()) { + val appInfo = packageManager.getApplicationInfo(packages[0], 0) + app.name = packageManager.getApplicationLabel(appInfo).toString() + } + } catch (e: Exception) { } + } + + val maxBytes = topApps.maxOfOrNull { it.totalBytes }?.toFloat() ?: 1f + + withContext(Dispatchers.Main) { + // 전체 요약 (색상 아이콘 추가) + binding.textMonthlyUsage.text = "이번 달 누적 (Wi-Fi 🟩: ${formatSize(wifiTotal)} / 모바일 🟦: ${formatSize(mobileTotal)})" + + val container = binding.layoutTopApps + container.removeAllViews() + container.visibility = View.VISIBLE + + if (topApps.isEmpty()) { + val emptyText = TextView(context).apply { + text = "앱별 데이터 없음" + setTextColor(Color.parseColor("#BBBBBB")) + } + container.addView(emptyText) + return@withContext + } + + // 💡 동적 멀티 컬러 프로그레스바 그리기 (LinearLayout + layout_weight) + topApps.forEachIndexed { index, app -> + // 텍스트 라벨 + val textView = TextView(context).apply { + text = "${index + 1}. ${app.name}\n(총: ${formatSize(app.totalBytes)} | Wi-Fi 🟩: ${formatSize(app.wifiBytes)} | 모바일 🟦: ${formatSize(app.mobileBytes)})" + setTextColor(Color.parseColor("#CCCCCC")) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setPadding(0, 16, 0, 4) + } + + // 전체 게이지 바 컨테이너 + val barContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + weightSum = maxBytes + val heightPx = (10 * resources.displayMetrics.density).toInt() + val marginPx = (8 * resources.displayMetrics.density).toInt() + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, heightPx).apply { + setMargins(0, 0, 0, marginPx) + } + setBackgroundColor(Color.parseColor("#333333")) // 빈 부분 어두운 배경 + } + + // 1. Wi-Fi 사용량 막대 (초록색) + if (app.wifiBytes > 0) { + val wifiView = View(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, app.wifiBytes.toFloat()) + setBackgroundColor(Color.parseColor("#4CAF50")) + } + barContainer.addView(wifiView) + } + + // 2. 모바일 사용량 막대 (파란색) + if (app.mobileBytes > 0) { + val mobileView = View(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, app.mobileBytes.toFloat()) + setBackgroundColor(Color.parseColor("#2196F3")) + } + barContainer.addView(mobileView) + } + + // 3. 남은 여백 (가장 많이 쓴 앱 기준 스케일 맞추기 위함) + val emptyWeight = maxBytes - app.totalBytes.toFloat() + if (emptyWeight > 0) { + val emptyView = View(context).apply { + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, emptyWeight) + } + barContainer.addView(emptyView) + } + + container.addView(textView) + container.addView(barContainer) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun hasUsageStatsPermission(): Boolean { + val appOps = requireContext().getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), requireContext().packageName) + return mode == AppOpsManager.MODE_ALLOWED + } + + private fun formatSpeed(bytesPerSec: Float): String { + return when { + bytesPerSec >= 1024 * 1024 -> String.format("%.2f MB/s", bytesPerSec / (1024 * 1024)) + bytesPerSec >= 1024 -> String.format("%.1f KB/s", bytesPerSec / 1024) + else -> "${bytesPerSec.toInt()} B/s" + } + } + + private fun formatSize(bytes: Long): String { + return when { + bytes >= 1024 * 1024 * 1024 -> String.format("%.2f GB", bytes / (1024f * 1024f * 1024f)) + bytes >= 1024 * 1024 -> String.format("%.1f MB", bytes / (1024f * 1024f)) + else -> "${bytes / 1024} KB" + } + } + + override fun onDestroyView() { + super.onDestroyView() + stopMonitoring() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt index 47db00fd..516b89d9 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/TorrentListAdapter.kt @@ -39,7 +39,7 @@ class TorrentListAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val task = tasks[position] - holder.tvName.text = task.name + holder.tvName.text = task.name.plus("(${task.stateText})") holder.progressBar.progress = task.progress.toInt() holder.tvProgress.text = String.format("%.1f%%", task.progress) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt index ab2362b3..1dd26746 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/HistoryManager.kt @@ -103,6 +103,7 @@ class PortMessage { var urls : List = emptyList() var imgSrc: String? = null var base64Data: String? = null + var value : String? = null } class BookContents { var chapterTitle : String? = null 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 1e02ab82..2177a634 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 @@ -12,6 +12,7 @@ import android.text.InputType import android.text.SpannableStringBuilder import android.text.style.RelativeSizeSpan import android.util.Log +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.PointerIcon @@ -34,6 +35,7 @@ import bums.lunatic.launcher.R import bums.lunatic.launcher.databinding.BooktokiBinding import bums.lunatic.launcher.home.GeckoWeb import bums.lunatic.launcher.home.GeckoWeb.JxEvent +import bums.lunatic.launcher.home.KeyEventHandler import bums.lunatic.launcher.home.NeoRssActivity import bums.lunatic.launcher.home.toast import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout @@ -64,12 +66,12 @@ import java.util.Date import kotlin.random.Random // 기존 BaseToki 및 하위 클래스들을 통합한 단일 클래스 -class TokiFragment : Fragment(), PagedTextViewInterface { +class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler { // --- Configuration Properties (Arguments에서 로드) --- private lateinit var contentsType: String private var lastNumber: Int = 0 - private lateinit var webcontentsName: String + lateinit var webcontentsName: String private lateinit var afterDot: String private var useNumberInUrl: Boolean = true private var enableGestures: Boolean = true @@ -160,6 +162,17 @@ class TokiFragment : Fragment(), PagedTextViewInterface { } } + fun newInstanceI(): TokiFragment = TokiFragment().apply { + arguments = Bundle().apply { + putString(ARG_TYPE, "ijavtorrent") + putInt(ARG_LAST_NUM, 143) + putString(ARG_NAME, "ijavtorrent") + putString(ARG_DOT, "com") + putBoolean(ARG_USE_NUM_URL, false) + putBoolean(ARG_ENABLE_GESTURE, false) + } + } + fun newInstanceNovels(): TokiFragment = TokiFragment().apply { arguments = Bundle().apply { putString(ARG_TYPE, "book") @@ -236,6 +249,24 @@ class TokiFragment : Fragment(), PagedTextViewInterface { fun isbooktoki() : Boolean { return webcontentsName.contains("booktoki") } + + override fun onKeyEvent(event: KeyEvent): Boolean { + if (!isbooktoki()) return false // 조건부 처리 + + val isUp = event.action == MotionEvent.ACTION_UP + return when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (isUp) actionNextEvent() + true + } + KeyEvent.KEYCODE_VOLUME_UP -> { + if (isUp) actionPrevEvent() + true + } + else -> false + } + } + override fun onSwipeLeft(count: Int) { if (!enableGestures) return Blog.LOGD(log = "onSwipeLeft ${count}") @@ -283,8 +314,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface { if (contentsType == "youtube") { binding.menuWeb.session?.goBack() } else { - // binding.menuWeb.session?.goBack() - // 기존 BaseToki 주석 처리됨 + actionNextEvent(false) } } @@ -395,6 +425,21 @@ class TokiFragment : Fragment(), PagedTextViewInterface { return workingUrl } } + + + override fun onHiddenChanged(hidden: Boolean) { + super.onHiddenChanged(hidden) + if (hidden) { + // 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지 + binding.menuWeb?.onPause() + // 일반 WebView라면: webView.onPause() 및 webView.pauseTimers() + } else { + // 💡 다시 나타날 때: 다시 시작 +// binding.menuWeb?.onResume() + // 일반 WebView라면: webView.onResume() 및 webView.resumeTimers() + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -403,6 +448,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface { Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}") binding = BooktokiBinding.inflate(inflater) binding.menuWeb.let { + it.sessionTag = webcontentsName it.visibility = View.VISIBLE it.lastDomain = getLastedDoamin() it.setOnLongClickListener { @@ -416,27 +462,27 @@ class TokiFragment : Fragment(), PagedTextViewInterface { 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) +// 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) } } } @@ -531,6 +577,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface { it.decoViews.add(activity.findViewById(R.id.reload)) it.decoViews.add(activity.findViewById(R.id.dl_video)) } + it.restoreSessionState() } binding.btnList.setOnClickListener { v -> 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 b1c98653..76246fb1 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/view/FloatingActionMenu.kt @@ -106,6 +106,14 @@ class FloatingActionMenu @JvmOverloads constructor( private var isDragMenuDisabled = false + // 💡 라벨 위치 동적 변경을 위한 프로퍼티 + var labelsPosition: Int + get() = mLabelsPosition + set(position) { + mLabelsPosition = position + requestLayout() + } + interface OnMenuToggleListener { fun onMenuToggle(opened: Boolean) } @@ -388,8 +396,14 @@ class FloatingActionMenu @JvmOverloads constructor( if (label != null) { val labelOffset = (mMaxButtonWidth - child.getMeasuredWidth()) / (if (mUsingMenuLabel) 1 else 2) - val labelUsedWidth: Int = + + // 💡 [수정] Center 옵션일 경우 라벨을 버튼 위로 겹쳐 그리므로 공간을 추가하지 않음 + val labelUsedWidth: Int = if (mLabelsPosition == LABELS_POSITION_CENTER) { + child.getMeasuredWidth() + } else { child.getMeasuredWidth() + label.calculateShadowWidth() + mLabelsMargin + labelOffset + } + measureChildWithMargins( label, widthMeasureSpec, @@ -397,9 +411,11 @@ class FloatingActionMenu @JvmOverloads constructor( heightMeasureSpec, 0 ) - usedWidth += label.getMeasuredWidth() - maxLabelWidth = - max(maxLabelWidth.toDouble(), (usedWidth + labelOffset).toDouble()).toInt() + + if (mLabelsPosition != LABELS_POSITION_CENTER) { + usedWidth += label.getMeasuredWidth() + maxLabelWidth = max(maxLabelWidth.toDouble(), (usedWidth + labelOffset).toDouble()).toInt() + } } } @@ -483,30 +499,40 @@ class FloatingActionMenu @JvmOverloads constructor( val label = fab.getTag(R.id.fab_label) as View? if (label != null) { - val labelsOffset = - (if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.getMeasuredWidth() / 2) + mLabelsMargin - val labelXNearButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - buttonsHorizontalCenter - labelsOffset - else - buttonsHorizontalCenter + labelsOffset + val labelLeft: Int + val labelRight: Int + val labelTop: Int - val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - label.getMeasuredWidth() - else - labelXNearButton + label.getMeasuredWidth() + // 💡 [수정] 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 labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXAwayFromButton - else - labelXNearButton + val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) + labelXNearButton - label.getMeasuredWidth() + else + labelXNearButton + label.getMeasuredWidth() - val labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - else - labelXAwayFromButton + labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) + labelXAwayFromButton + else + labelXNearButton - val labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - - label.getMeasuredHeight()) / 2 + 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()) @@ -667,7 +693,6 @@ class FloatingActionMenu @JvmOverloads constructor( fab = child as FloatingActionButton if (fab === mMenuButton) continue - // only child buttons and imageToggle on menuBtn. child.layout( childX, childY, childX + child.getMeasuredWidth(), childY + child.getMeasuredHeight() @@ -681,30 +706,40 @@ class FloatingActionMenu @JvmOverloads constructor( if (child !== mImageToggle) label = fab.getTag(R.id.fab_label) as View? if (label != null) { - 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 labelLeft: Int + val labelRight: Int + val labelTop: Int - val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - label.getMeasuredWidth() - else - labelXNearButton + label.getMeasuredWidth() + // 💡 [수정] 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 labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXAwayFromButton - else - labelXNearButton + val labelXAwayFromButton = if (mLabelsPosition == LABELS_POSITION_LEFT) + labelXNearButton - label.getMeasuredWidth() + else + labelXNearButton + label.getMeasuredWidth() - val labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT) - labelXNearButton - else - labelXAwayFromButton + labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT) + labelXAwayFromButton + else + labelXNearButton - val labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - - label.getMeasuredHeight()) / 2 + labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT) + labelXNearButton + else + labelXAwayFromButton + + labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2 + } label.layout( labelLeft, @@ -739,31 +774,43 @@ class FloatingActionMenu @JvmOverloads constructor( label.setShowShadow(false) label.setUsingStyle(true) } else { - label.setColors(mLabelsColorNormal, mLabelsColorPressed, mLabelsColorRipple) - label.setShowShadow(mLabelsShowShadow) - label.setCornerRadius(mLabelsCornerRadius) - if (mLabelsEllipsize > 0) { - setLabelEllipsize(label) + // 💡 [수정] Center 옵션일 경우 배경 및 테두리 효과를 제거하여 내장 텍스트처럼 보이게 연출 + if (mLabelsPosition == LABELS_POSITION_CENTER) { + label.setColors(Color.TRANSPARENT, Color.TRANSPARENT, Color.TRANSPARENT) + label.setShowShadow(false) + label.setCornerRadius(0) + label.updateBackground() + + label.setTextSize(TypedValue.COMPLEX_UNIT_PX, mLabelsTextSize) + label.setTextColor(mLabelsTextColor) + label.setPadding(0, 0, 0, 0) + } else { + label.setColors(mLabelsColorNormal, mLabelsColorPressed, mLabelsColorRipple) + label.setShowShadow(mLabelsShowShadow) + label.setCornerRadius(mLabelsCornerRadius) + if (mLabelsEllipsize > 0) { + setLabelEllipsize(label) + } + label.setMaxLines(mLabelsMaxLines) + label.updateBackground() + + label.setTextSize(TypedValue.COMPLEX_UNIT_PX, mLabelsTextSize) + label.setTextColor(mLabelsTextColor) + + var left = mLabelsPaddingLeft + var top = mLabelsPaddingTop + if (mLabelsShowShadow) { + left += (fab.shadowRadius + abs(fab.shadowXOffset.toDouble())).toInt() + top += (fab.shadowRadius + abs(fab.shadowYOffset.toDouble())).toInt() + } + + label.setPadding( + left, + top, + mLabelsPaddingLeft, + mLabelsPaddingTop + ) } - label.setMaxLines(mLabelsMaxLines) - label.updateBackground() - - label.setTextSize(TypedValue.COMPLEX_UNIT_PX, mLabelsTextSize) - label.setTextColor(mLabelsTextColor) - - var left = mLabelsPaddingLeft - var top = mLabelsPaddingTop - if (mLabelsShowShadow) { - left += (fab.shadowRadius + abs(fab.shadowXOffset.toDouble())).toInt() - top += (fab.shadowRadius + abs(fab.shadowYOffset.toDouble())).toInt() - } - - label.setPadding( - left, - top, - mLabelsPaddingLeft, - mLabelsPaddingTop - ) if (mLabelsMaxLines < 0 || mLabelsSingleLine) { label.setSingleLine(mLabelsSingleLine) @@ -1224,7 +1271,9 @@ class FloatingActionMenu @JvmOverloads constructor( private const val OPEN_UP = 0 private const val OPEN_DOWN = 1 - private const val LABELS_POSITION_LEFT = 0 - private const val LABELS_POSITION_RIGHT = 1 + // 💡 [수정] 상수 공개 선언 및 CENTER 속성(2) 추가 + const val LABELS_POSITION_LEFT = 0 + const val LABELS_POSITION_RIGHT = 1 + const val LABELS_POSITION_CENTER = 2 } } \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TaskAggregator.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TaskAggregator.kt index 2ab6dadd..b31edd2b 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/TaskAggregator.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TaskAggregator.kt @@ -109,12 +109,16 @@ object TaskAggregator { // [일괄 저장] if (allFeeds.isNotEmpty()) { - WorkersDb.getRealm().write { - // 1. 새 데이터 저장 - allFeeds.forEach { feed -> - copyToRealm(feed, UpdatePolicy.ALL) + try { + WorkersDb.getRealm().write { + // 1. 새 데이터 저장 + allFeeds.forEach { feed -> + try { copyToRealm(feed, UpdatePolicy.ERROR) }catch (e: Exception){} + + } } - } + }catch (e: Exception){} + } Blog.LOGE("NewsFeed Aggregation finished in ${System.currentTimeMillis() - startTime}ms. Items: ${allFeeds.size}") diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt index 4df7b0aa..935bc348 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt @@ -28,6 +28,7 @@ import bums.lunatic.launcher.utils.Blog import com.frostwire.jlibtorrent.swig.error_code import com.frostwire.jlibtorrent.swig.libtorrent import com.frostwire.jlibtorrent.swig.settings_pack +import com.frostwire.jlibtorrent.swig.torrent_status import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable.isActive @@ -41,8 +42,10 @@ import kotlinx.coroutines.launch data class TorrentTask( val infoHash: String, val name: String, - val progress: Float, // 0.0 ~ 100.0 - val isPaused: Boolean + val progress: Float, + val isPaused: Boolean, + val isQueued: Boolean, // 대기열 여부 + val stateText: String ) class TorrentService : Service() { @@ -87,6 +90,16 @@ class TorrentService : Service() { } else if (hasActiveTorrents && taskCount == 0) { // 활성화된 적이 있었는데 0개가 되었다면 (모두 완료되어 이동되었거나 삭제됨) println("TorrentService: 모든 작업이 완료되어 서비스를 자동 종료합니다.") + + try { + if (tempDir.exists()) { + tempDir.listFiles()?.forEach { it.deleteRecursively() } + println("TorrentService: 임시 다운로드 폴더 정리를 완료했습니다.") + } + } catch (e: Exception) { + e.printStackTrace() + } + stopForeground(true) // 💡 상단바 알림 즉시 제거 stopSelf() // 💡 서비스 스스로 종료 (메모리 해제) break // 무한 루프 탈출 @@ -103,6 +116,30 @@ class TorrentService : Service() { if (!handle.isValid) continue val status = handle.status() + val state = status.state() + +// 1. 현재 다운로드 대기 중(Queued)인지 확인 +// 'auto_managed'이면서, 실제 'downloading' 상태가 아닌 경우를 체크합니다. + val isQueued = status.flags().and_(libtorrent.getAuto_managed()).nonZero() && + (state.swig() == torrent_status.state_t.downloading_metadata.swigValue() || + state.swig() == torrent_status.state_t.checking_files.swigValue() || + state.swig() == torrent_status.state_t.checking_resume_data.swigValue()) + +// 2. 더 확실한 방법 (상태값이 'queued_for_checking' 등인 경우 포함) +// 실제 데이터 전송이 일어나지 않고 대기 순번을 기다리는 상태인지 확인 + val isDownloading = (state.swig() == torrent_status.state_t.downloading.swigValue()) + val isFinished = status.isFinished + val isPaused = status.flags().and_(libtorrent.getPaused()).nonZero() + val stateText = when { + status.flags().and_(libtorrent.getPaused()).nonZero() -> "일시정지" + state.swig() == torrent_status.state_t.checking_files.swigValue() -> "파일 검사 중" + state.swig() == torrent_status.state_t.downloading_metadata.swigValue() -> "메타데이터 수신 중" + state.swig() == torrent_status.state_t.downloading.swigValue() -> "다운로드 중" + state.swig() == torrent_status.state_t.finished.swigValue() || state.swig() == torrent_status.state_t.seeding.swigValue() -> "완료" + else -> "대기 중" // activeDownloads 제한에 걸려 대기하는 경우 여기로 들어옵니다. + } +// "일시정지도 아니고, 완료도 아닌데, 현재 다운로드 중도 아니면" -> 대기열 상태로 간주 + val realIsQueued = !isPaused && !isFinished && !isDownloading val hashStr = status.infoHash().toString() var rawName = status.name() @@ -133,7 +170,9 @@ class TorrentService : Service() { infoHash = hashStr, name = displayName, progress = status.progress() * 100f, - isPaused = status.flags().and_(com.frostwire.jlibtorrent.swig.libtorrent.getPaused()).nonZero() + isPaused = status.flags().and_(com.frostwire.jlibtorrent.swig.libtorrent.getPaused()).nonZero(), + isQueued = isQueued, + stateText = stateText ) ) } @@ -199,12 +238,24 @@ class TorrentService : Service() { sp.setBoolean(settings_pack.bool_types.enable_dht.swigValue(), true) // DHT 활성화 // sp.setBoolean(settings_pack.bool_types.enable_pex.swigValue(), true) // Peer Exchange (피어 교환) sp.setBoolean(settings_pack.bool_types.enable_lsd.swigValue(), true) // 로컬 네트워크 탐색 + sp.setBoolean(settings_pack.bool_types.dont_count_slow_torrents.swigValue(), true) + // 2. '느리다'고 판단할 다운로드 속도 기준 (예: 10KB/s 미만) + sp.setInteger(settings_pack.int_types.inactive_down_rate.swigValue(), 10 * 1024) +// 3. '느리다'고 판단할 업로드 속도 기준 (예: 5KB/s 미만) + sp.setInteger(settings_pack.int_types.inactive_up_rate.swigValue(), 5 * 1024) + +// 4. 이 느린 속도가 몇 초간 지속될 때 대기열로 미룰 것인지 판단 (예: 20초) + sp.setInteger(settings_pack.int_types.inactivity_timeout.swigValue(), 20) + +// [3] 얼마나 오래 느려야 대기열로 보낼지 결정 (초 단위) + sp.setInteger(settings_pack.int_types.auto_manage_interval.swigValue(), 20) // [3] 모바일 맞춤형 연결 최적화 // 안드로이드는 데스크탑처럼 수천 개를 뚫으면 공유기가 뻗거나 앱 OOM이 발생할 수 있습니다. sp.connectionsLimit(400) // 글로벌 최대 연결 수 (기본값 보통 200) - sp.activeDownloads(3) // 동시에 속도를 끌어올릴 다운로드 개수 (너무 많으면 속도가 분산됨) - sp.activeLimit(5) // 활성 상태(업/다운 포함) 유지 최대 개수 + sp.activeDownloads(4) // 동시에 속도를 끌어올릴 다운로드 개수 (너무 많으면 속도가 분산됨) + sp.activeLimit(6) // 활성 상태(업/다운 포함) 유지 최대 개수 + sp.activeSeeds(2) // 세팅을 세션에 반영 session.applySettings(sp) @@ -254,15 +305,14 @@ class TorrentService : Service() { println("TorrentService: 마그넷 파싱 에러 - ${error.message()} / URI: $magnetUri") return } - swigParams.max_connections = 150 + swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed()) + swigParams.max_connections = Int.MAX_VALUE // 다운로드/업로드 제한 해제 (기본값이 -1이긴 하나 명시적으로 박아줌) swigParams.download_limit = -1 swigParams.upload_limit = -1 - // 참고: 만약 "순차 다운로드(동영상 스트리밍용)"가 필요하다면 아래 주석을 푸세요. // 단, 전체 다운로드 완료 속도는 일반 다운로드보다 느려집니다. // swigParams.flags = swigParams.flags.or_(libtorrent.getSequential_download()) - swigParams.setSave_path(tempDir.absolutePath) session.swig().async_add_torrent(swigParams) @@ -289,7 +339,10 @@ class TorrentService : Service() { /** 제어 기능 */ fun pauseTorrent(infoHash: String) { - session.find(Sha1Hash(infoHash))?.pause() + session.find(Sha1Hash(infoHash))?.let { + it.pause() + it.queuePositionBottom() + } } diff --git a/app/src/main/res/layout/bottom_sheet_app_drawer.xml b/app/src/main/res/layout/bottom_sheet_app_drawer.xml index c00804cd..f8d81d2c 100644 --- a/app/src/main/res/layout/bottom_sheet_app_drawer.xml +++ b/app/src/main/res/layout/bottom_sheet_app_drawer.xml @@ -125,23 +125,22 @@ + android:layout_height="match_parent" + android:background="@color/black" + android:spinnerMode="dropdown" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_torrent_task.xml b/app/src/main/res/layout/item_torrent_task.xml index 1a004a49..38ff1e5c 100644 --- a/app/src/main/res/layout/item_torrent_task.xml +++ b/app/src/main/res/layout/item_torrent_task.xml @@ -11,13 +11,13 @@ + + + - + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index fce82445..6fd93af7 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -29,6 +29,7 @@ + @@ -62,6 +63,7 @@ + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f268b268..58f69065 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -44,7 +44,7 @@ wrap_content 35dp center - @color/tabs_black + @color/bottom_option visible