v1.0.1 업데이트

- 정렬 순서 wr_datetime DESC로 변경 (최신순 정렬)
- 런처 아이콘 개선 (Netflix 스타일: 빨간 배경 + 흰 아이콘)
- 웹뷰 동영상 재생 최적화 (_LAYER_TYPE_NONE, setInterval 제거)
- 설정 버튼 카테고리 하단 배치
- 메모리 누수 수정 (WebView cleanup)
This commit is contained in:
tvmon-dev
2026-04-16 15:45:32 +09:00
parent a2677a3294
commit 55c45cd399
27 changed files with 989 additions and 306 deletions

View File

@@ -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"
}

View File

@@ -55,19 +55,35 @@
android:theme="@style/Theme.Tvmon.Search" />
<activity
android:name=".ui.search.SearchActivity"
android:exported="true"
android:label="@string/search_title"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
android:name=".ui.search.SearchActivity"
android:exported="true"
android:label="@string/search_title"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon.Search">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
</activity>
</application>
<activity
android:name=".ui.settings.SettingsActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Tvmon" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -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<CategoryContent>
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getByCategory(categoryKey: String): List<CategoryContent>
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page ORDER BY cachedAt ASC")
suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List<CategoryContent>
@Query("SELECT * FROM category_content WHERE categoryKey = :categoryKey AND pageNumber <= :page")
suspend fun getByCategoryUntilPage(categoryKey: String, page: Int): List<CategoryContent>
@Query("SELECT MAX(pageNumber) FROM category_content WHERE categoryKey = :categoryKey")
suspend fun getMaxPage(categoryKey: String): Int?

View File

@@ -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(),

View File

@@ -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<String, ArrayObjectAdapter>()
private val loadingStates = mutableMapOf<String, Boolean>()
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<String, ArrayObjectAdapter>()
private val loadingStates = mutableMapOf<String, Boolean>()
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?,

View File

@@ -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
)

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -1,6 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF6B6B" />
<corners android:radius="8dp" />
</shape>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background with Netflix dark gray -->
<path
android:fillColor="#141414"
android:pathData="M0,0h320v180h-320z"/>
<!-- TV Screen - Netflix Red -->
<path
android:fillColor="#E50914"
android:pathData="M110,50h100v70h-100z"/>
<!-- TV Stand -->
<path
android:fillColor="#E50914"
android:pathData="M140,125h40v8h-40z"/>
<path
android:fillColor="#E50914"
android:pathData="M125,133h70v4h-70z"/>
<!-- Play Button - White -->
<path
android:fillColor="#FFFFFF"
android:pathData="M145,70l30,20l-30,20z"/>
<!-- tvmon Text - White -->
<!-- Letter 't' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,148h6v-3h-6v-4h10v18h-4v-11h-6z"/>
<!-- Letter 'v' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M115,141h4l5,15l5,-15h4l-7,18h-4z"/>
<!-- Letter 'm' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M140,159v-18h5v3c1.5,-2 3,-3 5,-3c2,0 3.5,1 4.5,2.5c1.5,-2 3.5,-2.5 5.5,-2.5c4,0 6.5,2.5 6.5,6.5v11.5h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11z"/>
<!-- Letter 'o' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M175,148c0,-5.5 4.5,-10 10,-10s10,4.5 10,10s-4.5,10 -10,10s-10,-4.5 -10,-10zm4,0c0,3.5 2.5,6 6,6s6,-2.5 6,-6s-2.5,-6 -6,-6s-6,2.5 -6,6z"/>
<!-- Letter 'n' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M205,159v-18h5v3c2,-2 4,-3 6,-3c5,0 8,3 8,8v10h-5v-10c0,-2.5 -2,-4.5 -4.5,-4.5s-4.5,2 -4.5,4.5v10z"/>
<!-- Right side - decorative lines -->
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,40h40v4h-40z"/>
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,55h35v4h-35z"/>
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,70h30v4h-30z"/>
</vector>

View File

