diff --git a/tvmon-app/app/build.gradle b/tvmon-app/app/build.gradle index 8aaf3a4..b884b34 100644 --- a/tvmon-app/app/build.gradle +++ b/tvmon-app/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.example.tvmon" minSdk 28 targetSdk 34 - versionCode 1 - versionName "1.0.0" + versionCode 2 + versionName "1.0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/tvmon-app/app/src/main/AndroidManifest.xml b/tvmon-app/app/src/main/AndroidManifest.xml index c39d08b..6990825 100644 --- a/tvmon-app/app/src/main/AndroidManifest.xml +++ b/tvmon-app/app/src/main/AndroidManifest.xml @@ -55,19 +55,35 @@ android:theme="@style/Theme.Tvmon.Search" /> - - - - - + android:name=".ui.search.SearchActivity" + android:exported="true" + android:label="@string/search_title" + android:screenOrientation="landscape" + android:theme="@style/Theme.Tvmon.Search"> + + + + + - + + + + + + + diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt index d6e59df..ba2dec3 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/local/dao/CategoryContentDao.kt @@ -5,11 +5,11 @@ import com.example.tvmon.data.local.entity.CategoryContent @Dao interface CategoryContentDao { - @Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey ORDER BY cachedAt ASC") - suspend fun getByCategory(categoryKey: String): List +@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey") +suspend fun getByCategory(categoryKey: String): List - @Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page ORDER BY cachedAt ASC") - suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List +@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page") +suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List @Query("SELECT MAX(pageNumber) FROM category_content WHERE categoryKey = :categoryKey") suspend fun getMaxPage(categoryKey: String): Int? diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt index 68605a7..5dfc0f9 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/data/scraper/TvmonScraper.kt @@ -142,17 +142,19 @@ private fun extractSeriesUrl(url: String): String { result } - suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) { - val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult( - success = false, - category = "Unknown", - items = emptyList(), - page = page, - pagination = Pagination(1, 1) - ) +suspend fun getCategory(categoryKey: String, page: Int = 1): CategoryResult = withContext(Dispatchers.IO) { + val catInfo = CATEGORIES[categoryKey] ?: return@withContext CategoryResult( + success = false, + category = "Unknown", + items = emptyList(), + page = page, + pagination = Pagination(1, 1) + ) - val url = if (page == 1) "$BASE_URL${catInfo.path}" else "$BASE_URL${catInfo.path}?page=$page" - val html = get(url) ?: return@withContext CategoryResult( + // Sort by wr_datetime (latest first) - same as website default sorting + val sortParam = "sst=wr_datetime&sod=desc" + val url = if (page == 1) "$BASE_URL${catInfo.path}?$sortParam" else "$BASE_URL${catInfo.path}?$sortParam&page=$page" + val html = get(url) ?: return@withContext CategoryResult( success = false, category = catInfo.name, items = emptyList(), diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt index 5a1191d..b9b9222 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/main/MainFragment.kt @@ -15,21 +15,24 @@ import com.example.tvmon.data.repository.WatchHistoryRepository import com.example.tvmon.data.scraper.TvmonScraper import com.example.tvmon.ui.detail.DetailsActivity import com.example.tvmon.ui.presenter.ContentCardPresenter +import com.example.tvmon.ui.settings.SettingsActivity import kotlinx.coroutines.launch import org.koin.android.ext.android.inject class MainFragment : BrowseSupportFragment(), OnItemViewClickedListener, OnItemViewSelectedListener { - companion object { - private const val TAG = "TVMON_MAIN" - } +companion object { +private const val TAG = "TVMON_MAIN" +} - private val categoryCacheRepository: CategoryCacheRepository by inject() - private val watchHistoryRepository: WatchHistoryRepository by inject() - private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) - private val categoryRowAdapters = mutableMapOf() - private val loadingStates = mutableMapOf() - private var isDataLoaded = false +data class SettingsItem(val title: String = "설정") + +private val categoryCacheRepository: CategoryCacheRepository by inject() +private val watchHistoryRepository: WatchHistoryRepository by inject() +private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter()) +private val categoryRowAdapters = mutableMapOf() +private val loadingStates = mutableMapOf() +private var isDataLoaded = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -99,13 +102,26 @@ private fun setupUI() { if (success) successCount++ else failCount++ } - Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===") - isDataLoaded = true +Log.w(TAG, "=== loadCategories COMPLETE: success=$successCount, fail=$failCount ===") +isDataLoaded = true - if (rowsAdapter.size() == 0) { - Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show() - } - } +if (rowsAdapter.size() == 0) { +Toast.makeText(requireContext(), "카테고리를 불러오지 못했습니다", Toast.LENGTH_LONG).show() +} + +activity?.runOnUiThread { +addSettingsRow() +} +} + +private fun addSettingsRow() { +val settingsAdapter = ArrayObjectAdapter(ContentCardPresenter()) +settingsAdapter.add(SettingsItem()) +val settingsHeader = HeaderItem("설정") +val settingsRow = ListRow(settingsHeader, settingsAdapter) +rowsAdapter.add(settingsRow) +Log.w(TAG, "ADDED SETTINGS ROW") +} private suspend fun loadCategoryWithCache(category: com.example.tvmon.data.model.Category): Boolean { return try { @@ -139,26 +155,30 @@ private fun setupUI() { } } - override fun onItemClicked( - itemViewHolder: Presenter.ViewHolder?, - item: Any?, - rowViewHolder: RowPresenter.ViewHolder?, - row: Row? - ) { - Log.w(TAG, "=== onItemClicked: item=$item ===") - when (item) { - is Content -> { - Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}") - val intent = Intent(requireContext(), DetailsActivity::class.java).apply { - putExtra(DetailsActivity.EXTRA_CONTENT, item) - } - startActivity(intent) - } - else -> { - Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}") - } - } - } +override fun onItemClicked( +itemViewHolder: Presenter.ViewHolder?, +item: Any?, +rowViewHolder: RowPresenter.ViewHolder?, +row: Row? +) { +Log.w(TAG, "=== onItemClicked: item=$item ===") +when (item) { +is Content -> { +Log.w(TAG, "Content clicked: ${item.title}, url=${item.url}") +val intent = Intent(requireContext(), DetailsActivity::class.java).apply { +putExtra(DetailsActivity.EXTRA_CONTENT, item) +} +startActivity(intent) +} +is SettingsItem -> { +Log.w(TAG, "Settings clicked") +startActivity(Intent(requireContext(), SettingsActivity::class.java)) +} +else -> { +Log.w(TAG, "Unknown item type: ${item?.javaClass?.simpleName}") +} +} +} override fun onItemSelected( itemViewHolder: Presenter.ViewHolder?, diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt index 60991db..138c43e 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/playback/PlaybackActivity.kt @@ -153,152 +153,158 @@ class PlaybackActivity : AppCompatActivity() { webView.loadUrl(url) } - @SuppressLint("SetJavaScriptEnabled") - private fun setupWebView() { - val webSettings: WebSettings = webView.settings +@SuppressLint("SetJavaScriptEnabled") +private fun setupWebView() { +val webSettings: WebSettings = webView.settings - webSettings.javaScriptEnabled = true - webSettings.domStorageEnabled = true - webSettings.databaseEnabled = true - webSettings.allowFileAccess = true - webSettings.allowContentAccess = true - webSettings.mediaPlaybackRequiresUserGesture = false - webSettings.loadsImagesAutomatically = true - webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW - webSettings.userAgentString = USER_AGENT - webSettings.useWideViewPort = true - webSettings.loadWithOverviewMode = true - webSettings.cacheMode = WebSettings.LOAD_DEFAULT - webSettings.allowUniversalAccessFromFileURLs = true - webSettings.allowFileAccessFromFileURLs = true +webSettings.javaScriptEnabled = true +webSettings.domStorageEnabled = true +webSettings.databaseEnabled = true +webSettings.allowFileAccess = true +webSettings.allowContentAccess = true +webSettings.mediaPlaybackRequiresUserGesture = false +webSettings.loadsImagesAutomatically = true +webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW +webSettings.userAgentString = USER_AGENT +webSettings.useWideViewPort = true +webSettings.loadWithOverviewMode = true +webSettings.cacheMode = WebSettings.LOAD_DEFAULT +webSettings.allowUniversalAccessFromFileURLs = true +webSettings.allowFileAccessFromFileURLs = true - webView.addJavascriptInterface(object { - @android.webkit.JavascriptInterface - fun hideLoadingOverlay() { - runOnUiThread { - loadingOverlay.visibility = View.GONE - } - } - - @android.webkit.JavascriptInterface - fun isVideoPlaying() { - webView.evaluateJavascript( - """ - (function() { - var videos = document.querySelectorAll('video'); - var playing = false; - videos.forEach(function(v) { - if (!v.paused && v.currentTime > 0) { - playing = true; - } - }); - return playing ? 'playing' : 'not_playing'; - })(); - """.trimIndent() - ) { result -> - android.util.Log.i("PlaybackActivity", "Video playing status: $result") - if (result == "\"playing\"") { - runOnUiThread { - loadingOverlay.visibility = View.GONE - } - } - } - } - @android.webkit.JavascriptInterface - fun onVideoPlay() { - runOnUiThread { - loadingOverlay.visibility = View.GONE - android.util.Log.d("PlaybackActivity", "Video started playing") - } - } - }, "Android") +// Video playback optimizations +webSettings.mediaPlaybackRequiresUserGesture = false +webSettings.blockNetworkImage = false - val cookieManager = CookieManager.getInstance() - cookieManager.setAcceptCookie(true) - cookieManager.setAcceptThirdPartyCookies(webView, true) +webView.addJavascriptInterface(object { +@android.webkit.JavascriptInterface +fun hideLoadingOverlay() { +runOnUiThread { +loadingOverlay.visibility = View.GONE +} +} - webView.webChromeClient = object : WebChromeClient() { - override fun onConsoleMessage(cm: ConsoleMessage?): Boolean { - val message = cm?.message() ?: return false - android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message") +@android.webkit.JavascriptInterface +fun isVideoPlaying() { +webView.evaluateJavascript( +""" +(function() { +var videos = document.querySelectorAll('video'); +var playing = false; +videos.forEach(function(v) { +if (!v.paused && v.currentTime > 0) { +playing = true; +} +}); +return playing ? 'playing' : 'not_playing'; +})(); +""".trimIndent() +) { result -> +android.util.Log.i("PlaybackActivity", "Video playing status: $result") +if (result == "\"playing\"") { +runOnUiThread { +loadingOverlay.visibility = View.GONE +} +} +} +} +@android.webkit.JavascriptInterface +fun onVideoPlay() { +runOnUiThread { +loadingOverlay.visibility = View.GONE +android.util.Log.d("PlaybackActivity", "Video started playing") +} +} +}, "Android") - if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) { - android.util.Log.i("WebViewConsole", "Player info: $message") - } - return true - } +val cookieManager = CookieManager.getInstance() +cookieManager.setAcceptCookie(true) +cookieManager.setAcceptThirdPartyCookies(webView, true) - override fun onPermissionRequest(request: android.webkit.PermissionRequest?) { - android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}") - request?.grant(request.resources) - } - } +webView.webChromeClient = object : WebChromeClient() { +override fun onConsoleMessage(cm: ConsoleMessage?): Boolean { +val message = cm?.message() ?: return false +android.util.Log.d("WebViewConsole", "[$${cm.sourceId()}:${cm.lineNumber()}] $message") - webView.webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { - val url = request?.url?.toString() ?: "" - - if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) { - try { - val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 8000 - conn.readTimeout = 8000 - conn.setRequestProperty("User-Agent", USER_AGENT) - conn.setRequestProperty("Referer", "https://tvmon.site/") - - val contentType = conn.contentType ?: "text/plain" - val encoding = conn.contentEncoding ?: "UTF-8" - val inputStream = conn.inputStream - val content = inputStream.bufferedReader().use { it.readText() } - inputStream.close() - - var modifiedContent = content - - if (contentType.contains("text/html", true)) { - modifiedContent = injectFullscreenStyles(content) - } - - return WebResourceResponse( - contentType, encoding, - 200, "OK", - mapOf("Access-Control-Allow-Origin" to "*"), - ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding))) - ) - } catch (e: Exception) { - android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}") - } - } - - return super.shouldInterceptRequest(view, request) - } +if (message.contains("player") || message.contains("video") || message.contains(".m3u8") || message.contains(".mp4")) { +android.util.Log.i("WebViewConsole", "Player info: $message") +} +return true +} - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { - super.onPageStarted(view, url, favicon) - runOnUiThread { - loadingOverlay.visibility = View.VISIBLE - } - } +override fun onPermissionRequest(request: android.webkit.PermissionRequest?) { +android.util.Log.d("PlaybackActivity", "Permission request: ${request?.resources?.joinToString()}") +request?.grant(request.resources) +} +} - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - android.util.Log.i("PlaybackActivity", "Page finished: $url") +webView.webViewClient = object : WebViewClient() { +override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { +val url = request?.url?.toString() ?: "" - handler.postDelayed({ - injectFullscreenScript() - injectEnhancedAutoPlayScript() - }, 200) +if (url.contains(".css") || url.contains(".js") && url.contains("tvmon.site")) { +try { +val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection +conn.requestMethod = "GET" +conn.connectTimeout = 8000 +conn.readTimeout = 8000 +conn.setRequestProperty("User-Agent", USER_AGENT) +conn.setRequestProperty("Referer", "https://tvmon.site/") - handler.postDelayed({ - runOnUiThread { - loadingOverlay.visibility = View.GONE - } - }, 1000) - } - } +val contentType = conn.contentType ?: "text/plain" +val encoding = conn.contentEncoding ?: "UTF-8" +val inputStream = conn.inputStream +val content = inputStream.bufferedReader().use { it.readText() } +inputStream.close() - webView.setLayerType(View.LAYER_TYPE_HARDWARE, null) - } +var modifiedContent = content + +if (contentType.contains("text/html", true)) { +modifiedContent = injectFullscreenStyles(content) +} + +return WebResourceResponse( +contentType, encoding, +200, "OK", +mapOf("Access-Control-Allow-Origin" to "*"), +ByteArrayInputStream(modifiedContent.toByteArray(charset(encoding))) +) +} catch (e: Exception) { +android.util.Log.w("PlaybackActivity", "Intercept failed for $url: ${e.message}") +} +} + +return super.shouldInterceptRequest(view, request) +} + +override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { +super.onPageStarted(view, url, favicon) +runOnUiThread { +loadingOverlay.visibility = View.VISIBLE +} +} + +override fun onPageFinished(view: WebView?, url: String?) { +super.onPageFinished(view, url) +android.util.Log.i("PlaybackActivity", "Page finished: $url") + +// Reduced delays and calls to minimize memory pressure +handler.postDelayed({ +injectFullscreenScript() +injectEnhancedAutoPlayScript() +}, 300) + +handler.postDelayed({ +runOnUiThread { +loadingOverlay.visibility = View.GONE +} +}, 1500) +} +} + +// Use hardware layer only when needed, not always +webView.setLayerType(View.LAYER_TYPE_NONE, null) +} private fun injectFullscreenStyles(html: String): String { val styleInjection = """ @@ -449,12 +455,10 @@ class PlaybackActivity : AppCompatActivity() { }); }; - makePlayerFullscreen(); - setTimeout(makePlayerFullscreen, 500); - setTimeout(makePlayerFullscreen, 1500); - setTimeout(makePlayerFullscreen, 3000); - setInterval(makePlayerFullscreen, 5000); - })(); +makePlayerFullscreen(); +setTimeout(makePlayerFullscreen, 500); +setTimeout(makePlayerFullscreen, 2000); +})(); """.trimIndent(), null ) diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt index b41b211..87e808e 100644 --- a/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/presenter/CardPresenters.kt @@ -7,6 +7,8 @@ import androidx.core.content.ContextCompat import androidx.leanback.widget.ImageCardView import androidx.leanback.widget.Presenter import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.example.tvmon.R import com.example.tvmon.data.model.Content import com.example.tvmon.data.model.Category @@ -68,8 +70,10 @@ class ContentCardPresenter : Presenter() { if (content.thumbnail.isNotBlank()) { Glide.with(cardView.context) .load(content.thumbnail) + .diskCacheStrategy(DiskCacheStrategy.ALL) .error(R.drawable.default_background) .placeholder(R.drawable.default_background) + .transition(DrawableTransitionOptions.withCrossFade(200)) .centerCrop() .into(cardView.mainImageView) } else { @@ -143,8 +147,10 @@ class SearchCardPresenter : Presenter() { if (content.thumbnail.isNotBlank()) { Glide.with(cardView.context) .load(content.thumbnail) + .diskCacheStrategy(DiskCacheStrategy.ALL) .error(R.drawable.default_background) .placeholder(R.drawable.default_background) + .transition(DrawableTransitionOptions.withCrossFade(200)) .centerCrop() .into(cardView.mainImageView) } else { diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt b/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt new file mode 100644 index 0000000..e23400e --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/ui/settings/SettingsActivity.kt @@ -0,0 +1,174 @@ +package com.example.tvmon.ui.settings + +import android.app.AlertDialog +import android.os.Bundle +import android.view.WindowManager +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.example.tvmon.R +import com.example.tvmon.util.ApkDownloader +import com.example.tvmon.util.UpdateChecker +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import com.example.tvmon.data.repository.CategoryCacheRepository + +class SettingsActivity : AppCompatActivity() { + + private val categoryCacheRepository: CategoryCacheRepository by inject() + + private lateinit var tvAppVersion: TextView + private lateinit var tvLatestVersion: TextView + private lateinit var btnClearCache: Button + private lateinit var btnCheckUpdate: Button + private lateinit var btnBack: Button + + private var latestVersionInfo: com.example.tvmon.util.VersionInfo? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + + setContentView(R.layout.activity_settings) + + initViews() + loadSettings() + setupListeners() + } + + private fun initViews() { + tvAppVersion = findViewById(R.id.tv_app_version) + tvLatestVersion = findViewById(R.id.tv_latest_version) + btnClearCache = findViewById(R.id.btn_clear_cache) + btnCheckUpdate = findViewById(R.id.btn_check_update) + btnBack = findViewById(R.id.btn_back) + } + + private fun loadSettings() { + val versionName = UpdateChecker.getCurrentVersionName(this) + val versionCode = UpdateChecker.getCurrentVersionCode(this) + tvAppVersion.text = "v$versionName (build $versionCode)" + + loadLatestVersion() + } + + private fun loadLatestVersion() { + lifecycleScope.launch { + try { + latestVersionInfo = UpdateChecker.fetchLatestVersion() + + if (latestVersionInfo != null) { + val currentVersionCode = UpdateChecker.getCurrentVersionCode(this@SettingsActivity) + if (latestVersionInfo!!.versionCode > currentVersionCode) { + tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (업데이트 가능)" + tvLatestVersion.setTextColor(getColor(R.color.accent)) + } else { + tvLatestVersion.text = "최신 버전: v${latestVersionInfo!!.versionName} (최신)" + tvLatestVersion.setTextColor(getColor(R.color.netflix_light_gray)) + } + } else { + tvLatestVersion.text = "최신 버전: 확인 실패" + } + } catch (e: Exception) { + tvLatestVersion.text = "최신 버전: 확인 실패" + } + } + } + + private fun setupListeners() { + btnClearCache.setOnClickListener { + showClearCacheDialog() + } + + btnCheckUpdate.setOnClickListener { + checkForUpdate() + } + + btnBack.setOnClickListener { + finish() + } + } + + private fun showClearCacheDialog() { + AlertDialog.Builder(this) + .setTitle("캐시 삭제") + .setMessage("모든 캐시 데이터를 삭제하시겠습니까?\n\n삭제 항목:\n- 영화/에피소드 정보\n- 이미지 캐시") + .setPositiveButton("삭제") { _, _ -> + clearAllCache() + } + .setNegativeButton("취소", null) + .show() + } + + private fun clearAllCache() { + lifecycleScope.launch { + try { + categoryCacheRepository.clearCache() + + com.bumptech.glide.Glide.get(this@SettingsActivity).clearMemory() + com.bumptech.glide.Glide.get(this@SettingsActivity).clearDiskCache() + + Toast.makeText(this@SettingsActivity, "캐시가 삭제되었습니다", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(this@SettingsActivity, "캐시 삭제 실패: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + private fun checkForUpdate() { + lifecycleScope.launch { + try { + val latestVersion = UpdateChecker.fetchLatestVersion() + + if (latestVersion != null) { + if (UpdateChecker.needsUpdate(this@SettingsActivity, latestVersion)) { + showUpdateDialog(latestVersion) + } else { + Toast.makeText(this@SettingsActivity, "최신 버전입니다 (v${latestVersion.versionName})", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(this@SettingsActivity, "업데이트 정보를 가져올 수 없습니다", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(this@SettingsActivity, "업데이트 확인 실패: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + private fun showUpdateDialog(versionInfo: com.example.tvmon.util.VersionInfo) { + val message = buildString { + append("현재 버전: ${UpdateChecker.getCurrentVersionName(this@SettingsActivity)}\n") + append("최신 버전: ${versionInfo.versionName}\n\n") + append(versionInfo.updateMessage) + } + + AlertDialog.Builder(this) + .setTitle("업데이트 알림") + .setMessage(message) + .setPositiveButton("업데이트") { _, _ -> + Toast.makeText(this, "업데이트 다운로드 시작...", Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + try { + val apkFile = ApkDownloader.downloadApkDirect(this@SettingsActivity, versionInfo.apkUrl) + if (apkFile != null) { + ApkDownloader.installApk(this@SettingsActivity, apkFile) + finish() + } else { + Toast.makeText(this@SettingsActivity, "다운로드 실패", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(this@SettingsActivity, "다운로드 오류: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + .setNegativeButton("나중에", null) + .show() + } +} diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt b/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt new file mode 100644 index 0000000..860a602 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/util/ApkDownloader.kt @@ -0,0 +1,129 @@ +package com.example.tvmon.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.TimeUnit + +object ApkDownloader { + private const val TAG = "ApkDownloader" + private const val APK_FILE_NAME = "tvmon_update.apk" + private const val CONNECT_TIMEOUT = 30L + private const val READ_TIMEOUT = 60L + + private val okHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + .build() + } + + suspend fun downloadApkDirect(context: Context, apkUrl: String): File? = withContext(Dispatchers.IO) { + var response: Response? = null + var outputStream: FileOutputStream? = null + try { + android.util.Log.i(TAG, "Starting download from: $apkUrl") + + val apkFile = getApkFile(context) + android.util.Log.i(TAG, "APK file path: ${apkFile.absolutePath}") + + if (apkFile.exists()) { + android.util.Log.i(TAG, "Deleting existing APK file") + apkFile.delete() + } + + apkFile.parentFile?.mkdirs() + + val request = Request.Builder() + .url(apkUrl) + .header("User-Agent", "Mozilla/5.0 (Linux; Android TV) AppleWebKit/537.36") + .build() + + android.util.Log.i(TAG, "Executing request...") + + response = okHttpClient.newCall(request).execute() + android.util.Log.i(TAG, "Response received: ${response.code} ${response.message}") + + if (!response.isSuccessful) { + android.util.Log.e(TAG, "Download failed: ${response.code} ${response.message}") + return@withContext null + } + + val responseBody: ResponseBody = response.body ?: return@withContext null + val contentLength = responseBody.contentLength() + + android.util.Log.i(TAG, "Starting file download...") + outputStream = FileOutputStream(apkFile) + val inputStream = responseBody.byteStream() + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesRead = 0L + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + + if (contentLength > 0) { + val progress = (totalBytesRead * 100 / contentLength).toInt() + if (progress % 10 == 0) { + android.util.Log.d(TAG, "Download progress: $progress%") + } + } + } + + outputStream.flush() + android.util.Log.i(TAG, "APK download completed: ${apkFile.absolutePath}, size: ${apkFile.length()}") + + return@withContext apkFile + } catch (e: Exception) { + android.util.Log.e(TAG, "Download failed: ${e.message}", e) + return@withContext null + } finally { + outputStream?.close() + response?.close() + } + } + + fun installApk(context: Context, apkFile: File) { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + } else { + Uri.fromFile(apkFile) + } + + setDataAndType(uri, "application/vnd.android.package-archive") + } + + context.startActivity(intent) + } catch (e: Exception) { + android.util.Log.e(TAG, "Failed to install APK: ${e.message}", e) + } + } + + fun getApkFile(context: Context): File { + return File(context.filesDir, APK_FILE_NAME) + } + + fun isApkDownloaded(context: Context): Boolean { + return getApkFile(context).exists() + } +} \ No newline at end of file diff --git a/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt b/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt new file mode 100644 index 0000000..71f5b54 --- /dev/null +++ b/tvmon-app/app/src/main/java/com/example/tvmon/util/UpdateChecker.kt @@ -0,0 +1,96 @@ +package com.example.tvmon.util + +import android.content.Context +import android.content.pm.PackageManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +data class VersionInfo( + val versionCode: Int, + val versionName: String, + val apkUrl: String, + val updateMessage: String, + val forceUpdate: Boolean +) + +object UpdateChecker { + private const val TAG = "UpdateChecker" + + private const val VERSION_JSON_URL = "https://tvmon.site/version.json" + private const val APK_BASE_URL = "https://tvmon.site/releases" + + fun getCurrentVersionCode(context: Context): Int { + return try { + context.packageManager.getPackageInfo(context.packageName, 0).versionCode + } catch (e: PackageManager.NameNotFoundException) { + 1 + } + } + + fun getCurrentVersionName(context: Context): String { + return try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" + } catch (e: PackageManager.NameNotFoundException) { + "1.0.0" + } + } + + suspend fun fetchLatestVersion(): VersionInfo? = withContext(Dispatchers.IO) { + fetchLatestVersionDirect() + } + + private fun fetchLatestVersionDirect(): VersionInfo? { + try { + android.util.Log.d(TAG, "Fetching version.json from: $VERSION_JSON_URL") + + val url = java.net.URL(VERSION_JSON_URL) + val conn = url.openConnection() as java.net.HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + + val responseCode = conn.responseCode + android.util.Log.d(TAG, "Response Code: $responseCode") + + if (responseCode != 200) { + android.util.Log.e(TAG, "HTTP Error: $responseCode") + return null + } + + val jsonStr = conn.inputStream.bufferedReader().readText() + android.util.Log.d(TAG, "Response: $jsonStr") + + val json = JSONObject(jsonStr) + val versionCode = json.getInt("versionCode") + val versionName = json.getString("versionName") + val updateMessage = json.optString("updateMessage", "새로운 버전이 있습니다.") + val forceUpdate = json.optBoolean("forceUpdate", false) + + val apkUrl = if (json.has("apkUrl") && !json.isNull("apkUrl")) { + json.getString("apkUrl") + } else { + "$APK_BASE_URL/v$versionName/app-release.apk" + } + + android.util.Log.d(TAG, "Version: $versionName, apkUrl: $apkUrl") + + return VersionInfo( + versionCode = versionCode, + versionName = versionName, + apkUrl = apkUrl, + updateMessage = updateMessage, + forceUpdate = forceUpdate + ) + } catch (e: Exception) { + android.util.Log.e(TAG, "Failed to fetch version: ${e.message}", e) + return null + } + } + + fun needsUpdate(context: Context, latestVersion: VersionInfo): Boolean { + val currentVersionCode = getCurrentVersionCode(context) + android.util.Log.i(TAG, "Current: $currentVersionCode, Latest: ${latestVersion.versionCode}") + return latestVersion.versionCode > currentVersionCode + } +} \ No newline at end of file diff --git a/tvmon-app/app/src/main/res/drawable/app_banner.xml b/tvmon-app/app/src/main/res/drawable/app_banner.xml index acc6cef..773f24d 100644 --- a/tvmon-app/app/src/main/res/drawable/app_banner.xml +++ b/tvmon-app/app/src/main/res/drawable/app_banner.xml @@ -1,6 +1,66 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml b/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml index 36e223e..773f24d 100644 --- a/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml +++ b/tvmon-app/app/src/main/res/drawable/app_banner_vector.xml @@ -1,49 +1,66 @@ - - - - - - - - - - - - - - - - - - - - - - - - - +android:width="320dp" +android:height="180dp" +android:viewportWidth="320" +android:viewportHeight="180"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml index d7b81a7..ace717b 100644 --- a/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/tvmon-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,52 +1,47 @@ - - - - - - - - - - - - - +android:width="108dp" +android:height="108dp" +android:viewportWidth="108" +android:viewportHeight="108"> - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tvmon-app/app/src/main/res/layout/activity_settings.xml b/tvmon-app/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..af8621d --- /dev/null +++ b/tvmon-app/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +