Skip to content

Commit e40f2bc

Browse files
authored
Merge pull request #266 from rainxchzed/manual_install-auto_check
2 parents a09c957 + 79bffe7 commit e40f2bc

61 files changed

Lines changed: 1273 additions & 298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44

55
<uses-permission android:name="android.permission.INTERNET" />
66

7+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8+
79
<uses-permission
810
android:name="android.permission.QUERY_ALL_PACKAGES"
911
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
1012

1113
<uses-permission
1214
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
1315
tools:ignore="RequestInstallPackagesPolicy" />
16+
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
1417

1518
<application
1619
android:name=".app.GithubStoreApp"
@@ -64,8 +67,7 @@
6467
<data android:mimeType="text/html" />
6568
</intent-filter>
6669

67-
<!-- GitHub repository links: https://github.com/{owner}/{repo} -->
68-
<intent-filter>
70+
<intent-filter android:autoVerify="false">
6971
<action android:name="android.intent.action.VIEW" />
7072
<category android:name="android.intent.category.DEFAULT" />
7173
<category android:name="android.intent.category.BROWSABLE" />

composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package zed.rainxch.githubstore.app
22

33
import android.app.Application
4+
import android.app.NotificationChannel
5+
import android.app.NotificationManager
46
import android.os.Build
57
import org.koin.android.ext.android.get
68
import org.koin.android.ext.koin.androidContext
79
import zed.rainxch.core.data.services.PackageEventReceiver
10+
import zed.rainxch.core.data.services.UpdateScheduler
811
import zed.rainxch.core.domain.repository.InstalledAppsRepository
912
import zed.rainxch.core.domain.system.PackageMonitor
1013
import zed.rainxch.githubstore.app.di.initKoin
@@ -20,7 +23,21 @@ class GithubStoreApp : Application() {
2023
androidContext(this@GithubStoreApp)
2124
}
2225

26+
createNotificationChannels()
2327
registerPackageEventReceiver()
28+
scheduleBackgroundUpdateChecks()
29+
}
30+
31+
private fun createNotificationChannels() {
32+
val channel = NotificationChannel(
33+
UPDATES_CHANNEL_ID,
34+
"App Updates",
35+
NotificationManager.IMPORTANCE_HIGH
36+
).apply {
37+
description = "Notifications when app updates are available"
38+
}
39+
val notificationManager = getSystemService(NotificationManager::class.java)
40+
notificationManager.createNotificationChannel(channel)
2441
}
2542