@@ -1,49 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background -->
<path
android:fillColor="#1F1F1F"
android:pathData="M0,0h320v180h-320z"/>
<!-- Gradient Overlay -->
<path
android:fillColor="#80000000"
android:pathData="M0,0h320v180h-320z"/>
<!-- TV Icon -->
<group
android:translateX="120"
android:translateY="40">
<!-- TV Screen -->
<path
android:fillColor="#FF6B6B"
android:pathData="M0,0h80v60h-80z"/>
<!-- TV Stand -->
<path
android:fillColor="#FF6B6B"
android:pathData="M20,65h40v5h-40z"/>
<path
android:fillColor="#FF6B6B"
android:pathData="M10,70h60v3h-60z"/>
<!-- Play Icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,18l25,17l-25,17z"/>
</group>
<!-- App Name -->
<path
android:fillColor="#FFFFFF"
android:pathData="M120,130h80v4h-80z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M140,136h40v3h-40z"/>
</vector>
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Background with Netflix dark gray -->
<path
android:fillColor="#141414"
android:pathData="M0,0h320v180h-320z"/>
<!-- TV Screen - Netflix Red -->
<path
android:fillColor="#E50914"
android:pathData="M110,50h100v70h-100z"/>
<!-- TV Stand -->
<path
android:fillColor="#E50914"
android:pathData="M140,125h40v8h-40z"/>
<path
android:fillColor="#E50914"
android:pathData="M125,133h70v4h-70z"/>
<!-- Play Button - White -->
<path
android:fillColor="#FFFFFF"
android:pathData="M145,70l30,20l-30,20z"/>
<!-- tvmon Text - White -->
<!-- Letter 't' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M100,148h6v-3h-6v-4h10v18h-4v-11h-6z"/>
<!-- Letter 'v' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M115,141h4l5,15l5,-15h4l-7,18h-4z"/>
<!-- Letter 'm' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M140,159v-18h5v3c1.5,-2 3,-3 5,-3c2,0 3.5,1 4.5,2.5c1.5,-2 3.5,-2.5 5.5,-2.5c4,0 6.5,2.5 6.5,6.5v11.5h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11h-5v-11c0,-2 -1.5,-3.5 -3.5,-3.5s-3.5,1.5 -3.5,3.5v11z"/>
<!-- Letter 'o' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M175,148c0,-5.5 4.5,-10 10,-10s10,4.5 10,10s-4.5,10 -10,10s-10,-4.5 -10,-10zm4,0c0,3.5 2.5,6 6,6s6,-2.5 6,-6s-2.5,-6 -6,-6s-6,2.5 -6,6z"/>
<!-- Letter 'n' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M205,159v-18h5v3c2,-2 4,-3 6,-3c5,0 8,3 8,8v10h-5v-10c0,-2.5 -2,-4.5 -4.5,-4.5s-4.5,2 -4.5,4.5v10z"/>
<!-- Right side - decorative lines -->
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,40h40v4h-40z"/>
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,55h35v4h-35z"/>
<path
android:fillColor="#E50914"
android:fillAlpha="0.3"
android:pathData="M250,70h30v4h-30z"/>
</vector>

View File

