Skip to content

Commit 5c20c28

Browse files
nqmgamingclaude
andcommitted
feat(telemetry): ship Firebase Analytics and Crashlytics in the Play build only
Adds a `distribution` flavor dimension to :app. `opensource` — the default, and what the GitHub release APK is built from — carries no Google Play Services libraries at all; `play` adds Analytics and Crashlytics for the Play Store AAB. Everything else about the two is identical. The `play` variants only exist when app/src/play/google-services.json is there. Without it the Firebase plugins are never applied and the task list is exactly what it was before this dimension existed, so a fresh clone builds unchanged and a Play task fails with "task not found" rather than quietly producing a Play build with Firebase missing. The file is gitignored — including at the module root, where the plugin would apply it to *every* flavor and leak the config into the open-source APK. Code under src/main only ever calls `Telemetry`; each flavor binds it to Firebase or to nothing. Events hang off BaseInstallController, where all five install backends converge: install_started and install_result, with a failure kind spelled out per case so R8 renaming can't change what a metric means. Timber warnings become Crashlytics breadcrumbs and Timber.e(throwable) a non-fatal. Nothing reports what the user installed — no package names, app names, file names or URIs. Verified by build: the opensource APK contains zero Crashlytics and measurement classes and none of the Firebase config strings, the play APK contains both, and the mapping-file upload task is wired into playRelease only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 18ba2c5 commit 5c20c28

22 files changed

Lines changed: 542 additions & 38 deletions

File tree

.github/workflows/build-debug.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ jobs:
2929
run: chmod +x gradlew
3030

3131
- name: Run Gradle Build
32-
run: ./gradlew assembleDebug
32+
run: ./gradlew :app:assembleOpensourceDebug :tv:assembleDebug
3333