2643
private fun registerPackageEventReceiver() {
@@ -38,4 +55,12 @@ class GithubStoreApp : Application() {
3855

3956
packageEventReceiver = receiver
4057
}
58+
59+
private fun scheduleBackgroundUpdateChecks() {
60+
UpdateScheduler.schedule(context = this)
61+
}
62+
63+
companion object {
64+
const val UPDATES_CHANNEL_ID = "app_updates"
65+
}
4166
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import androidx.compose.ui.Modifier
1717
import androidx.compose.ui.layout.onGloballyPositioned
1818
import androidx.compose.ui.platform.LocalDensity
1919
import androidx.compose.ui.unit.dp
20+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+
import androidx.lifecycle.viewmodel.compose.viewModel
2022
import androidx.navigation.NavHostController
2123
import androidx.navigation.compose.NavHost
2224
import androidx.navigation.compose.composable
@@ -26,6 +28,7 @@ import io.github.fletchmckee.liquid.rememberLiquidState
2628
import org.koin.compose.viewmodel.koinViewModel
2729
import org.koin.core.parameter.parametersOf
2830
import zed.rainxch.apps.presentation.AppsRoot
31+
import zed.rainxch.apps.presentation.AppsViewModel
2932
import zed.rainxch.auth.presentation.AuthenticationRoot
3033
import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight
3134
import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid
@@ -45,6 +48,9 @@ fun AppNavigation(
4548
var bottomNavigationHeight by remember { mutableStateOf(0.dp) }
4649
val density = LocalDensity.current
4750

51+
val appsViewModel = koinViewModel<AppsViewModel>()
52+
val appsState by appsViewModel.state.collectAsStateWithLifecycle()
53+
4854
CompositionLocalProvider(
4955
LocalBottomNavigationLiquid provides liquidState,
5056
LocalBottomNavigationHeight provides bottomNavigationHeight
@@ -222,6 +228,9 @@ fun AppNavigation(
222228
},
223229
onNavigateToFavouriteRepos = {
224230
navController.navigate(GithubStoreGraph.FavouritesScreen)
231+
},
232+
onNavigateToDevProfile = { username ->
233+
navController.navigate(GithubStoreGraph.DeveloperProfileScreen(username))
225234
}
226235
)
227236
}
@@ -237,7 +246,9 @@ fun AppNavigation(
237246
repositoryId = repoId
238247
)
239248
)
240-
}
249+
},
250+
viewModel = appsViewModel,
251+
state = appsState
241252
)
242253
}
243254
}
@@ -248,8 +259,16 @@ fun AppNavigation(
248259
BottomNavigation(
249260
currentScreen = currentScreen,
250261
onNavigate = {
251-
navController.navigate(it)
262+
navController.navigate(it) {
263+
popUpTo(GithubStoreGraph.HomeScreen) {
264+
saveState = true
265+
}
266+
267+
launchSingleTop = true
268+
restoreState = true
269+
}
252270
},
271+
isUpdateAvailable = appsState.apps.any { it.installedApp.isUpdateAvailable },
253272
modifier = Modifier
254273
.align(Alignment.BottomCenter)
255274
.navigationBarsPadding()

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt

Lines changed: 59 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,13 @@ import zed.rainxch.core.domain.getPlatform
5858
import zed.rainxch.core.domain.model.Platform
5959
import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid
6060
import zed.rainxch.core.presentation.theme.GithubStoreTheme
61-
import zed.rainxch.details.presentation.utils.isLiquidFrostAvailable
61+
import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable
6262

6363
@Composable
6464
fun BottomNavigation(
6565
currentScreen: GithubStoreGraph,
6666
onNavigate: (GithubStoreGraph) -> Unit,
67+
isUpdateAvailable: Boolean,
6768
modifier: Modifier = Modifier
6869
) {
6970
val liquidState = LocalBottomNavigationLiquid.current
@@ -138,9 +139,7 @@ fun BottomNavigation(
138139
if (isLiquidFrostAvailable()) {
139140
Modifier.liquid(liquidState) {
140141
this.shape = CircleShape
141-
if (isLiquidFrostAvailable()) {
142-
this.frost = if (isDarkTheme) 12.dp else 10.dp
143-
}
142+
this.frost = if (isDarkTheme) 12.dp else 10.dp
144143
this.curve = if (isDarkTheme) .35f else .45f
145144
this.refraction = if (isDarkTheme) .08f else .12f
146145
this.dispersion = if (isDarkTheme) .18f else .25f
@@ -237,6 +236,7 @@ fun BottomNavigation(
237236
visibleItems.forEachIndexed { index, item ->
238237
LiquidGlassTabItem(
239238
item = item,
239+
hasBadge = item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable,
240240
isSelected = item.screen == currentScreen,
241241
onSelect = { onNavigate(item.screen) },
242242
onPositioned = { x, width ->
@@ -264,6 +264,7 @@ private fun LiquidGlassTabItem(
264264
item: BottomNavigationItem,
265265
isSelected: Boolean,
266266
onSelect: () -> Unit,
267+
hasBadge: Boolean = false,
267268
onPositioned: suspend (x: Float, width: Float) -> Unit
268269
) {
269270
val scope = rememberCoroutineScope()
@@ -331,7 +332,7 @@ private fun LiquidGlassTabItem(
331332
label = "hPadding"
332333
)
333334

334-
Column(
335+
Box(
335336
modifier = Modifier
336337
.clip(CircleShape)
337338
.clickable(
@@ -347,46 +348,59 @@ private fun LiquidGlassTabItem(
347348
scaleX = pressScale
348349
scaleY = pressScale
349350
}
350-
.padding(horizontal = horizontalPadding, vertical = 6.dp),
351-
horizontalAlignment = Alignment.CenterHorizontally,
352-
verticalArrangement = Arrangement.spacedBy(1.dp)
351+
.padding(horizontal = horizontalPadding, vertical = 6.dp)
353352
) {
354-
Icon(
355-
imageVector = if (isSelected) item.iconFilled else item.iconOutlined,
356-
contentDescription = stringResource(item.titleRes),
357-
modifier = Modifier
358-
.size(22.dp)
359-
.graphicsLayer {
360-
scaleX = iconScale
361-
scaleY = iconScale
362-
translationY = with(density) { iconOffsetY.toPx() }
363-
},
364-
tint = iconTint
365-
)
366-
367-
Box(
368-
modifier = Modifier
369-
.height(if (isSelected) 16.dp else 0.dp)
370-
.graphicsLayer {
371-
alpha = labelAlpha
372-
scaleX = labelScale
373-
scaleY = labelScale
374-
},
375-
contentAlignment = Alignment.Center
353+
Column(
354+
horizontalAlignment = Alignment.CenterHorizontally,
355+
verticalArrangement = Arrangement.spacedBy(1.dp)
376356
) {
377-
Text(
378-
text = stringResource(item.titleRes),
379-
style = MaterialTheme.typography.labelSmall.copy(
380-
fontSize = 10.sp,
381-
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
382-
lineHeight = 12.sp
383-
),
384-
color = if (isSelected) {
385-
MaterialTheme.colorScheme.onPrimaryContainer
386-
} else {
387-
MaterialTheme.colorScheme.onSurface
388-
},
389-
maxLines = 1
357+
Icon(
358+
imageVector = if (isSelected) item.iconFilled else item.iconOutlined,
359+
contentDescription = stringResource(item.titleRes),
360+
modifier = Modifier
361+
.size(22.dp)
362+
.graphicsLayer {
363+
scaleX = iconScale
364+
scaleY = iconScale
365+
translationY = with(density) { iconOffsetY.toPx() }
366+
},
367+
tint = iconTint
368+
)
369+
370+
Box(
371+
modifier = Modifier
372+
.height(if (isSelected) 16.dp else 0.dp)
373+
.graphicsLayer {
374+
alpha = labelAlpha
375+
scaleX = labelScale
376+
scaleY = labelScale
377+
},
378+
contentAlignment = Alignment.Center
379+
) {
380+
Text(
381+
text = stringResource(item.titleRes),
382+
style = MaterialTheme.typography.labelSmall.copy(
383+
fontSize = 10.sp,
384+
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
385+
lineHeight = 12.sp
386+
),
387+
color = if (isSelected) {
388+
MaterialTheme.colorScheme.onPrimaryContainer
389+
} else {
390+
MaterialTheme.colorScheme.onSurface
391+
},
392+
maxLines = 1
393+
)
394+
}
395+
}
396+
397+
if (hasBadge) {
398+
Box(
399+
Modifier
400+
.size(12.dp)
401+
.clip(CircleShape)
402+
.background(MaterialTheme.colorScheme.error)
403+
.align(Alignment.TopEnd)
390404
)
391405
}
392406
}
@@ -403,7 +417,8 @@ fun BottomNavigationPreview() {
403417
currentScreen = GithubStoreGraph.HomeScreen,
404418
onNavigate = {
405419

406-
}
420+
},
421+
isUpdateAvailable = true
407422
)
408423
}
409424
}

core/data/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ kotlin {
2727
androidMain {
2828
dependencies {
2929
implementation(libs.ktor.client.okhttp)
30+
implementation(libs.androidx.work.runtime)
3031
}
3132
}
3233

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ class AndroidInstaller(
140140
}
141141
}
142142

143+
override fun uninstall(packageName: String) {
144+
Logger.d { "Requesting uninstall for: $packageName" }
145+
val intent = Intent(Intent.ACTION_DELETE).apply {
146+
data = "package:$packageName".toUri()
147+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
148+
}
149+
try {
150+
context.startActivity(intent)
151+
} catch (e: Exception) {
152+
Logger.w { "Failed to start uninstall for $packageName: ${e.message}" }
153+
}
154+
155+
}
156+
143157
override fun isObtainiumInstalled(): Boolean {
144158
return try {
145159
context.packageManager.getPackageInfo("dev.imranr.obtainium.fdroid", 0)

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ import kotlinx.coroutines.launch
1212
import zed.rainxch.core.domain.repository.InstalledAppsRepository
1313
import zed.rainxch.core.domain.system.PackageMonitor
1414

15-
/**
16-
* Listens to system package install/uninstall/replace broadcasts.
17-
* When a tracked package is installed or updated, it resolves the pending
18-
* install flag and updates version info from the system PackageManager.
19-
* When a tracked package is removed, it deletes the record from the database.
20-
*/
2115
class PackageEventReceiver(
2216
private val installedAppsRepository: InstalledAppsRepository,
2317
private val packageMonitor: PackageMonitor
@@ -49,17 +43,37 @@ class PackageEventReceiver(
4943
if (app.isPendingInstall) {
5044
val systemInfo = packageMonitor.getInstalledPackageInfo(packageName)
5145
if (systemInfo != null) {
52-
installedAppsRepository.updateApp(
53-
app.copy(
54-
isPendingInstall = false,
55-
isUpdateAvailable = false,
56-
installedVersionName = systemInfo.versionName,
57-
installedVersionCode = systemInfo.versionCode,
58-
latestVersionName = systemInfo.versionName,
59-
latestVersionCode = systemInfo.versionCode
46+
val expectedVersionCode = app.latestVersionCode ?: 0L
47+
val wasActuallyUpdated = expectedVersionCode > 0L &&
48+
systemInfo.versionCode >= expectedVersionCode
49+
50+
if (wasActuallyUpdated) {
51+
installedAppsRepository.updateAppVersion(
52+
packageName = packageName,
53+
newTag = app.latestVersion ?: systemInfo.versionName,
54+
newAssetName = app.latestAssetName ?: "",
55+
newAssetUrl = app.latestAssetUrl ?: "",
56+
newVersionName = systemInfo.versionName,
57+
newVersionCode = systemInfo.versionCode
6058
)
61-
)
62-
Logger.i { "Resolved pending install via broadcast: $packageName (v${systemInfo.versionName})" }
59+
installedAppsRepository.updatePendingStatus(packageName, false)
60+
Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" }
61+
} else {
62+
installedAppsRepository.updateApp(
63+
app.copy(
64+
isPendingInstall = false,
65+
installedVersionName = systemInfo.versionName,
66+
installedVersionCode = systemInfo.versionCode,
67+
isUpdateAvailable = (app.latestVersionCode
68+
?: 0L) > systemInfo.versionCode
69+
)
70+
)
71+
Logger.i {
72+
"Package replaced but not updated to target: $packageName " +
73+
"(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " +
74+
"target: v${app.latestVersionName}/${app.latestVersionCode})"
75+
}
76+
}
6377
} else {
6478
installedAppsRepository.updatePendingStatus(packageName, false)
6579
Logger.i { "Resolved pending install via broadcast (no system info): $packageName" }
@@ -83,7 +97,6 @@ class PackageEventReceiver(
8397

8498
private suspend fun onPackageRemoved(packageName: String) {
8599
try {
86-
val app = installedAppsRepository.getAppByPackage(packageName) ?: return
87100
installedAppsRepository.deleteInstalledApp(packageName)
88101
Logger.i { "Removed uninstalled app via broadcast: $packageName" }
89102
} catch (e: Exception) {

0 commit comments

Comments
 (0)