Skip to content

Commit 1078576

Browse files
committed
chore: fix compiler warnings
1 parent 8d9e01a commit 1078576

11 files changed

Lines changed: 127 additions & 102 deletions

File tree

app/src/main/java/app/pwhs/universalinstaller/presentation/install/ApkInfoContent.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -504,14 +504,14 @@ private fun VirusTotalCard(vt: VtResult?, fileSizeBytes: Long, sha256: String =
504504
// (only the button label changed), so tapping Check with no key looked like a no-op.
505505
val vtDesc = when (status) {
506506
VtStatus.CLEAN -> stringResource(R.string.apk_info_vt_clean)
507-
VtStatus.MALICIOUS -> stringResource(R.string.apk_info_vt_malicious, vt?.malicious ?: 0)
508-
VtStatus.SUSPICIOUS -> stringResource(R.string.apk_info_vt_suspicious, vt?.suspicious ?: 0)
507+
VtStatus.MALICIOUS -> stringResource(R.string.apk_info_vt_malicious, vt.malicious)
508+
VtStatus.SUSPICIOUS -> stringResource(R.string.apk_info_vt_suspicious, vt.suspicious)
509509
VtStatus.NOT_FOUND -> stringResource(R.string.apk_info_vt_not_found)
510510
VtStatus.NO_API_KEY -> stringResource(R.string.apk_info_vt_no_api_key)
511-
VtStatus.ERROR -> vt?.errorMessage?.takeIf { it.isNotBlank() } ?: stringResource(R.string.apk_info_vt_error)
512-
VtStatus.TOO_LARGE -> stringResource(R.string.apk_info_vt_too_large, vt?.errorMessage.orEmpty())
511+
VtStatus.ERROR -> vt.errorMessage.takeIf { it.isNotBlank() } ?: stringResource(R.string.apk_info_vt_error)
512+
VtStatus.TOO_LARGE -> stringResource(R.string.apk_info_vt_too_large, vt.errorMessage.orEmpty())
513513
VtStatus.SCANNING -> stringResource(R.string.apk_info_vt_scanning)
514-
VtStatus.UPLOADING -> stringResource(R.string.apk_info_vt_uploading, vt?.uploadProgress ?: 0)
514+
VtStatus.UPLOADING -> stringResource(R.string.apk_info_vt_uploading, vt.uploadProgress)
515515
VtStatus.QUEUED -> stringResource(R.string.apk_info_vt_queued)
516516
VtStatus.ANALYZING -> stringResource(R.string.apk_info_vt_analyzing)
517517
null -> null

app/src/main/java/app/pwhs/universalinstaller/presentation/install/InstallViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2088,7 +2088,7 @@ class InstallViewModel(
20882088
SplitType.Libs -> {
20892089
val normalized = e.name.replace('-', '_').lowercase()
20902090
val p = abiPriority[normalized] ?: Int.MAX_VALUE
2091-
val isBest = p == bestLibsPriority && bestLibsPriority != null
2091+
val isBest = p == bestLibsPriority
20922092
val bestAbi = abiPriority.entries.find { it.value == bestLibsPriority }?.key
20932093
val containsBest = bestAbi != null && normalized.contains(bestAbi)
20942094
isBest || containsBest

app/src/main/java/app/pwhs/universalinstaller/presentation/install/StorageCard.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ internal fun StorageCard(modifier: Modifier = Modifier) {
4646
),
4747
) {
4848
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
49-
Row(verticalAlignment = Alignment.CenterVertically) {
49+
Row {
5050
Icon(
5151
imageVector = Icons.Rounded.Storage,
5252
contentDescription = null,
@@ -60,6 +60,8 @@ internal fun StorageCard(modifier: Modifier = Modifier) {
6060
color = MaterialTheme.colorScheme.onSurface,
6161
modifier = Modifier.weight(1f),
6262
)
63+
}
64+
Row(verticalAlignment = Alignment.CenterVertically) {
6365
Text(
6466
text = stringResource(
6567
R.string.install_storage_value,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Google Play Console Declarations
2+
3+
## QUERY_ALL_PACKAGES Permission Justification
4+
5+
*Lưu ý: Nội dung khai báo giới hạn tối đa 500 ký tự.*
6+
7+
### 🇬🇧 English (Copy and paste into Google Play Console)
8+
9+
**Core purpose**
10+
> The core purpose of this app is to act as an advanced Package Installer and App Manager.
11+
12+
**Describe 1 feature in your app that requires a permitted use of the QUERY_ALL_PACKAGES permission:**
13+
> Our app features an App Manager that lets users view, extract, and uninstall apps on their device. We require the QUERY_ALL_PACKAGES permission to fetch and display the full list of installed packages. Without it, the app cannot show existing apps or detect if a new APK is an update or a fresh install.
14+
15+
*(Độ dài: 308 ký tự)*
16+
17+
---
18+
19+
### 🇻🇳 Tiếng Việt (Bản dịch tham khảo)
20+
21+
**Mục đích cốt lõi**
22+
> Hoạt động như một Trình cài đặt và Quản lý ứng dụng.
23+
24+
**Mô tả 1 tính năng cần sử dụng quyền QUERY_ALL_PACKAGES:**
25+
> Tính năng Quản lý Ứng dụng cho phép người dùng xem, trích xuất và gỡ cài đặt các app trong máy. Cần quyền QUERY_ALL_PACKAGES để tải danh sách toàn bộ gói đã cài đặt. Nếu không, app sẽ không thể hiển thị danh sách này hoặc nhận diện APK sắp cài là bản cập nhật hay cài mới.
26+
27+
*(Độ dài: 279 ký tự)*
28+
29+
---
30+
31+
### 🎥 Yêu cầu về Video Demo từ Google Play
32+
1. Quay màn hình mở ứng dụng.
33+
2. Bấm sang tab **Quản lý ứng dụng (Manage)** hiển thị toàn bộ app.
34+
3. Bấm vào một app bất kỳ để xem thông tin/gỡ cài đặt.

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ appcompat = "1.7.1"
3131
material = "1.10.0"
3232
tvFoundation = "1.0.0"
3333
tvMaterial = "1.0.0"
34+
navigationCompose = "2.9.7"
3435

3536
[libraries]
3637
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -121,6 +122,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
121122
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
122123
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
123124
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
125+
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
124126

125127

126128
[plugins]

tv/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ dependencies {
7575
implementation(libs.androidx.documentfile)
7676
implementation(libs.androidx.lifecycle.runtime.ktx)
7777
implementation(libs.androidx.lifecycle.viewmodel.compose)
78+
implementation(libs.androidx.navigation.compose)
7879
implementation(libs.androidx.foundation)
7980
implementation(libs.androidx.tv.foundation)
8081
implementation(libs.androidx.tv.material)

tv/src/main/java/app/pwhs/tv/TvApp.kt

Lines changed: 46 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,21 @@ import androidx.compose.foundation.layout.height
1212
import androidx.compose.foundation.layout.padding
1313
import androidx.compose.foundation.layout.size
1414
import androidx.compose.foundation.layout.width
15-
import androidx.compose.foundation.focusGroup
1615
import androidx.compose.foundation.shape.RoundedCornerShape
1716
import androidx.compose.runtime.Composable
1817
import androidx.compose.runtime.getValue
19-
import androidx.compose.runtime.mutableIntStateOf
20-
import androidx.compose.runtime.mutableStateOf
21-
import androidx.compose.runtime.remember
22-
import androidx.compose.runtime.saveable.rememberSaveable
23-
import androidx.compose.runtime.setValue
2418
import androidx.compose.ui.Alignment
2519
import androidx.compose.ui.Modifier
2620
import androidx.compose.ui.draw.clip
27-
import androidx.compose.ui.focus.focusProperties
2821
import androidx.compose.ui.graphics.Color
29-
import androidx.compose.ui.graphics.graphicsLayer
3022
import androidx.compose.ui.res.painterResource
3123
import androidx.compose.ui.unit.dp
32-
import androidx.compose.ui.zIndex
24+
import androidx.navigation.NavController
25+
import androidx.navigation.NavGraph.Companion.findStartDestination
26+
import androidx.navigation.compose.NavHost
27+
import androidx.navigation.compose.composable
28+
import androidx.navigation.compose.currentBackStackEntryAsState
29+
import androidx.navigation.compose.rememberNavController
3330
import androidx.tv.material3.ClickableSurfaceDefaults
3431
import androidx.tv.material3.ExperimentalTvMaterial3Api
3532
import androidx.tv.material3.Icon
@@ -39,14 +36,27 @@ import app.pwhs.tv.presentation.manage.ManageScreen
3936
import app.pwhs.tv.presentation.receive.ReceiveScreen
4037
import app.pwhs.tv.presentation.settings.SettingsScreen
4138

39+
/** Top-level destinations reachable from the side rail. */
40+
private object TvRoute {
41+
const val RECEIVE = "receive"
42+
const val MANAGE = "manage"
43+
const val SETTINGS = "settings"
44+
}
45+
4246
/**
43-
* Top-level TV shell: A completely static side Navigation Rail.
44-
* Extremely lightweight and performant for low-end TV hardware.
47+
* Top-level TV shell: a static side Navigation Rail driving a Navigation Compose [NavHost].
48+
*
49+
* Tabs are switched with the bottom-nav idiom (`saveState`/`restoreState` + `launchSingleTop`),
50+
* so a destination's back-stack entry — and its ViewModel — survives while you're on another tab.
51+
* The heavy data (installed apps, local-APK scan) is loaded once by the ViewModels rather than on
52+
* every visit, so returning to a tab restores instantly instead of re-querying and flashing a
53+
* loading state — that reload-on-every-switch is what made navigation feel janky.
4554
*/
4655
@Composable
4756
fun TvApp(modifier: Modifier = Modifier) {
48-
// Survives the locale/config-change recreate so the user stays on their current tab.
49-
var tab by rememberSaveable { mutableIntStateOf(0) }
57+
val navController = rememberNavController()
58+
val backStackEntry by navController.currentBackStackEntryAsState()
59+
val currentRoute = backStackEntry?.destination?.route ?: TvRoute.RECEIVE
5060

5161
Box(
5262
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
@@ -78,73 +88,50 @@ fun TvApp(modifier: Modifier = Modifier) {
7888
Spacer(Modifier.height(32.dp))
7989

8090
NavigationItem(
81-
selected = tab == 0,
82-
onClick = { tab = 0 },
91+
selected = currentRoute == TvRoute.RECEIVE,
92+
onClick = { navController.switchTab(TvRoute.RECEIVE) },
8393
iconRes = R.drawable.ic_apk_install
8494
)
8595
Spacer(Modifier.height(12.dp))
8696
NavigationItem(
87-
selected = tab == 1,
88-
onClick = { tab = 1 },
97+
selected = currentRoute == TvRoute.MANAGE,
98+
onClick = { navController.switchTab(TvRoute.MANAGE) },
8999
iconRes = R.drawable.ic_delete
90100
)
91101
Spacer(Modifier.height(12.dp))
92102
NavigationItem(
93-
selected = tab == 2,
94-
onClick = { tab = 2 },
103+
selected = currentRoute == TvRoute.SETTINGS,
104+
onClick = { navController.switchTab(TvRoute.SETTINGS) },
95105
iconRes = R.drawable.ic_setting
96106
)
97107
}
98108

99-
// Content Area — all three destinations stay composed once first visited (keep-alive),
100-
// so switching tabs is an alpha/focus toggle instead of a full teardown + rebuild +
101-
// data reload. That rebuild-and-reload on every switch is what made navigation feel
102-
// janky on low-end TV hardware; here the LaunchedEffect keys never reset, so the app
103-
// list / QR / storage stats are computed once and simply shown again.
109+
// Content Area
104110
Box(Modifier.weight(1f).fillMaxHeight()) {
105-
ScreenSlot(active = tab == 0) {
106-
ReceiveScreen(active = tab == 0, modifier = Modifier.fillMaxSize())
107-
}
108-
ScreenSlot(active = tab == 1) {
109-
ManageScreen(active = tab == 1, modifier = Modifier.fillMaxSize())
110-
}
111-
ScreenSlot(active = tab == 2) {
112-
SettingsScreen(modifier = Modifier.fillMaxSize())
111+
NavHost(
112+
navController = navController,
113+
startDestination = TvRoute.RECEIVE,
114+
modifier = Modifier.fillMaxSize(),
115+
) {
116+
composable(TvRoute.RECEIVE) { ReceiveScreen(modifier = Modifier.fillMaxSize()) }
117+
composable(TvRoute.MANAGE) { ManageScreen(modifier = Modifier.fillMaxSize()) }
118+
composable(TvRoute.SETTINGS) { SettingsScreen(modifier = Modifier.fillMaxSize()) }
113119
}
114120
}
115121
}
116122
}
117123
}
118124

119125
/**
120-
* Keeps a destination's composition — and therefore its loaded data, scroll position and focus
121-
* state — alive across tab switches. The slot composes its [content] the first time it becomes
122-
* [active] (lazy, so unvisited tabs cost nothing at startup) and never disposes it afterwards.
123-
*
124-
* When inactive it is drawn fully transparent and made focus-inert: [canFocus] is false and any
125-
* D-pad attempt to enter the group is cancelled, so focus can't wander into an off-screen
126-
* destination. (Explicit `requestFocus()` calls still pierce this, which is why the screens gate
127-
* their own focus requests on an `active` flag.)
126+
* Bottom-nav style tab switch: single top-level entry per tab, with the leaving tab's state saved
127+
* and the entered tab's state restored so it comes back exactly as the user left it.
128128
*/
129-
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
130-
@Composable
131-
private fun ScreenSlot(active: Boolean, content: @Composable () -> Unit) {
132-
var everActive by remember { mutableStateOf(false) }
133-
if (active) everActive = true
134-
if (!everActive) return
135-
136-
Box(
137-
modifier = Modifier
138-
.fillMaxSize()
139-
.zIndex(if (active) 1f else 0f)
140-
.graphicsLayer { alpha = if (active) 1f else 0f }
141-
.focusProperties {
142-
canFocus = active
143-
if (!active) onEnter = { cancelFocus() }
144-
}
145-
.focusGroup(),
146-
) {
147-
content()
129+
private fun NavController.switchTab(route: String) {
130+
if (currentDestination?.route == route) return
131+
navigate(route) {
132+
popUpTo(graph.findStartDestination().id) { saveState = true }
133+
launchSingleTop = true
134+
restoreState = true
148135
}
149136
}
150137

tv/src/main/java/app/pwhs/tv/presentation/manage/ManageScreen.kt

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ import kotlinx.coroutines.delay
8484
@Composable
8585
fun ManageScreen(
8686
modifier: Modifier = Modifier,
87-
active: Boolean = true,
8887
viewModel: ManageViewModel = viewModel()
8988
) {
9089
val uiState by viewModel.uiState.collectAsState()
@@ -93,8 +92,12 @@ fun ManageScreen(
9392
var confirm by remember { mutableStateOf<ConfirmAction?>(null) }
9493
val context = LocalContext.current
9594

96-
// Lands the D-pad on the first app row. See the two effects below for when it fires.
95+
// Default focus: land on the first app once, when the list first appears on this visit,
96+
// so the D-pad has a home instead of stranding focus on the rail. (The list itself is loaded
97+
// once by the ViewModel's init and kept in its retained back-stack entry, so revisiting this
98+
// tab restores instantly without a reload.)
9799
val firstRowFocus = remember { FocusRequester() }
100+
var didRequestFocus by remember { mutableStateOf(false) }
98101

99102
val uninstallLauncher = rememberLauncherForActivityResult(
100103
ActivityResultContracts.StartActivityForResult()
@@ -104,21 +107,6 @@ fun ManageScreen(
104107
viewModel.refresh()
105108
}
106109

107-
// Initial load only — the screen stays composed across tab switches (keep-alive), so this
108-
// fires once instead of on every visit; re-entry shows the already-loaded list instantly.
109-
LaunchedEffect(Unit) {
110-
viewModel.loadApps()
111-
}
112-
113-
// Pull focus onto the first row when this tab becomes visible, so the D-pad has a home instead
114-
// of being stranded on the rail. Gated on [active] because the screen is kept composed while
115-
// off-screen and requestFocus() would otherwise yank focus into a hidden destination.
116-
LaunchedEffect(active, uiState.isLoading) {
117-
if (active && !uiState.isLoading && uiState.filteredApps.isNotEmpty()) {
118-
runCatching { firstRowFocus.requestFocus() }
119-
}
120-
}
121-
122110
LaunchedEffect(uiState.filteredApps, uiState.isLoading) {
123111
if (uiState.isLoading) return@LaunchedEffect
124112
val current = focusedApp
@@ -129,12 +117,16 @@ fun ManageScreen(
129117
fresh != null -> if (fresh != current) focusedApp = fresh
130118
// Selection dropped out (uninstalled / disabled under the User filter / filtered):
131119
// re-home the pane AND move D-pad focus back to the list so it isn't stranded on a
132-
// control being animated away — but only while on-screen (see [active] note above).
120+
// control being animated away.
133121
else -> {
134122
focusedApp = uiState.filteredApps.firstOrNull()
135-
if (active && uiState.filteredApps.isNotEmpty()) runCatching { firstRowFocus.requestFocus() }
123+
if (uiState.filteredApps.isNotEmpty()) runCatching { firstRowFocus.requestFocus() }
136124
}
137125
}
126+
if (!didRequestFocus && uiState.filteredApps.isNotEmpty()) {
127+
didRequestFocus = true
128+
runCatching { firstRowFocus.requestFocus() }
129+
}
138130
}
139131

140132
// Destructive privileged actions confirm first (no system dialog on the silent root path).
@@ -307,7 +299,7 @@ fun ManageScreen(
307299
Spacer(Modifier.height(12.dp))
308300

309301
Text(
310-
text = stringResource(R.string.tv_manage_version_prefix, app.versionName ?: ""),
302+
text = stringResource(R.string.tv_manage_version_prefix, app.versionName),
311303
style = MaterialTheme.typography.bodyLarge,
312304
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.85f)
313305
)

tv/src/main/java/app/pwhs/tv/presentation/manage/ManageViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ class ManageViewModel(application: Application) : AndroidViewModel(application)
117117

118118
init {
119119
viewModelScope.launch { _rootAvailable.value = RootShell.isAvailable() }
120+
// Load once at ViewModel creation. The VM is retained in its Navigation back-stack entry
121+
// across tab switches, so revisiting Manage reuses this list instead of re-querying the
122+
// PackageManager and re-flashing the loading skeleton on every visit.
123+
loadApps()
120124
}
121125

122126
fun loadApps() {

0 commit comments

Comments
 (0)