3434
- name: Upload debug APKs
3535
uses: actions/upload-artifact@v4
3636
with:
3737
name: debug-apks
3838
path: |
39-
app/build/outputs/apk/debug/*.apk
39+
app/build/outputs/apk/opensource/debug/*.apk
4040
tv/build/outputs/apk/debug/*.apk
4141
retention-days: 14

.github/workflows/publish-release.yml

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ jobs:
6262
- name: Decode Play Store key
6363
run: echo "${{ secrets.PLAY_STORE_CONFIG_JSON }}" | base64 --decode > fastlane/play-store-key.json
6464

65+
# Enables the `play` flavor. Without this file :app has no play variants at all and every
66+
# bundlePlayRelease below fails with "task not found" — see docs/FIREBASE.md.
67+
- name: Decode Firebase config
68+
run: |
69+
mkdir -p app/src/play
70+
echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 --decode > app/src/play/google-services.json
71+
6572
# Build & Deploy
6673
- name: Grant execute permission
6774
run: chmod +x gradlew
@@ -86,19 +93,20 @@ jobs:
8693
continue-on-error: true
8794
run: bundle exec fastlane beta --fastlane_dir=fastlane_tv
8895

89-
# Both Play and GitHub now get the same artifact — Play gets the AAB built above
90-
# via fastlane, GitHub gets a side-loadable APK from the same single build.
96+
# Play got its AAB from the fastlane lanes above, built from the `play` flavor. GitHub
97+
# gets the `opensource` flavor instead: same app, minus Firebase Analytics and
98+
# Crashlytics, which is the build we distribute as open source.
9199
- name: Build release APK & AAB
92-
run: ./gradlew assembleRelease bundleRelease
100+
run: ./gradlew :app:assembleOpensourceRelease :app:bundleOpensourceRelease :tv:assembleRelease :tv:bundleRelease
93101

94102
# Artifacts
95103
- name: Upload release AAB & APK
96104
uses: actions/upload-artifact@v4
97105
with:
98106
name: release-build
99107
path: |
100-
app/build/outputs/bundle/release/app-release.aab
101-
app/build/outputs/apk/release/app-release.apk
108+
app/build/outputs/bundle/opensourceRelease/app-opensource-release.aab
109+
app/build/outputs/apk/opensource/release/app-opensource-release.apk
102110
tv/build/outputs/bundle/release/tv-release.aab
103111
tv/build/outputs/apk/release/tv-release.apk
104112
retention-days: 30
@@ -111,8 +119,8 @@ jobs:
111119
draft: false
112120
prerelease: false
113121
files: |
114-
app/build/outputs/apk/release/app-release.apk
115-
app/build/outputs/bundle/release/app-release.aab
122+
app/build/outputs/apk/opensource/release/app-opensource-release.apk
123+
app/build/outputs/bundle/opensourceRelease/app-opensource-release.aab
116124
tv/build/outputs/apk/release/tv-release.apk
117125
tv/build/outputs/bundle/release/tv-release.aab
118126
token: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,12 @@ vendor/downloads/
3131
InstallerX-Revived-main
3232
# android-re: decompiled APKs and analysis output (large, regenerable)
3333
android-re-workspace/
34+
35+
# Firebase config for the `play` flavor — carries our project's API key and app id, and its
36+
# presence is what enables the play variants at all. Kept out of the public repo; CI writes it
37+
# from the GOOGLE_SERVICES_JSON secret. See docs/FIREBASE.md.
38+
app/src/play/google-services.json
39+
# The module root is also on the plugin's search path, and a file there applies to *every*
40+
# flavor — which would put the config into the open-source build too. Ignored so it can't be
41+
# committed by accident; the only supported location is app/src/play/ above.
42+
app/google-services.json

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,19 @@ The project is a multi-module Gradle build:
153153
| `:tv` | application | The Android TV (10-foot, D-pad) app |
154154
| `:core` | library | Shared, UI-agnostic install/manage engine consumed by `:app`/`:tv` |
155155

156-
> A single distribution ships Shizuku, Root (libsu), and the default system
157-
> installer together — the old `store` / `full` product flavors were removed.
156+
> Shizuku, Root (libsu), and the default system installer ship together in every
157+
> build — the old `store` / `full` product flavors were removed.
158+
159+
`:app` has two flavors on a `distribution` dimension, differing only in telemetry:
160+
161+
| Flavor | Firebase Analytics + Crashlytics | Ships as |
162+
|--------------|----------------------------------|--------------------------------------|
163+
| `opensource` | no | GitHub release APK — and the default |
164+
| `play` | yes | Play Store AAB |
165+
166+
The `play` flavor needs `app/src/play/google-services.json`, which is not in the
167+
repository. Without it those variants don't exist and everything below builds the
168+
`opensource` flavor. See [docs/FIREBASE.md](docs/FIREBASE.md).
158169

159170
### Steps
160171

@@ -170,17 +181,17 @@ The project is a multi-module Gradle build:
170181
### Gradle
171182

172183
```bash
173-
# Phone app
174-
./gradlew :app:assembleDebug
175-
./gradlew :app:assembleRelease
184+
# Phone app (open-source flavor)
185+
./gradlew :app:assembleOpensourceDebug
186+
./gradlew :app:assembleOpensourceRelease
187+
188+
# Phone app (Play flavor — needs app/src/play/google-services.json)
189+
./gradlew :app:bundlePlayRelease
176190

177191
# Android TV app
178192
./gradlew :tv:assembleDebug
179193
./gradlew :tv:assembleRelease
180194

181-
# Release App Bundle (phone + TV)
182-
./gradlew assembleBundleRelease
183-
184195
# Everything at once
185196
./gradlew assembleDebug
186197
```
@@ -197,7 +208,7 @@ bundle exec fastlane build_debug
197208
# Build release APK
198209
bundle exec fastlane build_release
199210

200-
# Deploy beta to Firebase App Distribution
211+
# Deploy to Play Store closed testing track
201212
bundle exec fastlane beta
202213

203214
# Deploy to Play Store internal track

app/build.gradle.kts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
2+
import com.google.gms.googleservices.GoogleServicesPlugin.GoogleServicesPluginConfig
3+
import com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy
4+
import org.gradle.api.plugins.ExtensionAware
15
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
26
import java.util.Properties
37

@@ -10,6 +14,25 @@ plugins {
1014
id("kotlin-parcelize")
1115
}
1216

17+
// Firebase (Analytics + Crashlytics) ships in the `play` flavor only. Its config file carries
18+
// our Firebase project's keys, so it is deliberately untracked — see docs/FIREBASE.md. Without
19+
// it the `play` variants are switched off entirely and the Firebase plugins are never applied,
20+
// which is what keeps a fresh open-source checkout building exactly as it did before.
21+
val firebaseConfig = file("src/play/google-services.json")
22+
val hasFirebaseConfig = firebaseConfig.exists()
23+
24+
if (hasFirebaseConfig) {
25+
apply(plugin = "com.google.gms.google-services")
26+
apply(plugin = "com.google.firebase.crashlytics")
27+
28+
configure<GoogleServicesPluginConfig> {
29+
// `opensource` variants have no google-services.json by design. The plugin's default is
30+
// to fail the build when it can't find one for a variant, which would take the whole
31+
// open-source build down with it.
32+
missingGoogleServicesStrategy = MissingGoogleServicesStrategy.IGNORE
33+
}
34+
}
35+
1336
android {
1437
namespace = "app.pwhs.universalinstaller"
1538
compileSdk = 36
@@ -56,10 +79,32 @@ android {
5679
}
5780
}
5881

59-
// Single distribution: ships libsu for real Root install support alongside Shizuku
60-
// and the default system installer. The previous store/full split was removed —
61-
// apps in this category on Play routinely ship Root/Shizuku/Default together, so
62-
// the static-analysis concern that drove the split didn't pan out.
82+
// Both flavors ship the same installer backends — libsu for Root alongside Shizuku and the
83+
// default system installer. The dimension exists purely to keep Google's proprietary
84+
// Analytics/Crashlytics libraries out of the build we publish as open source. (An earlier
85+
// store/full split over libsu was removed for unrelated reasons; this is not that split.)
86+
flavorDimensions += "distribution"
87+
productFlavors {
88+
// What `assembleDebug` / `assembleRelease` and the IDE pick by default, and the build
89+
// the GitHub release APK comes from. No Firebase, no Google Play Services.
90+
create("opensource") {
91+
dimension = "distribution"
92+
isDefault = true
93+
if (hasFirebaseConfig) {
94+
// This flavor has no Firebase app id to upload a mapping file against, so the
95+
// upload task would fail at the end of every `assembleOpensourceRelease`.
96+
(this as ExtensionAware).extensions
97+
.configure<CrashlyticsExtension>("firebaseCrashlytics") {
98+
mappingFileUploadEnabled = false
99+
}
100+
}
101+
}
102+
// The Play Store build: same app plus Firebase Analytics and Crashlytics.
103+
create("play") {
104+
dimension = "distribution"
105+
}
106+
}
107+
63108
compileOptions {
64109
sourceCompatibility = JavaVersion.VERSION_11
65110
targetCompatibility = JavaVersion.VERSION_11
@@ -83,6 +128,16 @@ android {
83128
}
84129
}
85130

131+
androidComponents {
132+
// Drop the `play` variants when there is no google-services.json to build them against.
133+
// A contributor cloning the repo then sees exactly the task list they saw before this
134+
// dimension existed, and `assemblePlayRelease` fails with "task not found" rather than
135+
// producing a Play build with Firebase silently missing.
136+
beforeVariants(selector().withFlavor("distribution" to "play")) { variant ->
137+
variant.enable = hasFirebaseConfig
138+
}
139+
}
140+
86141
dependencies {
87142
implementation(project(":core"))
88143
implementation(libs.androidx.core.ktx)
@@ -141,6 +196,12 @@ dependencies {
141196
implementation(libs.libsu.core)
142197
implementation(libs.libsu.service)
143198

199+
// Firebase, `play` flavor only. Quoted configuration names because type-safe accessors for
200+
// flavor configurations aren't generated for the script that declares the flavor.
201+
"playImplementation"(platform(libs.firebase.bom))
202+
"playImplementation"(libs.firebase.analytics)
203+
"playImplementation"(libs.firebase.crashlytics)
204+
144205
implementation(libs.nanohttpd)
145206
implementation(libs.zxing.core)
146207
// Open-source QR scanner (Apache-2.0) for "Send to TV" — avoids proprietary ML Kit.

app/proguard-rules.pro

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,11 @@
1212
# public *;
1313
#}
1414

15-
# Uncomment this to preserve the line number information for
16-
# debugging stack traces.
17-
#-keepattributes SourceFile,LineNumberTable
18-
19-
# If you keep the line number information, uncomment this to
20-
# hide the original source file name.
21-
#-renamesourcefileattribute SourceFile
15+
# Line numbers survive R8 so Crashlytics can point at a line rather than a method. The mapping
16+
# file uploaded alongside the `play` build is what turns the renamed classes back into ours; the
17+
# renamed source file name is of no use to anyone, so it goes.
18+
-keepattributes SourceFile,LineNumberTable
19+
-renamesourcefileattribute SourceFile
2220

2321
# libsu (com.topjohnwu.superuser) — full flavor only. Harmless on store (no-op when
2422
# the class is absent). Kept defensively because JitPack-built artifacts occasionally

app/src/main/java/app/pwhs/universalinstaller/Application.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import app.pwhs.universalinstaller.di.appModule
77
import app.pwhs.universalinstaller.di.flavorModule
88
import app.pwhs.universalinstaller.presentation.install.controller.BackendSelfHeal
99
import app.pwhs.universalinstaller.presentation.install.controller.InstallerBackendFactory
10+
import app.pwhs.universalinstaller.telemetry.Telemetry
11+
import app.pwhs.universalinstaller.telemetry.createTelemetrySink
1012
import app.pwhs.universalinstaller.util.ApkFileIconFetcher
1113
import app.pwhs.universalinstaller.util.AppIconFetcher
1214
import app.pwhs.universalinstaller.util.CrashHandler
@@ -40,6 +42,25 @@ private class ReleaseTree : Timber.Tree() {
4042
}
4143
}
4244

45+
/**
46+
* Feeds the same warnings and errors [ReleaseTree] writes to logcat into the crash reporter,
47+
* so a report arrives with the run-up to the failure attached rather than a bare stack trace.
48+
*
49+
* A no-op on `opensource`, where [Telemetry] has no sink. The WARN floor is the same one
50+
* [ReleaseTree] uses and for the same reason: debug-level lines carry URIs and file names.
51+
*/
52+
private class TelemetryTree : Timber.Tree() {
53+
override fun isLoggable(tag: String?, priority: Int): Boolean = priority >= Log.WARN
54+
55+
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
56+
Telemetry.breadcrumb("${tag ?: "UniversalInstaller"}: $message")
57+
// Timber.e(throwable) is how this codebase reports a failure it recovered from. Those
58+
// are exactly the non-fatals worth seeing; warnings stay breadcrumbs so a device that
59+
// warns in a loop can't drown out the crash reports.
60+
if (priority >= Log.ERROR && t != null) Telemetry.recordException(t)
61+
}
62+
}
63+
4364
class App : Application(), SingletonImageLoader.Factory {
4465

4566
init {
@@ -61,12 +82,17 @@ class App : Application(), SingletonImageLoader.Factory {
6182

6283
override fun onCreate() {
6384
super.onCreate()
85+
// Before CrashHandler.install: that one chains to whatever handler is already default,
86+
// which on `play` is Crashlytics'. Binding the sink first also means a crash during the
87+
// rest of onCreate is still reported.
88+
Telemetry.install(createTelemetrySink(this))
6489
CrashHandler.install(this)
6590
// Release builds used to plant nothing, so Settings -> Diagnostics collected a logcat
6691
// dump containing not one line from this app. Issues #92 and #100 both arrived with a
6792
// full report attached and no clue in it. Release now keeps warnings and errors — the
6893
// lines that explain a failure — while debug keeps everything.
6994
Timber.plant(if (BuildConfig.DEBUG) Timber.DebugTree() else ReleaseTree())
95+
if (Telemetry.isCollecting) Timber.plant(TelemetryTree())
7096
startKoin{
7197
androidLogger()
7298
androidContext(this@App)

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,24 @@ object InstallErrorHelper {
119119
val info = getErrorInfo(context, failure)
120120
return "${info.title}: ${info.guidance}"
121121
}
122+
123+
/**
124+
* A stable, non-localised name for a failure kind, for telemetry.
125+
*
126+
* Deliberately not `failure::class.simpleName`: R8 renames ackpine's classes, so release
127+
* builds would report a different — and meaningless — name than debug ones. Never include
128+
* `failure.message`; it carries package and file names.
129+
*/
130+
fun failureKey(failure: InstallFailure): String = when (failure) {
131+
is InstallFailure.Aborted -> "aborted"
132+
is InstallFailure.Blocked -> "blocked"
133+
is InstallFailure.Conflict -> "conflict"
134+
is InstallFailure.Incompatible -> "incompatible"
135+
is InstallFailure.Invalid -> "invalid"
136+
is InstallFailure.Storage -> "storage"
137+
is InstallFailure.Timeout -> "timeout"
138+
is InstallFailure.Exceptional -> "exceptional"
139+
is InstallFailure.Generic -> "generic"
140+
else -> "unknown"
141+
}
122142
}

0 commit comments

Comments
 (0)