Skip to content

Commit 184007a

Browse files
authored
Merge pull request #128 from HereLiesAz/claude/modularize-dynamic-features
Modularize into dynamic feature modules (Play Feature Delivery)
2 parents 2c3f3a7 + 37efb48 commit 184007a

27 files changed

Lines changed: 582 additions & 144 deletions

app/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ android {
5050
namespace = "com.hereliesaz.logkitty"
5151
compileSdk = 37
5252

53+
// On-demand feature modules. Delivered individually on Google Play; fused into the universal /
54+
// standalone APK (see each module's <dist:fusing>) for the sideloaded GitHub build.
55+
dynamicFeatures += setOf(":feature:stats")
56+
5357
defaultConfig {
5458
applicationId = "com.hereliesaz.logkitty"
5559
minSdk = 30
@@ -217,6 +221,14 @@ configurations.all {
217221
}
218222

219223
dependencies {
224+
// Shared interfaces/constants for dynamic feature modules. `api` so feature modules, which
225+
// depend on :app, can compile against :core types (provided by the base at runtime).
226+
api(project(":core"))
227+
228+
// Play Feature Delivery: install/observe on-demand modules at runtime via SplitInstallManager.
229+
implementation(libs.play.feature.delivery)
230+
implementation(libs.play.feature.delivery.ktx)
231+
220232
// Keep libraries needed for UI and logging
221233
implementation(libs.androidx.core.ktx)
222234
implementation(libs.androidx.lifecycle.runtime.ktx)

app/proguard-rules.pro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@
1818

1919
# If you keep the line number information, uncomment this to
2020
# hide the original source file name.
21-
#-renamesourcefileattribute SourceFile
21+
#-renamesourcefileattribute SourceFile
22+
23+
# Dynamic feature entry points are instantiated reflectively by FeatureLoader using the class names
24+
# in core's FeatureModules, so R8 can't see them as used. Keep the classes and their no-arg
25+
# constructors (the implemented interfaces are kept by core's consumer rules).
26+
-keep class com.hereliesaz.logkitty.feature.stats.StatsFeatureImpl { <init>(); }
27+
-keep class com.hereliesaz.logkitty.feature.appmonitor.AppPickerFeatureImpl { <init>(); }
28+
-keep class com.hereliesaz.logkitty.feature.ads.AdsFeatureImpl { <init>(); }

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
66
<uses-permission android:name="android.permission.INTERNET" />
7-
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" tools:ignore="ProtectedPermissions" />
7+
<!-- PACKAGE_USAGE_STATS moved to the on-demand :feature:stats module so it isn't requested
8+
until the user installs the Developer Stats add-on. -->
89

910
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1011
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

app/src/main/kotlin/com/hereliesaz/logkitty/MainApplication.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.hereliesaz.logkitty
22

33
import android.app.Application
4+
import android.content.Context
45
import com.google.android.gms.ads.MobileAds
6+
import com.google.android.play.core.splitcompat.SplitCompat
57
import com.hereliesaz.logkitty.ui.MainViewModel
68
import com.hereliesaz.logkitty.utils.CrashReporter
79
import kotlinx.coroutines.CoroutineScope
@@ -24,6 +26,14 @@ class MainApplication : Application() {
2426
lateinit var mainViewModel: MainViewModel
2527
private set
2628

29+
override fun attachBaseContext(base: Context) {
30+
super.attachBaseContext(base)
31+
// Make code/resources from on-demand feature splits available to this process immediately
32+
// after install, without requiring an app restart, so reflectively-loaded feature classes
33+
// (FeatureLoader) resolve right away.
34+
SplitCompat.install(this)
35+
}
36+
2737
override fun onCreate() {
2838
super.onCreate()
2939

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.hereliesaz.logkitty.feature
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.DisposableEffect
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.platform.LocalContext
10+
import com.google.android.play.core.splitcompat.SplitCompat
11+
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
12+
import com.google.android.play.core.splitinstall.SplitInstallRequest
13+
import com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
14+
import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus
15+
16+
/** UI-facing status of an on-demand feature module. */
17+
sealed interface FeatureInstallStatus {
18+
data object Installed : FeatureInstallStatus
19+
data object NotInstalled : FeatureInstallStatus
20+
/** [progress] is 0f..1f, or -1f when the total size isn't known yet. */
21+
data class Installing(val progress: Float) : FeatureInstallStatus
22+
data class Failed(val message: String) : FeatureInstallStatus
23+
}
24+
25+
/** Current [status] of a module plus an [install] trigger to request it. */
26+
class FeatureInstallHandle(
27+
val status: FeatureInstallStatus,
28+
val install: () -> Unit,
29+
)
30+
31+
/**
32+
* Observes and drives installation of the dynamic feature module [moduleName] via Play's
33+
* [com.google.android.play.core.splitinstall.SplitInstallManager], surfacing progress as Compose
34+
* state. On a build installed outside Play (e.g. the fused GitHub APK) the module is already present,
35+
* so this reports [FeatureInstallStatus.Installed] immediately.
36+
*/
37+
@Composable
38+
fun rememberFeatureInstall(moduleName: String): FeatureInstallHandle {
39+
val context = LocalContext.current
40+
val manager = remember { SplitInstallManagerFactory.create(context.applicationContext) }
41+
42+
var status by remember(moduleName) {
43+
mutableStateOf<FeatureInstallStatus>(
44+
if (manager.installedModules.contains(moduleName)) FeatureInstallStatus.Installed
45+
else FeatureInstallStatus.NotInstalled
46+
)
47+
}
48+
49+
DisposableEffect(moduleName) {
50+
val listener = SplitInstallStateUpdatedListener { state ->
51+
if (!state.moduleNames().contains(moduleName)) return@SplitInstallStateUpdatedListener
52+
status = when (state.status()) {
53+
SplitInstallSessionStatus.DOWNLOADING -> {
54+
val total = state.totalBytesToDownload()
55+
val done = state.bytesDownloaded()
56+
FeatureInstallStatus.Installing(if (total > 0) done.toFloat() / total else -1f)
57+
}
58+
SplitInstallSessionStatus.PENDING,
59+
SplitInstallSessionStatus.DOWNLOADED,
60+
SplitInstallSessionStatus.INSTALLING -> FeatureInstallStatus.Installing(-1f)
61+
SplitInstallSessionStatus.INSTALLED -> {
62+
SplitCompat.install(context)
63+
FeatureInstallStatus.Installed
64+
}
65+
SplitInstallSessionStatus.FAILED ->
66+
FeatureInstallStatus.Failed("Install failed (code ${state.errorCode()})")
67+
else -> status
68+
}
69+
}
70+
manager.registerListener(listener)
71+
onDispose { manager.unregisterListener(listener) }
72+
}
73+
74+
return remember(moduleName, status) {
75+
FeatureInstallHandle(status) {
76+
if (status is FeatureInstallStatus.Installed || status is FeatureInstallStatus.Installing) {
77+
return@FeatureInstallHandle
78+
}
79+
status = FeatureInstallStatus.Installing(-1f)
80+
val request = SplitInstallRequest.newBuilder().addModule(moduleName).build()
81+
manager.startInstall(request)
82+
.addOnFailureListener {
83+
status = FeatureInstallStatus.Failed(it.message ?: "Install failed")
84+
}
85+
}
86+
}
87+
}

app/src/main/kotlin/com/hereliesaz/logkitty/ui/LogBottomSheet.kt

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
4545
import androidx.compose.material3.Text
4646
import androidx.compose.runtime.Composable
4747
import androidx.compose.runtime.CompositionLocalProvider
48-
import androidx.compose.runtime.DisposableEffect
4948
import androidx.compose.runtime.collectAsState
5049
import androidx.compose.runtime.getValue
5150
import androidx.compose.runtime.mutableStateOf
@@ -120,7 +119,7 @@ fun LogBottomSheet(
120119
val isLogReversed by viewModel.isLogReversed.collectAsState()
121120
val tagColoringEnabled by viewModel.tagColoringEnabled.collectAsState()
122121
val isPaused by viewModel.isPaused.collectAsState()
123-
val appStats by viewModel.appStats.collectAsState()
122+
val isRootEnabled by viewModel.isRootEnabled.collectAsState()
124123

125124
val currentFontFamily = remember(fontFamilyName) {
126125
val enumVal = try { CodingFont.valueOf(fontFamilyName) } catch (e: Exception) { CodingFont.SYSTEM }
@@ -138,17 +137,9 @@ fun LogBottomSheet(
138137
// so each monitored app remembers its own Logs/Stats choice while the sheet is open.
139138
var statsModeTabs by remember { mutableStateOf<Set<String>>(emptySet()) }
140139
val statsActive = selectedTab.type == TabType.APP && selectedTab.id in statsModeTabs
141-
142-
// Drive the stats poller from the active selection: only collect while an app tab is showing its
143-
// Stats view *and* the sheet is expanded, so it costs nothing when collapsed or showing logs.
144-
val expanded = controller.detent == AzSheetDetent.HALF || controller.detent == AzSheetDetent.FULL
145-
val collectStats = statsActive && expanded
146-
// DisposableEffect (not LaunchedEffect) so onDispose always stops the poller — its job lives in
147-
// viewModelScope, which outlives this composition, so a plain cancellation wouldn't halt it.
148-
DisposableEffect(collectStats, selectedTab.id, selectedTab.filterValue) {
149-
if (collectStats) viewModel.setStatsTarget(selectedTab.filterValue, selectedTab.title)
150-
onDispose { viewModel.setStatsTarget(null) }
151-
}
140+
// The Stats view's content (and its polling) lives entirely in the on-demand :feature:stats
141+
// module via StatsFeatureSlot, which only collects while it's on screen — so there's nothing to
142+
// start/stop from here.
152143

153144
when (controller.detent) {
154145
AzSheetDetent.HIDDEN -> PeekStrip(
@@ -188,7 +179,7 @@ fun LogBottomSheet(
188179
isPaused = isPaused,
189180
showStatsToggle = selectedTab.type == TabType.APP,
190181
statsActive = statsActive,
191-
appStats = appStats,
182+
useRoot = isRootEnabled,
192183
onToggleStats = {
193184
statsModeTabs = if (selectedTab.id in statsModeTabs) statsModeTabs - selectedTab.id
194185
else statsModeTabs + selectedTab.id
@@ -328,7 +319,7 @@ private fun ExpandedView(
328319
isPaused: Boolean,
329320
showStatsToggle: Boolean,
330321
statsActive: Boolean,
331-
appStats: com.hereliesaz.logkitty.model.AppStats?,
322+
useRoot: Boolean,
332323
onToggleStats: () -> Unit,
333324
selectedLineIds: Set<Long>,
334325
selectedLines: List<IndexedLogLine>,
@@ -472,8 +463,10 @@ private fun ExpandedView(
472463
.pointerInputHorizontalDrag(threshold = 64f, onLeft = onSwipeLeft, onRight = onSwipeRight)
473464
) {
474465
if (statsActive) {
475-
StatsView(
476-
stats = appStats,
466+
StatsFeatureSlot(
467+
packageName = selectedTab.filterValue,
468+
label = selectedTab.title,
469+
useRoot = useRoot,
477470
fontFamily = fontFamily,
478471
fontSize = fontSize,
479472
modifier = Modifier.fillMaxSize(),

app/src/main/kotlin/com/hereliesaz/logkitty/ui/MainViewModel.kt

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import androidx.lifecycle.viewModelScope
1212
import com.hereliesaz.logkitty.services.LogKittyAccessibilityService
1313
import com.hereliesaz.logkitty.ui.delegates.IndexedLogLine
1414
import com.hereliesaz.logkitty.ui.delegates.StateDelegate
15-
import com.hereliesaz.logkitty.ui.delegates.StatsDelegate
1615
import com.hereliesaz.logkitty.ui.theme.CodingFont
1716
import com.hereliesaz.logkitty.utils.LogcatReader
1817
import com.hereliesaz.logkitty.utils.LogSourceClassifier
@@ -111,25 +110,8 @@ class MainViewModel(
111110
// Delegate to handle the heavy lifting of log buffering.
112111
val stateDelegate = StateDelegate(viewModelScope, bufferSizeFlow = userPreferences.bufferSize)
113112

114-
// Delegate that polls per-app developer stats (CPU, memory, GPU/frames, network, power) for the
115-
// app currently shown in the Stats view. Idle until a target is set.
116-
val statsDelegate = StatsDelegate(viewModelScope, application)
117-
val appStats: StateFlow<com.hereliesaz.logkitty.model.AppStats?> = statsDelegate.stats
118-
119-
// The package currently displayed in the Stats view, so the poll can be re-targeted when the
120-
// root toggle changes underneath it.
121-
private var statsTargetPkg: String? = null
122-
private var statsTargetLabel: String = ""
123-
124-
/**
125-
* Points the developer-stats poller at [pkg] (or stops it when `null`). Safe to call repeatedly;
126-
* the delegate ignores no-op re-targets.
127-
*/
128-
fun setStatsTarget(pkg: String?, label: String = pkg ?: "") {
129-
statsTargetPkg = pkg
130-
statsTargetLabel = label
131-
statsDelegate.setTarget(pkg, label, isRootEnabled.value)
132-
}
113+
// Developer-stats collection now lives in the on-demand `:feature:stats` module; the base only
114+
// exposes the root flag the feature needs (via isRootEnabled below) and hosts its UI slot.
133115

134116
// --- State Flows ---
135117

@@ -290,14 +272,6 @@ class MainViewModel(
290272
userPreferences.activeSourceFilters.collect { syncSourceTabs(it) }
291273
}
292274

293-
// If the user flips Root Access while the Stats view is open, re-target the poller so it
294-
// switches between full and best-effort collection without the user reopening the view.
295-
viewModelScope.launch {
296-
isRootEnabled.collect { useRoot ->
297-
statsTargetPkg?.let { statsDelegate.setTarget(it, statsTargetLabel, useRoot) }
298-
}
299-
}
300-
301275
// Register the Accessibility Receiver
302276
val filter = IntentFilter(LogKittyAccessibilityService.ACTION_FOREGROUND_APP_CHANGED)
303277
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -410,7 +384,6 @@ class MainViewModel(
410384

411385
override fun onCleared() {
412386
super.onCleared()
413-
statsDelegate.stop()
414387
try {
415388
getApplication<Application>().unregisterReceiver(receiver)
416389
} catch (e: Exception) {

0 commit comments

Comments
 (0)