@@ -1,52 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background Circle -->
<path
android:fillColor="#FF6B6B"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- TV Screen -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38h40v28h-40z"/>
<!-- TV Stand -->
<path
android:fillColor="#FFFFFF"
android:pathData="M44,70h20v4h-20z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M40,74h28v2h-28z"/>
<!-- Play Button -->
<path
android:fillColor="#FF6B6B"
android:pathData="M50,44l12,8l-12,8z"/>
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- tvmon Text -->
<!-- Letter 't' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M38,82h4v-2h-4v-2h6v10h-2v-6h-4z"/>
<!-- Letter 'v' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M45,78h2l2,6l2,-6h2l-3,8h-2z"/>
<!-- Letter 'm' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,86v-8h2v1c0.5,-0.7 1.2,-1 2,-1c0.9,0 1.6,0.4 2,1.1c0.5,-0.7 1.3,-1.1 2.2,-1.1c1.6,0 2.8,1.2 2.8,2.8v5.2h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5z"/>
<!-- Letter 'o' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M66,82c0,-2.2 1.8,-4 4,-4s4,1.8 4,4s-1.8,4 -4,4s-4,-1.8 -4,-4zm2,0c0,1.1 0.9,2 2,2s2,-0.9 2,-2s-0.9,-2 -2,-2s-2,0.9 -2,2z"/>
<!-- Letter 'n' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M76,86v-8h2v1c0.6,-0.7 1.4,-1 2.3,-1c1.9,0 3.2,1.3 3.2,3.2v4.8h-2v-4.5c0,-1 -0.7,-1.7 -1.7,-1.7c-1,0 -1.8,0.7 -1.8,1.7v4.5z"/>
</vector>
<!-- TV Screen with white color -->
<path
android:fillColor="#FFFFFF"
android:pathData="M34,38h40v28h-40z"/>
<!-- TV Stand -->
<path
android:fillColor="#FFFFFF"
android:pathData="M44,70h20v4h-20z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M40,74h28v2h-28z"/>
<!-- Play Button - using Netflix red -->
<path
android:fillColor="#E50914"
android:pathData="M50,44l12,8l-12,8z"/>
<!-- tvmon Text - white -->
<!-- Letter 't' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M38,82h4v-2h-4v-2h6v10h-2v-6h-4z"/>
<!-- Letter 'v' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M45,78h2l2,6l2,-6h2l-3,8h-2z"/>
<!-- Letter 'm' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,86v-8h2v1c0.5,-0.7 1.2,-1 2,-1c0.9,0 1.6,0.4 2,1.1c0.5,-0.7 1.3,-1.1 2.2,-1.1c1.6,0 2.8,1.2 2.8,2.8v5.2h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5h-2v-5c0,-0.9 -0.7,-1.5 -1.5,-1.5c-0.9,0 -1.5,0.6 -1.5,1.5v5z"/>
<!-- Letter 'o' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M66,82c0,-2.2 1.8,-4 4,-4s4,1.8 4,4s-1.8,4 -4,4s-4,-1.8 -4,-4zm2,0c0,1.1 0.9,2 2,2s2,-0.9 2,-2s-0.9,-2 -2,-2s-2,0.9 -2,2z"/>
<!-- Letter 'n' -->
<path
android:fillColor="#FFFFFF"
android:pathData="M76,86v-8h2v1c0.6,-0.7 1.4,-1 2.3,-1c1.9,0 3.2,1.3 3.2,3.2v4.8h-2v-4.5c0,-1 -0.7,-1.7 -1.7,-1.7c-1,0 -1.8,0.7 -1.8,1.7v4.5z"/>
</vector>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/netflix_black"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="48dp">
<!-- Header -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="설정"
android:textColor="@android:color/white"
android:textSize="32sp"
android:textStyle="bold"
android:layout_marginBottom="32dp" />
<!-- Settings Items -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- App Version -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="앱 버전"
android:textColor="@color/netflix_light_gray"
android:textSize="18sp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_app_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="-"
android:textColor="@android:color/white"
android:textSize="18sp" />
<TextView
android:id="@+id/tv_latest_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="최신 버전: 확인 중..."
android:textColor="@color/netflix_light_gray"
android:textSize="14sp"
android:layout_marginTop="4dp" />
</LinearLayout>
</LinearLayout>
<!-- Cache Delete Buttons -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="캐시 관리"
android:textColor="@color/netflix_light_gray"
android:textSize="18sp" />
<Button
android:id="@+id/btn_clear_cache"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="전체 삭제"
android:textSize="14sp"
android:backgroundTint="@color/netflix_medium_gray"
android:textColor="@android:color/white"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp" />
</LinearLayout>
<!-- Update Check Button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="업데이트"
android:textColor="@color/netflix_light_gray"
android:textSize="18sp" />
<Button
android:id="@+id/btn_check_update"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="업데이트 확인"
android:textSize="16sp"
android:backgroundTint="@color/accent"
android:textColor="@android:color/white"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp" />
</LinearLayout>
</LinearLayout>
<!-- Bottom Spacer -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Back Button -->
<Button
android:id="@+id/btn_back"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="뒤로 가기"
android:textSize="16sp"
android:backgroundTint="@color/netflix_medium_gray"
android:textColor="@android:color/white"
android:paddingHorizontal="32dp"
android:paddingVertical="16dp"
android:layout_gravity="start" />
</LinearLayout>
</ScrollView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1F1F1F</color>
<color name="ic_launcher_background">#E50914</color>
</resources>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path
name="internal_files"
path="." />
<cache-path
name="cache"
path="." />
<external-files-path
name="external_files"
path="." />
</paths>

6
tvmon-app/version.json Normal file
View File

@@ -0,0 +1,6 @@
{
"versionCode": 2,
"versionName": "1.0.1",
"apkUrl": "https://git.webpluss.net/sanjeok77/tvmon_release/releases/download/v1.0.1/app-release.apk",
"updateMessage": "v1.0.1 업데이트\n\n- 정렬 순서 wr_datetime DESC로 변경\n- 런처 아이콘 개선 (Netflix 스타일)\n- 웹뷰 동영상 재생 최적화\n- 설정 버튼 카테고리 하단 배치"
}